Refactoring complete: v0.4.0 architectural milestone #1

Merged
mix merged 48 commits from docs/refactoring-plan into main 2026-06-22 08:05:10 +03:00
74 changed files with 5424 additions and 2712 deletions
+15 -7
View File
@@ -9,6 +9,7 @@ Project notes:
- [Changelog](docs/CHANGELOG.md)
- [Roadmap](docs/ROADMAP.md)
- [Architecture](docs/ARCHITECTURE.md)
- [Refactoring plan](docs/REFACTORING.md)
## Features
@@ -351,14 +352,21 @@ Windows:
## 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.
- `cmd/gosentry` — entry point; starts the desktop app.
- `src/domain` — pure value types: `Job`, `Config`, `RunRecord`, `Schedule`, `JobRuntime`.
- `src/app``Service`: sole owner of job and runtime state; emits typed events to the UI.
- `src/scheduler` — pure timing loop; calls `Service.RunDue` on every tick.
- `src/runner` — shell command execution, log file writing, and log cleanup.
- `src/storage` — YAML persistence (`gosentry.yaml`, `jobs.yaml`).
- `src/platform/autostart``Manager` interface with Windows (shortcut) and Linux (XDG) implementations.
- `src/platform/desktop` — display-scale helper (Linux only).
- `src/platform/winproc` — hidden-window startup flags (Windows only).
- `src/ui` — Fyne windows, tabs, and dialogs; reads service state through events.
- `assets` — app icons embedded into the application binary.
- `scripts` — build helpers.
- `docs` — architecture notes, changelog, and roadmap.
Build outputs are written to `dist/`. The old local `bin/` directory is not used.
Build outputs are written to `dist/`.
## Dependencies
+4 -4
View File
@@ -3,15 +3,15 @@ package main
import (
"os"
"gitea.mixdep.ru/mix/gosentry/src/core"
"gitea.mixdep.ru/mix/gosentry/src/gui"
"gitea.mixdep.ru/mix/gosentry/src/domain"
"gitea.mixdep.ru/mix/gosentry/src/ui"
)
func main() {
// The executable entry point intentionally delegates all startup work to the
// GUI package. Keeping main small makes it easier to add platform-specific
// UI package. Keeping main small makes it easier to add platform-specific
// packaging later without mixing window setup, storage, and scheduler logic.
gui.Run(hasArgument(core.StartInTrayArgument))
ui.Run(hasArgument(domain.StartInTrayArgument))
}
func hasArgument(argument string) bool {
+72 -41
View File
@@ -1,73 +1,104 @@
# GoSentry Architecture
This document shows the current component interaction model. GoSentry is still a
single desktop process: the GUI, scheduler, storage, and command runner live in
one application and communicate through Go function calls and shared in-memory
job state.
This document shows the current component interaction model. GoSentry is a
single desktop process: the GUI, application service, scheduler, storage, and
command runner live in one application. They communicate through typed events
and well-defined interfaces rather than shared mutable state.
## Package Map
```
cmd/gosentry entry point — starts the UI
src/
domain/ pure value types: Job, Config, RunRecord, Schedule, JobRuntime
app/ Service — sole owner of job/runtime state; emits typed Events
scheduler/ pure timing loop; calls app.Service.RunDue on every tick
runner/ shell command execution + log file writing + cleanup
storage/ YAML persistence (gosentry.yaml, jobs.yaml)
platform/
autostart/ Manager interface + Windows (shortcut) and Linux (XDG) impls
desktop/ display-scale helper (Linux only)
winproc/ hidden-window startup flags (Windows only)
ui/ Fyne windows, tabs, and dialogs; reads service via Events
```
## Component Diagram
```mermaid
flowchart LR
user["Desktop user"]
gui["src/gui - Fyne windows, tabs, dialogs"]
store["src/core Store - YAML config and jobs"]
scheduler["src/core Scheduler - @every and cron timing"]
runner["src/core Runner - shell command execution"]
autostart["src/core Autostart - Windows Startup shortcut / Linux desktop startup"]
config["gosentry.yaml - application settings"]
jobs["jobs.yaml - job definitions"]
logs["logs_dir - per-run command output logs"]
shell["Platform shell - cmd.exe /C or sh -c"]
ui["src/ui\nFyne windows, tabs, dialogs"]
svc["src/app Service\nsole owner of job + runtime state"]
store["src/storage Store\nYAML config and jobs"]
sched["src/scheduler Scheduler\npure timing loop"]
runner["src/runner\nshell command execution"]
autostart["src/platform/autostart Manager\nWindows shortcut / Linux XDG"]
config["gosentry.yaml\napplication settings"]
jobs["jobs.yaml\njob definitions"]
logs["logs_dir\nper-run command output logs"]
shell["Platform shell\ncmd.exe /C or sh -c"]
user -->|"edits jobs, settings, runs commands"| gui
gui -->|"OpenStore, SaveConfig, SaveJobs"| store
user -->|"edits jobs, settings, runs commands"| ui
ui -->|"CreateJob, UpdateJob, DeleteJob, RunNow, UpdateSettings, …"| svc
svc -->|"SaveJobs, SaveConfig, LoadJobs, LoadConfig"| store
store -->|"read/write"| config
store -->|"read/write"| jobs
gui -->|"Start, Pause, RunNow, RefreshSchedule"| scheduler
scheduler -->|"SaveJobs after state changes"| store
scheduler -->|"RunJob(trigger)"| runner
svc -->|"Start(RunDue)"| sched
sched -->|"RunDue(now)"| svc
svc -->|"RunJob"| runner
runner -->|"execute command"| shell
runner -->|"write stdout/stderr log"| logs
runner -->|"RunRecord with status, duration, log path"| scheduler
scheduler -->|"onChange RunRecord"| gui
gui -->|"display History, command output, job state"| user
runner -->|"RunRecord"| svc
svc -->|"emit JobChanged / RunRecorded / ErrorOccurred"| ui
ui -->|"display jobs, history, status"| user
gui -->|"SetAutostart, AutostartStatus"| autostart
autostart -->|"use executable path from resolved Paths"| config
ui -->|"SetAutostart, AutostartStatus"| autostart
svc -->|"Set / Status via Manager"| autostart
```
## Main Flows
1. Startup:
The executable starts `cmd/gosentry`, which calls the GUI package. The GUI
opens the store, loads `gosentry.yaml` and `jobs.yaml`, creates the main tabs,
then starts the scheduler with the loaded job slice.
`cmd/gosentry` calls `ui.Run`, which creates an `app.Service`, opens the
store, loads `gosentry.yaml` and `jobs.yaml`, subscribes the UI to service
events, builds the main window, and calls `Service.Start` to begin the
scheduler loop.
2. Editing settings or jobs:
The GUI updates the in-memory job/config state and asks `Store` to write YAML
back to disk. Job definitions stay in one `jobs.yaml`; runtime command output
is not stored there.
The UI calls mutating methods on `app.Service` (e.g. `CreateJob`,
`UpdateJob`, `UpdateSettings`). The Service validates the request, updates
its in-memory state, persists through `storage.Store`, and emits a typed
`Event`. The UI's observer receives the event and refreshes the relevant
widget on the main thread via `fyne.Do`.
3. Scheduled run:
`Scheduler` checks due jobs on a one-second ticker. When a job is due, it marks
the job as running, saves state, and starts `Runner` asynchronously.
`scheduler.Scheduler` fires a tick every second. On each tick it calls
`Service.RunDue(now)`. The Service checks which enabled, non-paused jobs are
due, marks each as running, and launches `runner.RunJob` in a goroutine.
4. Manual run:
`Run now` calls the same scheduler path as scheduled execution, but the
resulting history record uses the `Manual` trigger.
`Run now` in the UI calls `Service.RunNow`. The Service checks that the job
exists, is not already running, and that the scheduler is not paused, then
executes `runner.RunJob` with the `Manual` trigger.
5. Command execution:
`Runner` executes the command through the platform shell, captures stdout and
stderr, writes one timestamped `.log` file, and returns a `RunRecord`.
`runner.RunJob` builds the platform-specific invocation, executes the
command through the platform shell, captures stdout and stderr, writes one
timestamped `.log` file, and returns a `domain.RunRecord`.
6. History update:
The scheduler receives the `RunRecord`, updates the matching job, saves YAML,
runs log cleanup, and calls the GUI callback so the `History` tab refreshes.
When a run goroutine completes, `Service` updates the job's runtime, saves
YAML, triggers log cleanup, and emits `RunRecorded`. The UI observer appends
the record to the History tab.
7. Autostart:
The Settings tab calls the platform autostart implementation. Windows uses a
shortcut in the current user's Startup folder. Linux uses a desktop-session
startup entry. Both autostart mechanisms pass `--start-in-tray`, so the
scheduler starts without opening the main window after sign-in.
`UpdateSettings` in the Service calls `autostart.Manager.Set`. The Manager
interface has two implementations: Windows writes a `.lnk` shortcut to the
user Startup folder; Linux writes an XDG Autostart `.desktop` file. Both
entries pass `--start-in-tray`.
8. Error surfacing:
Background errors (failed YAML saves, cleanup errors) are emitted as
`ErrorOccurred` events and displayed in the UI status area, rather than
being silently discarded.
+62
View File
@@ -2,6 +2,68 @@
All notable GoSentry changes are recorded in this file.
## 0.4.0 - 2026-06-22
**Architectural milestone: completed refactoring and reached target architecture.**
- Completed Phase 5 refactoring: hardening, testing, and documentation.
- Surface all save/cleanup errors from service and storage; no more silently swallowed `_ = ...` on persistence.
- Introduced `platform/autostart.Manager` interface with per-platform implementations (Windows, Linux, other); inject into service instead of calling package functions.
- Filled test gaps: folder filtering, log cleanup (count and age), settings persistence and migration, concurrent run prevention.
- Verified `go test -race ./...` passes on Windows; no data races in the refactored codebase.
- Updated `docs/ARCHITECTURE.md`, `docs/TESTS.md`, and README with final package structure and build/test instructions.
- **Refactoring target reached:** Service layer owns all state and is the sole writer; UI is a thin view marshaling updates via `fyne.Do`; core engines are stateless and injectable; domain layer is pure with no test noise.
- Known follow-ups recorded in `ROADMAP.md`:
- Linux test build is currently broken (Windows-only test symbols need `//go:build windows`); will fix separately.
- File-size soft limits exceeded in a few places; revisit when next editing those files.
- No observable behavior changes.
## 0.3.6 - 2026-06-22
- Completed Phase 4 refactoring: carved up the GUI into focused, testable components.
- Renamed `src/gui``src/ui` and split monolithic `app.go` into `run.go` (lifecycle) and `mainwindow.go` (view construction).
- Extracted view components into separate files: `jobs_view.go`, `job_dialog.go`, `history_view.go`, `settings_view.go`.
- Extracted platform wiring into separate files: `tray.go`, `singleinstance.go`, `layout.go`.
- Removed forbidden platform imports (autostart, desktop, paths) from `src/ui`; all platform concerns now flow through `app.Service`.
- Upgraded Fyne from v2.5.3 to v2.6.3 to enable `fyne.Do` for cross-thread widget marshaling (resolves concurrency issue #4).
- Added `docs/PERFORMANCE.md` with measured startup-time analysis: the ~290ms increase from Phase 4 is entirely the Fyne 2.6.3 upgrade's `w.Show()` cost, not the restructuring.
- Added `docs/PERFORMANCE.md` and wired post-Fyne-2.7.x re-check into `ROADMAP.md`.
- No observable behavior changes; continued internal refactoring toward separated concerns and testability.
## 0.3.5 - 2026-06-19
- Completed Phase 3 refactoring: application service and state management.
- Added `app.Service` as the single owner of application state (job registry, settings, run history).
- Implemented event-driven observer dispatch: Services can emit events (JobAdded, JobChanged, etc.) to decouple state changes from UI updates.
- Added `app.Clock` interface for testable time-dependent behavior in scheduler and run tracking.
- Converted scheduler to drive app.Service instead of directly managing domain state.
- Created `app.Format` helpers for display rendering (job names, schedule summaries, run times).
- Added comprehensive unit tests for app.Service and supporting types.
- No observable behavior changes; continued internal refactoring toward separated concerns and testability.
## 0.3.4 - 2026-06-19
- Completed Phase 2 refactoring: domain cleanup and value object extraction.
- Split durable job configuration (`domain.Job`) from transient execution state (`domain.JobRuntime`), keyed by job ID.
- Added `domain.Schedule` value object with `Parse`, `Validate`, and `Next(time.Time)` methods for cron/interval parsing.
- Migrated scheduler to parse schedules once at load/edit instead of per tick, removing duplicated parsing.
- Made `RunJob` pure: runner no longer mutates jobs, returning only `RunRecord` for the caller to fold into runtime state.
- Simplified `storage.normalizeJobs` to touch only durable configuration; runtime initialization moved to `domain.NewRuntime`.
- No observable behavior changes; continued internal refactoring toward separated concerns.
## 0.3.3 - 2026-06-18
- Completed Phase 1 refactoring: split the flat `src/core` package into specialized, focused packages:
- `src/domain` for pure types (Job, RunRecord, Config)
- `src/storage` for persistence (Load/Save, Paths, YAML helpers)
- `src/runner` for job execution (RunJob orchestration, logging, exit codes)
- `src/scheduler` for timing loop
- `src/platform/winproc` for cross-platform hidden window configuration
- `src/platform/autostart` for system autostart integration
- `src/platform/desktop` for desktop environment integration
- `src/app` for application-level code (Version, future Service layer)
- No observable behavior changes; internal structure improvements only.
## 0.3.1 - 2026-06-17
- Changed startup timing in History to measure until the main window is actually shown instead of stopping during UI construction.
+57
View File
@@ -0,0 +1,57 @@
# 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.
### Next check
Re-measure with the same method after the planned **Fyne 2.6.3 → 2.7.x upgrade**
(see [ROADMAP.md](ROADMAP.md) → Tray Interaction). The goal is to learn whether
2.7's driver/threading changes recover any of the ~290 ms `w.Show()` cost or
hold it steady. Reuse the `GOSENTRY_TIMING` instrumentation pattern above; do not
commit the temporary timers.
+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`
- [x] T4.7 — Confirm app.go is gone; smoke test both platforms
### Phase 5 — Hardening & docs
- [x] T5.1 — Surface errors from service + storage
- [x] T5.2 — Introduce `autostart.Manager` interface
- [x] T5.3 — Fill test gaps (folder filtering, cleanup, migration, concurrency)
- [x] T5.4 — Run `go test -race ./...` clean on both platforms
- [x] 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. |
+44 -6
View File
@@ -2,6 +2,32 @@
This file tracks planned GoSentry work that is larger than a single bug fix.
## Refactoring Follow-Ups
Loose ends found while verifying the [refactoring plan](REFACTORING.md) against
its Definition of done. The architecture target is reached and verified on
Windows, but the items below remain.
- **Linux test build is broken (correctness, not cosmetic).** `src/runner/runner_test.go`
is a shared (untagged) test file that references Windows-only symbols
(`SysProcAttr.HideWindow`, `SysProcAttr.CmdLine`, `windowsShellCommandLine`).
The `runtime.GOOS != "windows"` guards are runtime skips and cannot save a file
that does not *compile*, so `go test ./...` fails to build on Linux. This
contradicts T5.4 ("go test -race clean on both platforms") and the DoD's
"green on Windows and Linux." Fix: move the Windows-only tests
(`TestShellCommandHidesWindow`, `TestShellCommandUsesWindowsSafeQuoting`, and any
peers touching `SysProcAttr` / `windowsShellCommandLine`) into a new
`src/runner/runner_windows_test.go` guarded by `//go:build windows`.
- **File-size guidelines exceeded.** The DoD asks for no `src/ui` file over ~250
lines and no single file over ~400:
- `src/ui/jobs_view.go` — 415 lines (over both the ~250 UI target and the ~400 cap).
- `src/app/operations.go` — 486 lines (over ~400).
- `src/app/operations_test.go` (536) and `src/runner/runner_test.go` (421) also
exceed 400 if the cap is read to include test files.
These are soft ("~") limits; revisit when next touching those files rather than
splitting purely for line count.
## Post-Field-Test Cleanup
After real-world use confirms the main workflows, clean up temporary
@@ -24,13 +50,25 @@ Cleanup checklist:
## Tray Interaction
Improve tray icon interaction after choosing a tray backend path.
Improve tray icon interaction: click the tray icon to show and focus the main
window.
- Add double-click on the tray icon to show and focus the main window.
- Current Fyne 2.5.3 desktop tray API exposes menu and icon setup, but does not
expose click or double-click callbacks for the tray icon itself.
- Revisit when Fyne exposes this callback, or evaluate a small platform-specific
tray integration if the behavior becomes important enough.
- 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.
- As part of that upgrade, **re-measure startup time** with the `GOSENTRY_TIMING`
method recorded in [PERFORMANCE.md](PERFORMANCE.md). The Fyne 2.5.3 -> 2.6.3 bump
added ~290 ms to startup, all inside `w.Show()`; check whether 2.7 recovers any
of it or holds steady, and append the result to PERFORMANCE.md.
- 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.
## Delivery And Packaging
+268 -89
View File
@@ -1,146 +1,326 @@
# GoSentry Test Suite
All tests are located alongside source code in the `src/core/` package. Tests follow Go conventions with `*_test.go` filename patterns.
All tests are located alongside source code in their respective packages under `src/`. Tests follow Go conventions with `*_test.go` filename patterns.
## Running Tests
### Using the test scripts
The repository provides convenience scripts to run all tests with static analysis:
**Unix/Linux/macOS:**
```bash
./scripts/test.sh
```
**Windows:**
```bash
scripts\test.bat
```
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
### Manual test commands
Run all tests:
```bash
go test ./...
```
Run all tests with race detection:
```bash
go test -race ./...
```
Run tests with verbose output:
```bash
go test -v ./...
```
Run a specific test by name:
```bash
go test -run TestRunJobWritesLogFile ./src/runner
```
Run tests with code coverage:
```bash
go test -cover ./src/runner
go test -coverprofile=coverage.out ./src/runner
go tool cover -html=coverage.out
```
---
## Test Files Overview
### store_test.go
**Location:** `src/core/store_test.go`
**Package:** `core`
### src/domain/schedule_test.go
Tests YAML serialization and storage behavior.
**Package:** `domain`
Tests schedule parsing and validation.
| Test | Purpose |
|------|---------|
| `TestJobsYAMLDoesNotPersistRuntimeNoise` | Verifies that `jobs.yaml` does not persist runtime state fields (LastRun, NextRun, LastState, Output, etc.). Only job definitions are stored; runtime data is kept in memory and log files. |
| `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. |
---
### scheduler_test.go
**Location:** `src/core/scheduler_test.go`
**Package:** `core`
### src/app/service_test.go
Tests schedule parsing and job invocation output formatting.
**Package:** `app`
Tests `Service` construction and the state-accessor contract.
| Test | Purpose |
|------|---------|
| `TestNextRunTimeSupportsEvery` | Verifies `@every` duration syntax (e.g., `@every 10s`) correctly calculates next run time. Tests with 10-second interval. |
| `TestNextRunTimeSupportsCron` | Verifies standard 5-field cron expressions (e.g., `*/5 * * * *`) correctly calculate next run time. Tests 5-minute interval. |
| `TestRunningOutputIncludesInvocation` | Verifies the running job output header includes all relevant invocation details: command, arguments, success exit codes, start time, and trigger type. |
| `TestNewServiceBuildsRuntimePerJob` | Verifies that `NewService` creates a `JobRuntime` entry for every loaded job. |
| `TestJobsReturnsCopy` | Verifies that `Service.Jobs` returns a defensive copy so callers cannot mutate internal state. |
| `TestStoreReturnsWiredStore` | Verifies that `Service.Store` returns the injected `storage.Store`. |
---
### runner_test.go
**Location:** `src/core/runner_test.go`
**Package:** `core`
### 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 YAML, and emits `JobChanged`. |
| `TestCreateJobValidates` | Verifies that `CreateJob` rejects jobs with an invalid schedule. |
| `TestUpdateJobKeepsRuntimeAndReflectsDisable` | Verifies that `UpdateJob` preserves existing runtime state and disables a job correctly. |
| `TestUpdateJobReenablesPausedJob` | Verifies that re-enabling a previously-disabled job clears the paused runtime state. |
| `TestRuntimeLazilyRecreated` | Verifies that `UpdateJob` recreates a missing runtime entry rather than panicking. |
| `TestUpdateJobNotFound` | Verifies that `UpdateJob` returns an error for an unknown job ID. |
| `TestDeleteJobRemovesEverything` | Verifies that `DeleteJob` removes the job from the slice, the runtime map, and the schedule cache. |
| `TestDeleteJobNotFound` | Verifies that `DeleteJob` returns an error for an unknown job ID. |
| `TestSetEnabledNotFound` | Verifies that `SetEnabled` returns an error for an unknown job ID. |
| `TestSetEnabledToggles` | Verifies that `SetEnabled` flips the enabled flag and persists the change. |
#### Global pause / run-now / run-due
| Test | Purpose |
|------|---------|
| `TestSetGlobalPauseUpdatesRuntimesAndEmits` | Verifies that `SetGlobalPause` updates all job runtimes, emits `SchedulerStateChanged`, and persists state. |
| `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. |
| `TestRunNowRefusedWhilePaused` | Verifies that `RunNow` is rejected when the global pause flag is set. |
| `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. |
| `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. |
| `TestPrependLogCapsActivityList` | Verifies that the activity log never grows beyond its maximum cap. |
---
### src/app/events_test.go
**Package:** `app`
Tests the event-emission and observer-subscription machinery.
| Test | Purpose |
|------|---------|
| `TestEmitDeliversToAllObserversInOrder` | Verifies that all registered observers receive emitted events in registration order. |
| `TestEmitWithNoObserversIsNoop` | Verifies that emitting an event with no observers does not panic. |
| `TestObserverCanReadServiceState` | Verifies that an observer called by `emit` can safely read Service state (jobs, runtimes). |
---
### src/app/format_test.go
**Package:** `app`
Tests display-formatting helpers used by the UI.
| Test | Purpose |
|------|---------|
| `TestStatusText` | Verifies that job status codes map to the correct display strings. |
| `TestEventText` | Verifies trigger-type labels for scheduled, manual, and UI triggers. |
| `TestDisplayFolder` | Verifies that an empty folder string shows "No folder". |
| `TestDisplayArguments` | Verifies that an empty arguments string shows "None". |
| `TestDisplaySuccessExitCodes` | Verifies that an empty exit-codes string shows the default "0". |
| `TestDisplayRunMode` | Verifies run-mode labels for normal and start-only modes. |
| `TestDisplayInvocation` | Verifies that the full invocation display string includes command, arguments, and exit codes. |
| `TestDisplayIndex` | Verifies that the display index is one-based (job slice index + 1). |
---
### src/storage/store_test.go
**Package:** `storage`
Tests YAML round-tripping, migration, and default generation.
| Test | Purpose |
|------|---------|
| `TestJobsRoundTrip` | Verifies that jobs saved to YAML are reloaded with identical field values. |
| `TestConfigRoundTrip` | Verifies that settings saved to YAML are reloaded with identical field values. |
| `TestNormalizeJobsFillsDefaults` | Verifies that `normalizeJobs` assigns sequential IDs and sets default enabled state for jobs missing those fields. |
| `TestLoadOrCreateConfigMigratesFromLegacy` | Verifies that the old flat-config format is migrated to the current `gosentry.yaml` + `jobs_dir` layout on first load. |
| `TestLoadOrCreateConfigCreatesDefaultsOnFirstRun` | Verifies that a missing config file is created with sane defaults and a sample job. |
| `TestJobsYAMLDoesNotPersistRuntimeNoise` | Verifies that `jobs.yaml` does not persist runtime state (LastRun, NextRun, etc.). Only durable job fields are stored. |
---
### src/scheduler/scheduler_test.go
**Package:** `scheduler`
Tests the timing-loop contract using a fake clock.
| Test | Purpose |
|------|---------|
| `TestSchedulerCallsTickWithClockNow` | Verifies that the scheduler calls the injected tick function with the wall-clock time returned by the fake Clock. |
| `TestSchedulerStopReleasesClock` | Verifies that `Stop` terminates the tick loop and releases the Clock without hanging. |
---
### src/runner/runner_test.go
**Package:** `runner`
Tests command execution, exit code handling, output capture, and Windows-specific process behavior.
#### Log File Tests
#### Log file tests
| Test | Purpose |
|------|---------|
| `TestRunJobWritesLogFile` | Verifies that each job execution creates a `.log` file in the configured logs directory with sanitized job name in filename and proper metadata (trigger type, job name, command output). |
| `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. |
#### Command Execution Tests
#### Output formatting
| Test | Purpose |
|------|---------|
| `TestFormatOutput` | Verifies that stdout and stderr are combined with section labels in the formatted output. |
| `TestFormatOutputEmptyStreams` | Verifies that empty stdout/stderr streams are omitted from the formatted output. |
| `TestLogArguments` | Verifies that arguments are included in the log header and absent when the arguments field is empty. |
| `TestSanitizeFileName` | Verifies that spaces and special characters in a job name are replaced to produce a safe filename segment. |
#### Command execution
| Test | Platform | Purpose |
|------|----------|---------|
| `TestRunJobRunsQuotedWindowsExecutable` | Windows | Verifies that executable paths with quotes (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. |
| `TestRunJobRunsQuotedWindowsExecutable` | Windows | Verifies that executable paths with quotes are executed correctly via `cmd.exe`. |
| `TestRunJobRunsUnquotedWindowsProgramPathWithSpaces` | Windows | Verifies that unquoted executable paths with spaces are quoted and executed correctly. |
| `TestRunJobRunsWindowsCommandWithSeparateArguments` | Windows | Verifies that command and arguments from the Job struct are combined and executed correctly. |
#### Exit Code Handling Tests
#### Exit code handling
| Test | Purpose |
|------|---------|
| `TestRunJobAcceptsConfiguredExitCode` | Verifies that exit codes listed in `SuccessExitCodes` (e.g., `"0,1"`) result in "OK" status even if nonzero. Includes detail message about accepted exit code. |
| `TestRunJobRejectsUnconfiguredExitCode` | Verifies that exit codes not listed in `SuccessExitCodes` result in "Failed" status with exit code detail. |
| `TestRunJobAcceptsConfiguredExitCode` | Verifies that exit codes in `SuccessExitCodes` result in "OK" status even when nonzero. |
| `TestRunJobRejectsUnconfiguredExitCode` | Verifies that exit codes absent from `SuccessExitCodes` result in "Failed" status. |
#### Start-Only Mode Tests
#### Start-only mode
| Test | Purpose |
|------|---------|
| `TestRunJobStartOnlyDoesNotWaitForExitCode` | Verifies that jobs with `StartOnly: true` launch the process and return "OK" immediately without waiting for process exit or checking exit code. |
| `TestRunJobStartOnlyReportsStartFailure` | Verifies that jobs with `StartOnly: true` still report "Failed" if the process fails to start (e.g., executable not found). |
| `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. |
#### Utility Function Tests
#### Utility / Windows invocation
| 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. |
| `TestParseExitCodes` | All | Verifies that mixed-separator exit-code strings (comma, semicolon, newline) are parsed correctly. |
| `TestDirectCommandDoesNotHideWindow` | Windows | Verifies that direct executable commands do not request hidden-window startup. |
| `TestShellCommandHidesWindow` | Windows | Verifies that shell commands request hidden-window startup to prevent console flash. |
| `TestShellCommandUsesWindowsSafeQuoting` | Windows | Verifies `cmd.exe /S /C` quoting for paths with spaces and special characters. |
| `TestWindowsShellCommandLineQuotesUnquotedProgramPath` | Windows | Verifies that unquoted program paths in shell commands are quoted while preserving already-quoted arguments. |
---
### autostart_windows_test.go
**Location:** `src/core/autostart_windows_test.go`
**Package:** `core`
**Build Tags:** `//go:build windows` (Windows only)
### src/runner/cleanup_test.go
Tests Windows autostart entry creation via shortcuts in the Startup folder.
**Package:** `runner`
Tests log-file cleanup by age and by count.
| Test | Purpose |
|------|---------|
| `TestParseRegistryRunValue` | Verifies that legacy Windows Registry `Run` entry values are correctly parsed from `reg query` output (for migration/cleanup). |
| `TestSameWindowsPathIgnoresCaseAndQuotes` | Verifies that Windows path comparison is case-insensitive and handles quote marks correctly (e.g., `"D:\..."` matches `d:\...`). |
| `TestSameWindowsPathHandlesSpaces` | Verifies that Windows path comparison correctly matches paths with spaces both with and without quotes. |
| `TestStartupShortcutPathUsesUserStartupFolder` | Verifies that the startup shortcut path resolves to the user's Startup folder using `%APPDATA%` environment variable. |
| `TestCreateStartupShortcutHandlesSpaces` | Verifies that `.lnk` shortcut files are created with correct `TargetPath` and `Arguments` (--start-in-tray) even when target path contains spaces. |
| `TestCleanupLogsMissingDirReturnsNil` | Verifies that cleanup returns nil (not an error) when the logs directory does not exist. |
| `TestCleanupLogsRemovesFilesPastMaxAge` | Verifies that `.log` files older than `MaxLogAgeDays` are deleted. |
| `TestCleanupLogsKeepsFilesWithinAgeLimit` | Verifies that `.log` files within the age limit are retained. |
| `TestCleanupLogsByCountDeletesOldest` | Verifies that when file count exceeds `MaxLogFiles`, the oldest files are removed first. |
| `TestCleanupLogsNonLogFilesNotDeleted` | Verifies that non-`.log` files in the logs directory are never deleted by cleanup. |
| `TestCleanupLogsSubdirsNotDeleted` | Verifies that subdirectories inside the logs directory are not deleted by cleanup. |
| `TestCleanupLogsZeroLimitsDisableBothPolicies` | Verifies that setting both limits to zero disables both the age and count cleanup policies. |
---
### autostart_linux_test.go
**Location:** `src/core/autostart_linux_test.go`
**Package:** `core`
**Build Tags:** `//go:build linux` (Linux only)
### src/platform/autostart/autostart_windows_test.go
Tests Linux autostart entry creation via XDG Desktop Entry files.
**Location:** `src/platform/autostart/autostart_windows_test.go`
**Build Tags:** `//go:build windows`
Tests Windows autostart via shortcuts in the Startup folder.
| Test | Purpose |
|------|---------|
| `TestLinuxAutostartStartsInTray` | Verifies that the XDG Desktop Entry is created with the `--start-in-tray` argument in the `Exec=` field, so scheduled jobs run immediately after login without displaying the window. |
| `TestLinuxAutostartRemovesLegacyDesktopEntry` | Verifies that legacy autostart entries (from old PySentry implementation) are cleaned up when enabling autostart through the new system. |
| `TestParseRegistryRunValue` | Verifies that legacy `HKCU\...\Run` entry values are parsed correctly from `reg query` output (for migration/cleanup). |
| `TestSameWindowsPathIgnoresCaseAndQuotes` | Verifies that Windows path comparison is case-insensitive and handles quote marks correctly. |
| `TestSameWindowsPathHandlesSpaces` | Verifies that Windows path comparison matches paths with and without surrounding quotes. |
| `TestSameWindowsPathStripsExtendedLengthPrefix` | Verifies that `\\?\`-prefixed paths are compared correctly after stripping the prefix. |
| `TestSameWindowsPathMatchesShortNameViaFilesystem` | Verifies that 8.3 short names are resolved to long names for comparison. |
| `TestStartupShortcutPathUsesUserStartupFolder` | Verifies that the shortcut path resolves into `%APPDATA%\Microsoft\Windows\Start Menu\Programs\Startup`. |
| `TestCreateStartupShortcutHandlesCyrillicPath` | Verifies that `.lnk` files are created correctly when the executable path contains Cyrillic characters. |
| `TestCreateStartupShortcutHandlesSpaces` | Verifies that `.lnk` files are created with correct `TargetPath` and `--start-in-tray` arguments when the path contains spaces. |
---
## Running Tests
### src/platform/autostart/autostart_linux_test.go
### Run all tests in the package
```bash
cd D:\Local\Git\gosentry
go test ./src/core
```
**Location:** `src/platform/autostart/autostart_linux_test.go`
**Build Tags:** `//go:build linux`
### Run tests with verbose output
```bash
go test -v ./src/core
```
Tests Linux autostart via XDG Desktop Entry files.
### Run specific test by name
```bash
go test -run TestRunJobWritesLogFile ./src/core
```
| Test | Purpose |
|------|---------|
| `TestLinuxAutostartStartsInTray` | Verifies that the XDG Desktop Entry is created with `--start-in-tray` in the `Exec=` field. |
| `TestLinuxAutostartRemovesLegacyDesktopEntry` | Verifies that enabling autostart also removes legacy PySentry service files left by earlier builds. |
### Run Windows-only tests (on Windows)
```bash
go test -v ./src/core # Windows build tags are active
```
---
### Run Linux-only tests (on Linux)
```bash
go test -v ./src/core # Linux build tags are active
```
### src/ui/jobs_view_test.go
### Run with code coverage
```bash
go test -cover ./src/core
go test -coverprofile=coverage.out ./src/core
go tool cover -html=coverage.out
```
**Package:** `ui`
Tests pure helper functions in the jobs view (no Fyne widget construction).
| Test | Purpose |
|------|---------|
| `TestFilterValue` | Verifies that `filterValue` returns the correct display string for the current folder filter. |
| `TestFolderOptionsAlwaysIncludesSentinels` | Verifies that the folder filter list always starts with "All" and "No folder" sentinel entries. |
| `TestFolderOptionsAppendsUniqueFolders` | Verifies that folder names from the job list are appended once each, in order, without duplicates. |
| `TestFilteredJobIndexesAll` | Verifies that the "All" filter returns indexes for every job. |
| `TestFilteredJobIndexesByNamedFolder` | Verifies that filtering by a named folder returns only jobs in that folder. |
| `TestFilteredJobIndexesNoFolder` | Verifies that the "No folder" filter returns only jobs with an empty folder field. |
| `TestFilteredJobIndexesEmptySlice` | Verifies that filtering an empty job slice returns an empty index list. |
---
@@ -150,20 +330,19 @@ go tool cover -html=coverage.out
2. **Cross-platform** — Platform-specific tests use `//go:build` tags and `runtime.GOOS` checks to skip when not applicable.
3. **Exit Code Flexibility** — The `SuccessExitCodes` field allows jobs to treat nonzero exit codes as success, tested explicitly.
3. **Fake clocks and runners** — The scheduler is exercised with an injected fake `Clock`; the service operations tests inject a fake `runJob` function to avoid spawning real processes.
4. **Path Handling** — Extensive tests cover Windows path quoting, spaces in paths, and case-insensitive matching to avoid subtle shell escaping bugs.
4. **Event-driven correctness**`app` tests subscribe to the event bus and assert that the expected events are emitted, rather than inspecting internal fields directly.
5. **Start-Only Mode** — Special handling for long-running processes that should be launched but not waited on, tested separately from normal execution flow.
5. **Exit Code Flexibility** — The `SuccessExitCodes` field allows jobs to treat nonzero exit codes as success, tested explicitly.
6. **Path Handling** — Extensive tests cover Windows path quoting, spaces in paths, and case-insensitive matching to avoid subtle shell escaping bugs.
7. **Start-Only Mode** — Special handling for long-running processes that should be launched but not waited on, tested separately from normal execution flow.
---
## Future Test Coverage Gaps
## Remaining Test Coverage Gaps
Potential areas for additional tests:
- Job group/folder filtering and persistence
- Log cleanup (max file count and max age)
- Settings persistence and migration
- GUI integration tests (currently untested)
- Concurrent job execution
- Job history and run record storage
- GUI integration tests — Fyne widget interaction is not yet tested end-to-end
- Job history on-disk retrieval — RunRecord file reading is not covered
+22 -19
View File
@@ -3,7 +3,7 @@ module gitea.mixdep.ru/mix/gosentry
go 1.22
require (
fyne.io/fyne/v2 v2.5.3
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
)
@@ -13,29 +13,32 @@ require (
github.com/BurntSushi/toml v1.4.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/fredbi/uri v1.1.0 // indirect
github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/fyne-io/gl-js v0.0.0-20220119005834-d2da28d9ccfe // indirect
github.com/fyne-io/glfw-js v0.0.0-20241126112943-313d8a0fe1d0 // indirect
github.com/fyne-io/image v0.0.0-20220602074514-4956b0afb3d2 // indirect
github.com/go-gl/gl v0.0.0-20211210172815-726fda9656d6 // indirect
github.com/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.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.0 // indirect
github.com/go-text/typesetting v0.2.0 // indirect
github.com/go-text/typesetting v0.2.1 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/gopherjs/gopherjs v1.17.2 // indirect
github.com/jeandeaual/go-locale v0.0.0-20240223122105-ce5225dcaa49 // indirect
github.com/jsummers/gobmp v0.0.0-20151104160322-e2ba15ffa76e // indirect
github.com/nicksnyder/go-i18n/v2 v2.4.0 // indirect
github.com/hack-pad/go-indexeddb v0.3.2 // indirect
github.com/hack-pad/safejs v0.1.0 // indirect
github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade // indirect
github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect
github.com/nicksnyder/go-i18n/v2 v2.5.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rymdport/portal v0.3.0 // indirect
github.com/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.8.4 // indirect
github.com/yuin/goldmark v1.7.1 // indirect
golang.org/x/image v0.18.0 // indirect
golang.org/x/mobile v0.0.0-20231127183840-76ac6878050a // indirect
golang.org/x/net v0.25.0 // indirect
golang.org/x/sys v0.20.0 // indirect
golang.org/x/text v0.16.0 // indirect
github.com/stretchr/testify v1.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
golang.org/x/sys v0.30.0 // indirect
golang.org/x/text v0.22.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+47 -623
View File
@@ -1,660 +1,84 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU=
cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU=
cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY=
cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc=
cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0=
cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To=
cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4=
cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M=
cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc=
cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk=
cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs=
cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc=
cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY=
cloud.google.com/go v0.72.0/go.mod h1:M+5Vjvlc2wnp6tjzE102Dw08nGShTscUx2nZMufOKPI=
cloud.google.com/go v0.74.0/go.mod h1:VV1xSbzvo+9QJOxLDaJfTjx5e+MePCpCWwvftOeQmWk=
cloud.google.com/go v0.78.0/go.mod h1:QjdrLG0uq+YwhjoVOLsS1t7TW8fs36kLs4XO5R5ECHg=
cloud.google.com/go v0.79.0/go.mod h1:3bzgcEeQlzbuEAYu4mrWhKqWjmpprinYgKJLgKHnbb8=
cloud.google.com/go v0.81.0/go.mod h1:mk/AM35KwGk/Nm2YSeZbxXdrNK3KZOYHmLkOqC2V6E0=
cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=
cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE=
cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc=
cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg=
cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc=
cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ=
cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE=
cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk=
cloud.google.com/go/firestore v1.1.0/go.mod h1:ulACoGHTpvq5r8rxGJ4ddJZBZqakUQqClKRT5SZwBmk=
cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I=
cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw=
cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA=
cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU=
cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw=
cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos=
cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk=
cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs=
cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0=
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
fyne.io/fyne/v2 v2.5.3 h1:k6LjZx6EzRZhClsuzy6vucLZBstdH2USDGHSGWq8ly8=
fyne.io/fyne/v2 v2.5.3/go.mod h1:0GOXKqyvNwk3DLmsFu9v0oYM0ZcD1ysGnlHCerKoAmo=
fyne.io/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 v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0=
github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o=
github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY=
github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs=
github.com/bketelsen/crypt v0.0.4/go.mod h1:aI6NrJ0pMGgvZKL1iVgXLnfIFJtfV+bKCoqOes/6LfM=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
github.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5ynNVH9qI8YYLbd1fK2po=
github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk=
github.com/envoyproxy/go-control-plane v0.9.9-0.20210217033140-668b12f5399d/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
github.com/felixge/fgprof v0.9.3 h1:VvyZxILNuCiUCSXtPtYmmtGvb65nqXh2QFWc0Wpf2/g=
github.com/felixge/fgprof v0.9.3/go.mod h1:RdbpDgzqYVh/T9fPELJyV7EYJuHB55UTEULNun8eiPw=
github.com/fredbi/uri v1.1.0 h1:OqLpTXtyRg9ABReqvDGdJPqZUxs8cyBDOMXBbskCaB8=
github.com/fredbi/uri v1.1.0/go.mod h1:aYTUoAXBOq7BLfVJ8GnKmfcuURosB1xyHDIfWeC/iW4=
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
github.com/fyne-io/gl-js v0.0.0-20220119005834-d2da28d9ccfe h1:A/wiwvQ0CAjPkuJytaD+SsXkPU0asQ+guQEIg1BJGX4=
github.com/fyne-io/gl-js v0.0.0-20220119005834-d2da28d9ccfe/go.mod h1:d4clgH0/GrRwWjRzJJQXxT/h1TyuNSfF/X64zb/3Ggg=
github.com/fyne-io/glfw-js v0.0.0-20241126112943-313d8a0fe1d0 h1:/1YRWFv9bAWkoo3SuxpFfzpXH0D/bQnTjNXyF4ih7Os=
github.com/fyne-io/glfw-js v0.0.0-20241126112943-313d8a0fe1d0/go.mod h1:gsGA2dotD4v0SR6PmPCYvS9JuOeMwAtmfvDE7mbYXMY=
github.com/fyne-io/image v0.0.0-20220602074514-4956b0afb3d2 h1:hnLq+55b7Zh7/2IRzWCpiTcAvjv/P8ERF+N7+xXbZhk=
github.com/fyne-io/image v0.0.0-20220602074514-4956b0afb3d2/go.mod h1:eO7W361vmlPOrykIg+Rsh1SZ3tQBaOsfzZhsIOb/Lm0=
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
github.com/go-gl/gl v0.0.0-20211210172815-726fda9656d6 h1:zDw5v7qm4yH7N8C8uWd+8Ii9rROdgWxQuGoJ9WDXxfk=
github.com/go-gl/gl v0.0.0-20211210172815-726fda9656d6/go.mod h1:9YTyiznxEY1fVinfM7RvRcjRHbw2xLBJ3AAGIT0I4Nw=
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/fyne-io/gl-js v0.2.0 h1:+EXMLVEa18EfkXBVKhifYB6OGs3HwKO3lUElA0LlAjs=
github.com/fyne-io/gl-js v0.2.0/go.mod h1:ZcepK8vmOYLu96JoxbCKJy2ybr+g1pTnaBDdl7c3ajI=
github.com/fyne-io/glfw-js v0.3.0 h1:d8k2+Y7l+zy2pc7wlGRyPfTgZoqDf3AI4G+2zOWhWUk=
github.com/fyne-io/glfw-js v0.3.0/go.mod h1:Ri6te7rdZtBgBpxLW19uBpp3Dl6K9K/bRaYdJ22G8Jk=
github.com/fyne-io/image v0.1.1 h1:WH0z4H7qfvNUw5l4p3bC1q70sa5+YWVt6HCj7y4VNyA=
github.com/fyne-io/image v0.1.1/go.mod h1:xrfYBh6yspc+KjkgdZU/ifUC9sPA5Iv7WYUBzQKK7JM=
github.com/fyne-io/oksvg v0.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.0 h1:LBYoTmp5jYiJ4NPqDc2pz17MLmA3wHw1dZSVGcOdeAc=
github.com/go-text/render v0.2.0/go.mod h1:CkiqfukRGKJA5vZZISkjSYrcdtgKQWRa2HIzvwNN5SU=
github.com/go-text/typesetting v0.2.0 h1:fbzsgbmk04KiWtE+c3ZD4W2nmCRzBqrqQOvYlwAOdho=
github.com/go-text/typesetting v0.2.0/go.mod h1:2+owI/sxa73XA581LAzVuEBZ3WEEV2pXeDswCH/3i1I=
github.com/go-text/typesetting-utils v0.0.0-20240317173224-1986cbe96c66 h1:GUrm65PQPlhFSKjLPGOZNPNxLCybjzjYBzjfoBGaDUY=
github.com/go-text/typesetting-utils v0.0.0-20240317173224-1986cbe96c66/go.mod h1:DDxDdQEnB70R8owOx3LVpEFvpMK9eeH1o2r0yZhFI9o=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/go-text/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/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y=
github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4=
github.com/golang/mock v1.5.0/go.mod h1:CWnOUgYIOo4TcNZ0wHX3YZCqsaM1I1Jvs6v3mP3KVu8=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.1/go.mod h1:DopwsBzvsk0Fs44TXzsVbJyPhcCPeIwnvohx4u74HPM=
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20201023163331-3e6fc7fc9c4c/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/pprof v0.0.0-20210122040257-d980be63207e/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/pprof v0.0.0-20210226084205-cbba55b83ad5/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/pprof v0.0.0-20211214055906-6f57359322fd h1:1FjCyPC+syAzJ5/2S8fqdZK1R22vvA0J7JZKcuOIQ7Y=
github.com/google/pprof v0.0.0-20211214055906-6f57359322fd/go.mod h1:KgnwoLYCZ8IQu3XUZ8Nc/bM9CCZFOyjUNOSygVozoDg=
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/gopherjs/gopherjs v0.0.0-20211219123610-ec9572f70e60/go.mod h1:cz9oNYuRUWGdHmLF2IodMLkAhcPtXeULvcBNagUrxTI=
github.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g=
github.com/gopherjs/gopherjs v1.17.2/go.mod h1:pRRIvn/QzFLrKfvEz3qUuEhtE/zLCWfreZ6J5gM2i+k=
github.com/goxjs/gl v0.0.0-20210104184919-e3fafc6f8f2a/go.mod h1:dy/f2gjY09hwVfIyATps4G2ai7/hLwLkc5TrPqONuXY=
github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw=
github.com/hashicorp/consul/api v1.1.0/go.mod h1:VmuI/Lkw1nC05EYQWNKwWGbkg+FbDBtguAZLlVdkD9Q=
github.com/hashicorp/consul/sdk v0.1.1/go.mod h1:VKf9jXwCTEY1QZP2MOLRhb5i/I/ssyNV1vwHyQBF0x8=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80=
github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60=
github.com/hashicorp/go-msgpack v0.5.3/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM=
github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk=
github.com/hashicorp/go-rootcerts v1.0.0/go.mod h1:K6zTfqpRlCUIjkwsN4Z+hiSfzSTQa6eBIzfwKfwNnHU=
github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU=
github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4=
github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/go.net v0.0.1/go.mod h1:hjKkEWcCURg++eb33jQU7oqQcI9XDCnUzHA0oac0k90=
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64=
github.com/hashicorp/mdns v1.0.0/go.mod h1:tL+uN++7HEJ6SQLQ2/p+z2pH24WQKWjBPkE0mNTz8vQ=
github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2pPBoIllUwCN7I=
github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc=
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
github.com/jeandeaual/go-locale v0.0.0-20240223122105-ce5225dcaa49 h1:Po+wkNdMmN+Zj1tDsJQy7mJlPlwGNQd9JZoPjObagf8=
github.com/jeandeaual/go-locale v0.0.0-20240223122105-ce5225dcaa49/go.mod h1:YiutDnxPRLk5DLUFj6Rw4pRBBURZY07GFr54NdV9mQg=
github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
github.com/jsummers/gobmp v0.0.0-20151104160322-e2ba15ffa76e h1:LvL4XsI70QxOGHed6yhQtAU34Kx3Qq2wwBzGFKY8zKk=
github.com/jsummers/gobmp v0.0.0-20151104160322-e2ba15ffa76e/go.mod h1:kLgvv7o6UM+0QSf0QjAse3wReFDsb9qbZJdfexWlrQw=
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/magiconair/properties v1.8.5/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60=
github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg=
github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc=
github.com/mitchellh/go-homedir v1.0.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
github.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI=
github.com/mitchellh/gox v0.4.0/go.mod h1:Sd9lOJ0+aimLBi73mGofS1ycjY8lL3uZM3JPS42BGNg=
github.com/mitchellh/iochan v1.0.0/go.mod h1:JwYml1nuB7xOzsp52dPpHFffvOCDupsG0QubkSMEySY=
github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/neelance/astrewrite v0.0.0-20160511093645-99348263ae86/go.mod h1:kHJEU3ofeGjhHklVoIGuVj85JJwZ6kWPaJwCIxgnFmo=
github.com/neelance/sourcemap v0.0.0-20200213170602-2833bce08e4c/go.mod h1:Qr6/a/Q4r9LP1IltGz7tA7iOK1WonHEYhu1HRBA7ZiM=
github.com/nicksnyder/go-i18n/v2 v2.4.0 h1:3IcvPOAvnCKwNm0TB0dLDTuawWEj+ax/RERNC+diLMM=
github.com/nicksnyder/go-i18n/v2 v2.4.0/go.mod h1:nxYSZE9M0bf3Y70gPQjN9ha7XNHX7gMc814+6wVyEI4=
github.com/hack-pad/go-indexeddb v0.3.2 h1:DTqeJJYc1usa45Q5r52t01KhvlSN02+Oq+tQbSBI91A=
github.com/hack-pad/go-indexeddb v0.3.2/go.mod h1:QvfTevpDVlkfomY498LhstjwbPW6QC4VC/lxYb0Kom0=
github.com/hack-pad/safejs v0.1.0 h1:qPS6vjreAqh2amUqj4WNG1zIw7qlRQJ9K10eDKMCnE8=
github.com/hack-pad/safejs v0.1.0/go.mod h1:HdS+bKF1NrE72VoXZeWzxFOVQVUSqZJAG0xNCnb+Tio=
github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade h1:FmusiCI1wHw+XQbvL9M+1r/C3SPqKrmBaIOYwVfQoDE=
github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade/go.mod h1:ZDXo8KHryOWSIqnsb/CiDq7hQUYryCgdVnxbj8tDG7o=
github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 h1:YLvr1eE6cdCqjOe972w/cYF+FjW34v27+9Vo5106B4M=
github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25/go.mod h1:kLgvv7o6UM+0QSf0QjAse3wReFDsb9qbZJdfexWlrQw=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
github.com/nicksnyder/go-i18n/v2 v2.5.1 h1:IxtPxYsR9Gp60cGXjfuR/llTqV8aYMsC472zD0D1vHk=
github.com/nicksnyder/go-i18n/v2 v2.5.1/go.mod h1:DrhgsSDZxoAfvVrBVLXoxZn/pN5TXqaDbq7ju94viiQ=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
github.com/pelletier/go-toml v1.9.3/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/profile v1.7.0 h1:hnbDkaNWPCLMO9wGLdBFTIZvzDrDfBM2072E1S9gJkA=
github.com/pkg/profile v1.7.0/go.mod h1:8Uer0jas47ZQMJ7VD+OHknK4YDY07LPUC6dEvqDjvNo=
github.com/pkg/sftp v1.10.1/go.mod h1:lYOWFsE0bwd1+KfKJaKeuokY15vzFx25BLbzYYoAxZI=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts=
github.com/rymdport/portal v0.3.0 h1:QRHcwKwx3kY5JTQcsVhmhC3TGqGQb9LFghVNUy8AdB8=
github.com/rymdport/portal v0.3.0/go.mod h1:kFF4jslnJ8pD5uCi17brj/ODlfIidOxlgUDTO5ncnC4=
github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=
github.com/shurcooL/go v0.0.0-20200502201357-93f07166e636/go.mod h1:TDJrrUr11Vxrven61rcy3hJMUqaf/CLWYhHNPmT14Lk=
github.com/shurcooL/httpfs v0.0.0-20190707220628-8d4bc4ba7749/go.mod h1:ZY1cvUeJuFPAdZ/B6v7RHavJWZn2YPVFQ1OSXhCGOkg=
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
github.com/shurcooL/vfsgen v0.0.0-20200824052919-0d455de96546/go.mod h1:TrYk7fJVaAttu97ZZKrO9UbRa8izdowaMIZcxYMbVaw=
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
github.com/spf13/afero v1.6.0/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z93I=
github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
github.com/spf13/cobra v1.2.1/go.mod h1:ExllRjgxM/piMAM+3tAZvg8fsklGAf3tPfi+i8t68Nk=
github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.8.1/go.mod h1:o0Pch8wJ9BVSWGQMbra6iw0oQ5oktSIBaujf1rJH9Ns=
github.com/rymdport/portal v0.4.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/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw=
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
github.com/yuin/goldmark v1.4.0/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
github.com/yuin/goldmark v1.7.1 h1:3bajkSilaCbjdKVsKdZjZCLBNPL9pYzrCakKaf4U49U=
github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
go.etcd.io/etcd/api/v3 v3.5.0/go.mod h1:cbVKeC6lCfl7j/8jBhAK6aIYO9XOjdptoxU/nLQcPvs=
go.etcd.io/etcd/client/pkg/v3 v3.5.0/go.mod h1:IJHfcCEKxYu1Os13ZdwCwIUTUVGYTSAM3YSwc9/Ac1g=
go.etcd.io/etcd/client/v2 v2.305.0/go.mod h1:h9puh54ZTgAKtEbut2oe9P4L/oqKCVB6xsXlzd7alYQ=
go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk=
go.opencensus.io v0.23.0/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E=
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU=
go.uber.org/zap v1.17.0/go.mod h1:MXVU+bhUf/A7Xi2HNOnopQOrmycQ5Ih87HtOu4q5SSo=
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/crypto v0.0.0-20181029021203-45a5f77698d3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
golang.org/x/exp v0.0.0-20190731235908-ec7cb31e5a56/go.mod h1:JhuoJpWY28nO4Vef9tZUw9qufEGTyX1+7lmHxV5q5G4=
golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek=
golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY=
golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU=
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.18.0 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ=
golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs=
golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
golang.org/x/lint v0.0.0-20210508222113-6edffad5e616/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE=
golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o=
golang.org/x/mobile v0.0.0-20211207041440-4e6c2922fdee/go.mod h1:pe2sM7Uk+2Su1y7u/6Z8KJ24D7lepUjFZbhFOrmDfuQ=
golang.org/x/mobile v0.0.0-20231127183840-76ac6878050a h1:sYbmY3FwUWCBTodZL1S3JUuOvaW6kM2o+clDzzDNBWg=
golang.org/x/mobile v0.0.0-20231127183840-76ac6878050a/go.mod h1:Ede7gF0KGoHlj822RtphAHK1jLdrcuRBZg0sF1Q+SPc=
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY=
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181023162649-9b4f9f5ad519/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181201002055-351d144fa1fc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210316092652-d523dce5a7f4/go.mod h1:RBQZq4jEuRlivfhVLdyRGr576XBO4/greRjx4P4O3yc=
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20201109201403-9fd604954f58/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20210220000619-9bb904979d93/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20210313182246-cd4f82c27b84/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20210402161424-2e8d93401602/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181026203630-95b1ffbd15a5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210220050731-9a76102bfb43/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210305230114-8fe3ee5dd75b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210315160823-c6e025ad8005/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210403161142-5e06dd20ab57/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191112195655-aa38f8e97acc/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8=
golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.0.0-20200904185747-39188db58858/go.mod h1:Cj7w3i3Rnn0Xh82ur9kSqwfTHTeVxaDqrfMjpcNT6bE=
golang.org/x/tools v0.0.0-20201110124207-079ba7bd75cd/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.0.0-20201201161351-ac6f37ff4c2a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.0.0-20201208233053-a543418bbed2/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.0.0-20210105154028-b0ab187a4818/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0=
golang.org/x/tools v0.1.2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
golang.org/x/tools v0.1.8-0.20211022200916-316ba0b74098/go.mod h1:LGqMHiF4EqQNHR1JncWGqT5BVaXmza+X+BDGol+dOxo=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M=
google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.19.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=
google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=
google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM=
google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc=
google.golang.org/api v0.35.0/go.mod h1:/XrVsuzM0rZmrsbjJutiuftIzeuTQcEeaYcSk/mQ1dg=
google.golang.org/api v0.36.0/go.mod h1:+z5ficQTmoYpPn8LCUNVpK5I7hwkpjbcgqA7I34qYtE=
google.golang.org/api v0.40.0/go.mod h1:fYKFpnQN0DsDSKRVRcQSDQNtqWPfM9i+zNPxepjRCQ8=
google.golang.org/api v0.41.0/go.mod h1:RkxM5lITDfTzmyKFPt+wGrCJbVfniCr2ool8kTBzRTU=
google.golang.org/api v0.43.0/go.mod h1:nQsDGjRXMo4lvh5hP0TKqF244gqhGcr/YSIykhUk/94=
google.golang.org/api v0.44.0/go.mod h1:EBOGZqzyhtvMDoxwS97ctnh0zUmYY6CxqXsc1AvkYD8=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0=
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8=
google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA=
google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200513103714-09dca8ec2884/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U=
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA=
google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20200904004341-0bd0a958aa1d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20201109203340-2640f1f9cdfb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20201201144952-b05cb90ed32e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20201210142538-e3217bee35cc/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20201214200347-8c77b98c765d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20210222152913-aa3ee6e6a81c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20210303154014-9728d6b83eeb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20210310155132-4ce2db91004e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20210319143718-93e7006c17a6/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20210402141018-6c239bbf2bb1/go.mod h1:9lPAdzaEmUacj36I+k7YKbEc5CXzPIeORRgDAUOu28A=
google.golang.org/genproto v0.0.0-20210602131652-f16073e35f0c/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60=
google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk=
google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
google.golang.org/grpc v1.31.1/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
google.golang.org/grpc v1.33.1/go.mod h1:fr5YgcSWrqhRRxogOsw7RzIpsmvOZ6IcH4kBYTpR3n0=
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
google.golang.org/grpc v1.34.0/go.mod h1:WotjhfgOW/POjDeRt8vscBtXq+2VjORFy659qA51WJ8=
google.golang.org/grpc v1.35.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU=
google.golang.org/grpc v1.36.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU=
google.golang.org/grpc v1.36.1/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU=
google.golang.org/grpc v1.38.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4=
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
golang.org/x/image v0.24.0 h1:AN7zRgVsbvmTfNyqIbbOraYL8mSwcKncEj8ofjgzcMQ=
golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8=
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/ini.v1 v1.62.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=
+2 -2
View File
@@ -4,7 +4,7 @@ set -euo pipefail
# Optional first argument mirrors build-linux.sh. The Docker build still writes
# the final artifact into the local dist/ tree, not into the container. The
# default includes the application version and target platform.
version="$(sed -n 's/^var Version = "\(.*\)"/\1/p' src/core/version.go)"
version="$(sed -n 's/^var Version = "\(.*\)"/\1/p' src/app/version.go)"
version="${version:-0.0.0-dev}"
tag="gitea.mixdep.ru/mix/gosentry-builder:${version}"
output="${1:-dist/linux/gosentry-${version}-linux-amd64}"
@@ -26,7 +26,7 @@ docker run --rm \
-v "$(pwd):/src" \
-w /src \
"$tag" \
bash -c 'CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build -buildvcs=false -trimpath -ldflags "-s -w -X gitea.mixdep.ru/mix/gosentry/src/core.Version=${VERSION}" -o "${OUTPUT}" ./cmd/gosentry'
bash -c 'CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build -buildvcs=false -trimpath -ldflags "-s -w -X gitea.mixdep.ru/mix/gosentry/src/app.Version=${VERSION}" -o "${OUTPUT}" ./cmd/gosentry'
# Icons are embedded in the Go binary, so there is no assets directory to copy
# after extracting the Linux executable.
+2 -2
View File
@@ -3,7 +3,7 @@ set -euo pipefail
# Optional first argument lets a developer or CI job choose the output path. The
# default includes the application version and target platform.
version="$(sed -n 's/^var Version = "\(.*\)"/\1/p' src/core/version.go)"
version="$(sed -n 's/^var Version = "\(.*\)"/\1/p' src/app/version.go)"
version="${version:-0.0.0-dev}"
output="${1:-dist/linux/gosentry-${version}-linux-amd64}"
mkdir -p "$(dirname "$output")"
@@ -17,7 +17,7 @@ export GOARCH=amd64
# -trimpath removes local machine paths from debug/build metadata. -s -w strips
# symbol/debug tables to keep the desktop binary smaller.
go build -trimpath -ldflags "-s -w -X gitea.mixdep.ru/mix/gosentry/src/core.Version=${version}" -o "$output" ./cmd/gosentry
go build -trimpath -ldflags "-s -w -X gitea.mixdep.ru/mix/gosentry/src/app.Version=${version}" -o "$output" ./cmd/gosentry
# The application icon is embedded by Go, so the Linux build does not need a
# sidecar assets directory beside the executable.
+4 -4
View File
@@ -9,7 +9,7 @@ script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
repo_root="$(cd "${script_dir}/.." && pwd)"
cd "$repo_root"
version="$(sed -n 's/^var Version = "\(.*\)"/\1/p' src/core/version.go)"
version="$(sed -n 's/^var Version = "\(.*\)"/\1/p' src/app/version.go)"
version="${version:-0.0.0-dev}"
tag="gitea.mixdep.ru/mix/gosentry-builder:${version}"
@@ -99,15 +99,15 @@ run_in_builder() {
}
build_linux_amd64() {
run_in_builder 'mkdir -p dist/linux && CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build -buildvcs=false -trimpath -ldflags "-s -w -X gitea.mixdep.ru/mix/gosentry/src/core.Version=${VERSION}" -o "dist/linux/gosentry-${VERSION}-linux-amd64" ./cmd/gosentry'
run_in_builder 'mkdir -p dist/linux && CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build -buildvcs=false -trimpath -ldflags "-s -w -X gitea.mixdep.ru/mix/gosentry/src/app.Version=${VERSION}" -o "dist/linux/gosentry-${VERSION}-linux-amd64" ./cmd/gosentry'
}
build_linux_arm64() {
run_in_builder 'mkdir -p dist/linux && CC=aarch64-linux-gnu-gcc CGO_ENABLED=1 GOOS=linux GOARCH=arm64 CGO_CFLAGS="--sysroot=/ -I/usr/include/aarch64-linux-gnu" CGO_LDFLAGS="--sysroot=/ -L/usr/lib/aarch64-linux-gnu" PKG_CONFIG_LIBDIR=/usr/lib/aarch64-linux-gnu/pkgconfig go build -buildvcs=false -trimpath -ldflags "-s -w -X gitea.mixdep.ru/mix/gosentry/src/core.Version=${VERSION}" -o "dist/linux/gosentry-${VERSION}-linux-arm64" ./cmd/gosentry'
run_in_builder 'mkdir -p dist/linux && CC=aarch64-linux-gnu-gcc CGO_ENABLED=1 GOOS=linux GOARCH=arm64 CGO_CFLAGS="--sysroot=/ -I/usr/include/aarch64-linux-gnu" CGO_LDFLAGS="--sysroot=/ -L/usr/lib/aarch64-linux-gnu" PKG_CONFIG_LIBDIR=/usr/lib/aarch64-linux-gnu/pkgconfig go build -buildvcs=false -trimpath -ldflags "-s -w -X gitea.mixdep.ru/mix/gosentry/src/app.Version=${VERSION}" -o "dist/linux/gosentry-${VERSION}-linux-arm64" ./cmd/gosentry'
}
build_windows_amd64() {
run_in_builder 'mkdir -p dist/windows && x86_64-w64-mingw32-windres -O coff -o cmd/gosentry/rsrc_windows_amd64.syso packaging/windows/gosentry.rc && CC=x86_64-w64-mingw32-gcc CGO_ENABLED=1 GOOS=windows GOARCH=amd64 go build -buildvcs=false -trimpath -ldflags "-s -w -H=windowsgui -X gitea.mixdep.ru/mix/gosentry/src/core.Version=${VERSION}" -o "dist/windows/gosentry-${VERSION}-windows-amd64.exe" ./cmd/gosentry'
run_in_builder 'mkdir -p dist/windows && x86_64-w64-mingw32-windres -O coff -o cmd/gosentry/rsrc_windows_amd64.syso packaging/windows/gosentry.rc && CC=x86_64-w64-mingw32-gcc CGO_ENABLED=1 GOOS=windows GOARCH=amd64 go build -buildvcs=false -trimpath -ldflags "-s -w -H=windowsgui -X gitea.mixdep.ru/mix/gosentry/src/app.Version=${VERSION}" -o "dist/windows/gosentry-${VERSION}-windows-amd64.exe" ./cmd/gosentry'
}
mapfile -t targets < <(choose_targets "$@" | normalize_targets | awk '!seen[$0]++')
+2 -2
View File
@@ -6,7 +6,7 @@ REM directory. Move to the repository root (the parent of scripts\) before using
REM relative paths such as .\cmd\gosentry and packaging\windows\gosentry.rc.
cd /d "%~dp0\.."
for /f "tokens=4" %%V in ('findstr /C:"var Version" src\core\version.go') do set "VERSION=%%~V"
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:"=%"
@@ -47,7 +47,7 @@ if %ERRORLEVEL%==0 (
REM -trimpath removes local machine paths from the binary, -s -w reduce binary
REM size, and -H=windowsgui prevents a separate console window from opening when
REM the GUI app starts from Explorer or a shortcut.
"%GOEXE%" build -trimpath -ldflags "-s -w -H=windowsgui -X gitea.mixdep.ru/mix/gosentry/src/core.Version=%VERSION%" -o "%OUTPUT%" .\cmd\gosentry
"%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 exit /b 1
REM Icons are embedded into the executable, so no assets directory is copied next
+35
View File
@@ -0,0 +1,35 @@
@echo off
setlocal enabledelayedexpansion
REM GoSentry test runner
REM Runs go vet and go test with race detection
REM Move to repository root
cd /d "%~dp0\.."
REM Fyne uses native libraries through CGO. MSYS2 UCRT64 provides the GCC toolchain
REM expected by the Windows build; prepending it keeps the script self-contained
REM without permanently changing the user's system PATH.
if exist "C:\msys64\ucrt64\bin" set "PATH=C:\msys64\ucrt64\bin;%PATH%"
REM Race detector requires CGO
set "CGO_ENABLED=1"
echo Running go vet...
go vet ./...
if errorlevel 1 (
echo.
echo ✗ go vet failed
exit /b 1
)
echo.
echo Running go test with race detection...
go test -race ./...
if errorlevel 1 (
echo.
echo ✗ go test failed
exit /b 1
)
echo.
echo ✓ All tests passed
+16
View File
@@ -0,0 +1,16 @@
#!/bin/bash
# GoSentry test runner
# Runs go vet and go test with race detection
set -e
echo "Running go vet..."
go vet ./...
echo ""
echo "Running go test with race detection..."
go test -race ./...
echo ""
echo "✓ All tests passed"
+100
View File
@@ -0,0 +1,100 @@
package app
import "gitea.mixdep.ru/mix/gosentry/src/domain"
// Event is something the Service did to its state that observers may want to
// react to. It is a sealed interface: the concrete types in this file are the
// only implementations (enforced by the unexported isEvent marker), so a UI
// listener can exhaustively type-switch over them and the compiler will flag a
// new event type that a switch forgot to handle.
//
// Events replace the old single onChange callback. Instead of the scheduler
// reaching into the GUI, the Service emits typed events and the UI subscribes —
// the UI's listener becomes the one place that touches widgets.
type Event interface {
isEvent()
}
// JobChanged signals that a job's durable config or transient runtime changed:
// created, edited, deleted, enabled/disabled, or a status transition such as a
// run starting. Observers should re-read the affected state through the Service
// (Jobs/Runtime) rather than expect a payload snapshot — that keeps the event
// small and avoids handing out stale copies.
//
// JobID identifies the affected job. A zero JobID means a broad change (for
// example a delete, or a global pause that touched every job) and observers
// should refresh their whole view.
type JobChanged struct {
JobID int
}
// RunRecorded signals that a job run finished and produced a RunRecord. It
// carries the record by value because the record is an immutable result that
// observers append to history; there is nothing for them to re-read.
type RunRecorded struct {
Record domain.RunRecord
}
// SchedulerStateChanged signals that the global scheduler pause state flipped.
// The UI uses it to update the pause/resume control and status text.
type SchedulerStateChanged struct {
Paused bool
}
// 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 (RunRecorded) isEvent() {}
func (SchedulerStateChanged) isEvent() {}
func (ErrorOccurred) isEvent() {}
// Observer receives events emitted by the Service. OnEvent is the single
// reaction point; the UI implements it and marshals any widget work onto the
// main thread (fyne.Do) itself — the Service knows nothing about Fyne.
type Observer interface {
OnEvent(Event)
}
// ObserverFunc adapts a plain function to the Observer interface, so callers can
// subscribe a closure without declaring a type.
type ObserverFunc func(Event)
// OnEvent calls the wrapped function.
func (f ObserverFunc) OnEvent(event Event) { f(event) }
// Subscribe registers an observer to receive every subsequently emitted event.
// Registration is expected during setup, before the scheduler starts, but is
// guarded so it is safe at any time.
func (s *Service) Subscribe(observer Observer) {
s.dispatchMu.Lock()
defer s.dispatchMu.Unlock()
s.observers = append(s.observers, observer)
}
// emit delivers an event to every registered observer.
//
// Single-threaded dispatch contract:
// - emit holds dispatchMu for the whole dispatch, so observers are never
// invoked concurrently and never overlap with each other or with Subscribe.
// Each observer sees events one at a time, in emit order.
// - emit must be called WITHOUT holding s.mu. The Service computes a state
// change under mu, releases it, then emits — so an observer is free to call
// back into read methods (Jobs/Runtime) without deadlocking on the state
// lock.
// - An observer must NOT call back into a Service method that emits (directly
// or indirectly): dispatchMu is non-reentrant, so re-entrant emission would
// deadlock. Observers react and return quickly; long or UI work is the
// observer's own responsibility to defer (e.g. fyne.Do).
func (s *Service) emit(event Event) {
s.dispatchMu.Lock()
defer s.dispatchMu.Unlock()
for _, observer := range s.observers {
observer.OnEvent(event)
}
}
+59
View File
@@ -0,0 +1,59 @@
package app
import (
"testing"
"gitea.mixdep.ru/mix/gosentry/src/domain"
)
func TestEmitDeliversToAllObserversInOrder(t *testing.T) {
svc := newTestService(nil)
var first, second []Event
svc.Subscribe(ObserverFunc(func(e Event) { first = append(first, e) }))
svc.Subscribe(ObserverFunc(func(e Event) { second = append(second, e) }))
svc.emit(JobChanged{JobID: 7})
svc.emit(RunRecorded{Record: domain.RunRecord{JobID: 7, State: "Success"}})
svc.emit(SchedulerStateChanged{Paused: true})
for name, got := range map[string][]Event{"first": first, "second": second} {
if len(got) != 3 {
t.Fatalf("%s observer got %d events, want 3", name, len(got))
}
if jc, ok := got[0].(JobChanged); !ok || jc.JobID != 7 {
t.Errorf("%s event[0] = %#v, want JobChanged{JobID:7}", name, got[0])
}
if rr, ok := got[1].(RunRecorded); !ok || rr.Record.State != "Success" {
t.Errorf("%s event[1] = %#v, want RunRecorded Success", name, got[1])
}
if ss, ok := got[2].(SchedulerStateChanged); !ok || !ss.Paused {
t.Errorf("%s event[2] = %#v, want SchedulerStateChanged{Paused:true}", name, got[2])
}
}
}
func TestEmitWithNoObserversIsNoop(t *testing.T) {
svc := newTestService(nil)
// Must not panic with an empty observer list.
svc.emit(JobChanged{})
}
// Observers may read Service state from within OnEvent without deadlocking,
// because emit is called outside the state lock.
func TestObserverCanReadServiceState(t *testing.T) {
jobs := []domain.Job{{ID: 1, Name: "Job", Enabled: true}}
svc := newTestService(jobs)
var sawName string
svc.Subscribe(ObserverFunc(func(Event) {
if snapshot := svc.Jobs(); len(snapshot) == 1 {
sawName = snapshot[0].Name
}
}))
svc.emit(JobChanged{JobID: 1})
if sawName != "Job" {
t.Errorf("observer read name = %q, want %q", sawName, "Job")
}
}
+89
View File
@@ -0,0 +1,89 @@
package app
import (
"fmt"
"strings"
"gitea.mixdep.ru/mix/gosentry/src/domain"
)
// StatusText formats a job's current state for display: "Paused" if disabled,
// else its runtime LastState (Ready, Running, Success, etc).
func StatusText(j domain.Job, runtime *domain.JobRuntime) string {
if !j.Enabled {
return "Paused"
}
if runtime == nil {
return ""
}
return runtime.LastState
}
// EventText formats a run record for the History table, showing time, trigger,
// job name, outcome state, detail, and log file (if any).
func EventText(e domain.RunRecord) string {
trigger := e.Trigger
if trigger == "" {
trigger = "Unknown"
}
if e.LogFile != "" {
return fmt.Sprintf("%s %s %s %s %s %s", e.Time, trigger, e.JobName, e.State, e.Detail, e.LogFile)
}
return fmt.Sprintf("%s %s %s %s %s", e.Time, trigger, e.JobName, e.State, e.Detail)
}
// DisplayFolder formats a job's folder for display: "(No folder)" if empty,
// else the trimmed folder name.
func DisplayFolder(folder string) string {
if strings.TrimSpace(folder) == "" {
return "(No folder)"
}
return strings.TrimSpace(folder)
}
// DisplayArguments formats a job's arguments for display: "(none)" if empty,
// else the trimmed arguments.
func DisplayArguments(arguments string) string {
if strings.TrimSpace(arguments) == "" {
return "(none)"
}
return strings.TrimSpace(arguments)
}
// 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 {
if job.StartOnly {
return "Start only"
}
return "Wait for completion"
}
// DisplayInvocation formats a job's command and arguments for the jobs list,
// joining them with spacing and collapsing newlines in arguments to spaces.
func DisplayInvocation(job domain.Job) string {
if strings.TrimSpace(job.Arguments) == "" {
return job.Command
}
return job.Command + " " + strings.ReplaceAll(strings.TrimSpace(job.Arguments), "\n", " ")
}
// DisplayIndex returns the position of jobIndex in the given slice of indexes,
// or 0 if not found.
func DisplayIndex(indexes []int, jobIndex int) int {
for display, index := range indexes {
if index == jobIndex {
return display
}
}
return 0
}
+109
View File
@@ -0,0 +1,109 @@
package app
import (
"testing"
"gitea.mixdep.ru/mix/gosentry/src/domain"
)
func TestStatusText(t *testing.T) {
tests := []struct {
name string
job domain.Job
runtime *domain.JobRuntime
want string
}{
{"disabled is paused", domain.Job{Enabled: false}, &domain.JobRuntime{LastState: "Running"}, "Paused"},
{"enabled shows runtime state", domain.Job{Enabled: true}, &domain.JobRuntime{LastState: "Success"}, "Success"},
{"enabled with nil runtime is empty", domain.Job{Enabled: true}, nil, ""},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := StatusText(tc.job, tc.runtime); got != tc.want {
t.Errorf("StatusText = %q, want %q", got, tc.want)
}
})
}
}
func TestEventText(t *testing.T) {
withLog := domain.RunRecord{
Time: "2026-06-19 12:00:00", Trigger: "Schedule", JobName: "Build",
State: "Success", Detail: "ok", LogFile: "build.log",
}
if got, want := EventText(withLog), "2026-06-19 12:00:00 Schedule Build Success ok build.log"; got != want {
t.Errorf("EventText with log = %q, want %q", got, want)
}
noLog := domain.RunRecord{
Time: "2026-06-19 12:00:00", Trigger: "Manual", JobName: "Build",
State: "Success", Detail: "ok",
}
if got, want := EventText(noLog), "2026-06-19 12:00:00 Manual Build Success ok"; got != want {
t.Errorf("EventText without log = %q, want %q", got, want)
}
// An empty trigger is shown as "Unknown".
blank := domain.RunRecord{Time: "t", JobName: "J", State: "S", Detail: "d"}
if got, want := EventText(blank), "t Unknown J S d"; got != want {
t.Errorf("EventText blank trigger = %q, want %q", got, want)
}
}
func TestDisplayFolder(t *testing.T) {
if got := DisplayFolder(" "); got != "(No folder)" {
t.Errorf("blank folder = %q, want %q", got, "(No folder)")
}
if got := DisplayFolder(" Reports "); got != "Reports" {
t.Errorf("folder = %q, want %q", got, "Reports")
}
}
func TestDisplayArguments(t *testing.T) {
if got := DisplayArguments(""); got != "(none)" {
t.Errorf("empty args = %q, want %q", got, "(none)")
}
if got := DisplayArguments(" -v "); got != "-v" {
t.Errorf("args = %q, want %q", got, "-v")
}
}
func 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")
}
if got := DisplayRunMode(domain.Job{StartOnly: false}); got != "Wait for completion" {
t.Errorf("wait = %q, want %q", got, "Wait for completion")
}
}
func TestDisplayInvocation(t *testing.T) {
if got := DisplayInvocation(domain.Job{Command: "echo"}); got != "echo" {
t.Errorf("no args = %q, want %q", got, "echo")
}
// Arguments are appended with spacing and their newlines collapsed to spaces.
job := domain.Job{Command: "echo", Arguments: " hi\nthere "}
if got, want := DisplayInvocation(job), "echo hi there"; got != want {
t.Errorf("with args = %q, want %q", got, want)
}
}
func TestDisplayIndex(t *testing.T) {
indexes := []int{4, 7, 2}
if got := DisplayIndex(indexes, 7); got != 1 {
t.Errorf("DisplayIndex(7) = %d, want 1", got)
}
// A jobIndex not present returns 0.
if got := DisplayIndex(indexes, 99); got != 0 {
t.Errorf("DisplayIndex(missing) = %d, want 0", got)
}
}
+486
View File
@@ -0,0 +1,486 @@
package app
import (
"context"
"errors"
"fmt"
"strings"
"time"
"gitea.mixdep.ru/mix/gosentry/src/domain"
"gitea.mixdep.ru/mix/gosentry/src/runner"
)
// maxJobLogs bounds the in-memory activity list kept per job. The full history
// lives in the log files on disk; this is only the recent activity shown in the
// GUI, so an old run aging out of the list is intentional.
const maxJobLogs = 50
// timestampLayout matches the format used for run records so UI-action activity
// and command runs line up in the History view.
const timestampLayout = "2006-01-02 15:04:05"
// errJobNotFound is returned by the mutating operations when no loaded job has
// the requested ID.
var errJobNotFound = errors.New("job not found")
// CreateJob normalizes and validates the supplied configuration, assigns the
// next free ID, and adds it to the loaded set. It returns the stored job (with
// its assigned ID) so the caller can select it. The job is persisted and a
// "Created" activity record is emitted.
func (s *Service) CreateJob(job domain.Job) (domain.Job, error) {
normalizeJob(&job)
if err := validateJob(job); err != nil {
return domain.Job{}, err
}
s.mu.Lock()
job.ID = s.nextIDLocked()
s.jobs = append(s.jobs, job)
runtime := domain.NewRuntime(job)
s.runtimes[job.ID] = runtime
s.parseScheduleLocked(&job)
record := uiRecord(job.ID, job.Name, "Created", "Job was added")
prependLog(runtime, record)
err := s.store.SaveJobs(s.jobs)
s.mu.Unlock()
s.emit(RunRecorded{Record: record})
s.emit(JobChanged{JobID: job.ID})
return job, err
}
// UpdateJob replaces the durable configuration of the job with the same ID,
// keeping its runtime state (keyed by ID) and recomputing its next run. The job
// is persisted and an "Updated" activity record is emitted.
func (s *Service) UpdateJob(job domain.Job) error {
normalizeJob(&job)
if err := validateJob(job); err != nil {
return err
}
s.mu.Lock()
existing := s.findByIDLocked(job.ID)
if existing == nil {
s.mu.Unlock()
return fmt.Errorf("update job %d: %w", job.ID, errJobNotFound)
}
*existing = job
runtime := s.runtimeForLocked(existing)
// An edit may have toggled Enabled; reflect that into the status the same way
// a dedicated enable/disable would, then recompute the next run.
if job.Enabled {
if runtime.LastState == "" || runtime.LastState == "Paused" {
runtime.LastState = "Ready"
}
} else {
runtime.LastState = "Paused"
}
s.parseScheduleLocked(existing)
s.refreshNextRunLocked(existing, runtime)
record := uiRecord(job.ID, job.Name, "Updated", "Job settings changed")
prependLog(runtime, record)
err := s.store.SaveJobs(s.jobs)
s.mu.Unlock()
s.emit(RunRecorded{Record: record})
s.emit(JobChanged{JobID: job.ID})
return err
}
// DeleteJob removes the job with the given ID along with its runtime and cached
// schedule. The remaining jobs are persisted and a "Deleted" activity record is
// emitted. The JobChanged event carries a zero ID to signal a broad change.
func (s *Service) DeleteJob(id int) error {
s.mu.Lock()
index := s.indexByIDLocked(id)
if index < 0 {
s.mu.Unlock()
return fmt.Errorf("delete job %d: %w", id, errJobNotFound)
}
deleted := s.jobs[index]
s.jobs = append(s.jobs[:index], s.jobs[index+1:]...)
delete(s.runtimes, id)
delete(s.schedules, id)
record := uiRecord(id, deleted.Name, "Deleted", "Job was removed")
err := s.store.SaveJobs(s.jobs)
s.mu.Unlock()
s.emit(RunRecorded{Record: record})
s.emit(JobChanged{JobID: 0})
return err
}
// SetEnabled enables or disables a single job. Enabling moves it back to "Ready"
// and recomputes its next run (respecting the global pause); disabling parks it
// at "Paused". The job is persisted and a "Resumed"/"Paused" activity record is
// emitted.
func (s *Service) SetEnabled(id int, enabled bool) error {
s.mu.Lock()
job := s.findByIDLocked(id)
if job == nil {
s.mu.Unlock()
return fmt.Errorf("set enabled job %d: %w", id, errJobNotFound)
}
job.Enabled = enabled
runtime := s.runtimeForLocked(job)
s.parseScheduleLocked(job)
var record domain.RunRecord
if enabled {
runtime.LastState = "Ready"
s.refreshNextRunLocked(job, runtime)
record = uiRecord(id, job.Name, "Resumed", "Job was enabled")
} else {
runtime.LastState = "Paused"
runtime.NextRun = "Paused"
runtime.NextDue = time.Time{}
record = uiRecord(id, job.Name, "Paused", "Job was disabled")
}
prependLog(runtime, record)
err := s.store.SaveJobs(s.jobs)
s.mu.Unlock()
s.emit(RunRecorded{Record: record})
s.emit(JobChanged{JobID: id})
return err
}
// 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
now := time.Now()
for index := range s.jobs {
job := &s.jobs[index]
runtime := s.runtimeForLocked(job)
s.refreshNextRunFromLocked(job, runtime, now)
}
err := s.store.SaveJobs(s.jobs)
s.mu.Unlock()
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 err
}
// 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.paused {
s.mu.Unlock()
return errors.New("scheduler is paused")
}
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()
// Reflect the "Running" transition; the run's completion emits again later.
s.emit(JobChanged{JobID: id})
return err
}
// RunDue is the scheduler's per-tick entry point: it starts whatever is due at
// the given time. It is a no-op while globally paused. 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()
var startedID int
var startErr error
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
}
startErr = s.startRunLocked(job, runtime, "Schedule")
startedID = job.ID
break
}
}
s.mu.Unlock()
if startErr != nil {
s.emit(ErrorOccurred{Err: fmt.Errorf("save jobs before scheduled run: %w", startErr)})
}
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 directory may have changed, and log
// cleanup runs so a tightened retention policy takes effect immediately.
func (s *Service) UpdateSettings(config domain.Config) error {
if err := validateConfig(config); err != nil {
return err
}
s.mu.Lock()
s.store.Config = config
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
}
logsDir := s.store.Paths.LogsDir
maxFiles := s.store.Config.MaxLogFiles
maxAge := s.store.Config.MaxLogAgeDays
s.mu.Unlock()
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()
var cleanupErr, saveErr error
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)
cleanupErr = runner.CleanupLogs(s.store.Paths.LogsDir, s.store.Config.MaxLogFiles, s.store.Config.MaxLogAgeDays)
saveErr = s.store.SaveJobs(s.jobs)
}
s.mu.Unlock()
if cleanupErr != nil {
s.emit(ErrorOccurred{Err: fmt.Errorf("log cleanup after run %q: %w", jobCopy.Name, cleanupErr)})
}
if saveErr != nil {
s.emit(ErrorOccurred{Err: fmt.Errorf("save jobs after run %q: %w", jobCopy.Name, saveErr)})
}
s.emit(RunRecorded{Record: record})
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) {
s.refreshNextRunFromLocked(job, runtime, time.Now())
}
// refreshNextRunFromLocked is refreshNextRunLocked with an explicit reference
// time, used when one timestamp should drive a whole batch (e.g. a global
// pause). The caller must hold mu.
func (s *Service) refreshNextRunFromLocked(job *domain.Job, runtime *domain.JobRuntime, from time.Time) {
if !job.Enabled {
runtime.NextRun = "Paused"
runtime.NextDue = time.Time{}
return
}
if s.paused {
runtime.NextRun = "Scheduler paused"
runtime.NextDue = time.Time{}
return
}
s.prepareNextRunLocked(job, runtime, from)
}
// prepareNextRunLocked computes the concrete next-due time from the cached
// schedule. A missing cache entry means the schedule string was unparseable.
// The caller must hold mu.
func (s *Service) prepareNextRunLocked(job *domain.Job, runtime *domain.JobRuntime, from time.Time) {
sched, ok := s.schedules[job.ID]
if !ok {
runtime.NextRun = "Invalid schedule"
runtime.NextDue = time.Time{}
return
}
runtime.NextDue = sched.Next(from)
runtime.NextRun = runtime.NextDue.Format(timestampLayout)
}
// parseScheduleLocked caches a parsed schedule for the job, dropping the cache
// entry when the schedule string is invalid so prepareNextRunLocked can tell the
// two apart. The caller must hold mu.
func (s *Service) parseScheduleLocked(job *domain.Job) {
sched, err := domain.Parse(job.Schedule)
if err != nil {
delete(s.schedules, job.ID)
return
}
s.schedules[job.ID] = sched
}
// findByIDLocked returns a pointer into the jobs slice for the job with the
// given ID, or nil. The caller must hold mu.
func (s *Service) findByIDLocked(id int) *domain.Job {
index := s.indexByIDLocked(id)
if index < 0 {
return nil
}
return &s.jobs[index]
}
// indexByIDLocked returns the slice index of the job with the given ID, or -1.
// The caller must hold mu.
func (s *Service) indexByIDLocked(id int) int {
for index := range s.jobs {
if s.jobs[index].ID == id {
return index
}
}
return -1
}
// runtimeForLocked returns the runtime for a job, lazily creating it if missing
// so the Service stays robust if a job lacks an entry. The caller must hold mu.
func (s *Service) runtimeForLocked(job *domain.Job) *domain.JobRuntime {
runtime, ok := s.runtimes[job.ID]
if !ok || runtime == nil {
runtime = domain.NewRuntime(*job)
s.runtimes[job.ID] = runtime
}
return runtime
}
// nextIDLocked returns the smallest ID greater than every loaded job's ID. The
// caller must hold mu.
func (s *Service) nextIDLocked() int {
next := 1
for index := range s.jobs {
if s.jobs[index].ID >= next {
next = s.jobs[index].ID + 1
}
}
return next
}
// prependLog adds a record to the front of a runtime's activity list and caps
// its length so it cannot grow without bound.
func prependLog(runtime *domain.JobRuntime, record domain.RunRecord) {
runtime.Logs = append([]domain.RunRecord{record}, runtime.Logs...)
if len(runtime.Logs) > maxJobLogs {
runtime.Logs = runtime.Logs[:maxJobLogs]
}
}
// uiRecord builds an activity record for a user/Service action, using the same
// timestamp shape and "UI" trigger as the GUI did so History stays consistent.
func uiRecord(jobID int, jobName string, state string, detail string) domain.RunRecord {
return domain.RunRecord{
Time: time.Now().Format(timestampLayout),
JobID: jobID,
JobName: jobName,
Trigger: "UI",
State: state,
Detail: detail,
}
}
// 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) {
job.Name = strings.TrimSpace(job.Name)
job.Folder = strings.TrimSpace(job.Folder)
job.Schedule = strings.TrimSpace(job.Schedule)
job.Command = strings.TrimSpace(job.Command)
job.Arguments = strings.TrimSpace(job.Arguments)
job.SuccessExitCodes = strings.TrimSpace(job.SuccessExitCodes)
if job.SuccessExitCodes == "" {
job.SuccessExitCodes = "0"
}
}
// validateJob enforces the minimum executable definition: name, schedule, and
// command must be present. Folder is optional. The schedule string itself is not
// rejected for being unparseable — that surfaces later as an "Invalid schedule"
// next-run, matching the prior behavior.
func validateJob(job domain.Job) error {
if job.Name == "" || job.Schedule == "" || job.Command == "" {
return errors.New("name, schedule, and command are required")
}
return nil
}
// validateConfig rejects settings that would break persistence or cleanup.
func validateConfig(config domain.Config) error {
if strings.TrimSpace(config.JobsDir) == "" {
return errors.New("jobs directory is required")
}
if strings.TrimSpace(config.LogsDir) == "" {
return errors.New("logs directory is required")
}
if config.MaxLogFiles <= 0 {
return errors.New("max log files must be a positive number")
}
if config.MaxLogAgeDays <= 0 {
return errors.New("max log age days must be a positive number")
}
return nil
}
+536
View File
@@ -0,0 +1,536 @@
package app
import (
"context"
"path/filepath"
"sync/atomic"
"testing"
"time"
"gitea.mixdep.ru/mix/gosentry/src/domain"
"gitea.mixdep.ru/mix/gosentry/src/storage"
)
// newTempService builds a Service backed by a store rooted in a temp directory,
// so the mutating operations can persist to real (throwaway) files.
func newTempService(t *testing.T, jobs []domain.Job) *Service {
t.Helper()
dir := t.TempDir()
store := &storage.Store{
Paths: storage.Paths{
ExecutablePath: filepath.Join(dir, "gosentry"),
AppDir: dir,
ConfigPath: filepath.Join(dir, "gosentry.yaml"),
JobsDir: dir,
JobsPath: filepath.Join(dir, "jobs.yaml"),
LogsDir: filepath.Join(dir, "logs"),
},
Config: domain.Config{JobsDir: ".", LogsDir: "logs", MaxLogFiles: 100, MaxLogAgeDays: 30},
}
return NewService(store, jobs)
}
// recorder is a test observer that captures every emitted event.
type recorder struct {
events []Event
}
func (r *recorder) OnEvent(e Event) { r.events = append(r.events, e) }
func (r *recorder) jobChanged() (ids []int) {
for _, e := range r.events {
if jc, ok := e.(JobChanged); ok {
ids = append(ids, jc.JobID)
}
}
return ids
}
func (r *recorder) records() (out []domain.RunRecord) {
for _, e := range r.events {
if rr, ok := e.(RunRecorded); ok {
out = append(out, rr.Record)
}
}
return out
}
func TestCreateJobAssignsIDAndEmits(t *testing.T) {
svc := newTempService(t, nil)
rec := &recorder{}
svc.Subscribe(rec)
created, err := svc.CreateJob(domain.Job{Name: "Build", Schedule: "@every 1m", Command: "echo hi", Enabled: true})
if err != nil {
t.Fatalf("CreateJob: %v", err)
}
if created.ID != 1 {
t.Errorf("first job ID = %d, want 1", created.ID)
}
if got := svc.Jobs(); len(got) != 1 || got[0].Name != "Build" {
t.Fatalf("jobs after create = %+v", got)
}
if rt := svc.Runtime(1); rt == nil || rt.LastState != "Ready" {
t.Errorf("runtime = %+v, want LastState Ready", rt)
}
if recs := rec.records(); len(recs) != 1 || recs[0].State != "Created" {
t.Errorf("records = %+v, want one Created", recs)
}
if ids := rec.jobChanged(); len(ids) != 1 || ids[0] != 1 {
t.Errorf("JobChanged ids = %v, want [1]", ids)
}
// A second job takes the next free ID.
second, err := svc.CreateJob(domain.Job{Name: "Two", Schedule: "@every 1m", Command: "echo two"})
if err != nil {
t.Fatalf("CreateJob 2: %v", err)
}
if second.ID != 2 {
t.Errorf("second job ID = %d, want 2", second.ID)
}
}
func TestCreateJobValidates(t *testing.T) {
svc := newTempService(t, nil)
if _, err := svc.CreateJob(domain.Job{Schedule: "@every 1m", Command: "echo"}); err == nil {
t.Error("expected error for missing name")
}
if got := svc.Jobs(); len(got) != 0 {
t.Errorf("invalid job should not be stored, jobs = %+v", got)
}
}
func TestUpdateJobKeepsRuntimeAndReflectsDisable(t *testing.T) {
svc := newTempService(t, []domain.Job{{ID: 5, Name: "Old", Schedule: "@every 1m", Command: "echo", Enabled: true}})
if err := svc.UpdateJob(domain.Job{ID: 5, Name: "New", Schedule: "@every 1m", Command: "echo", Enabled: false}); err != nil {
t.Fatalf("UpdateJob: %v", err)
}
got := svc.Jobs()
if got[0].Name != "New" || got[0].Enabled {
t.Errorf("job after update = %+v", got[0])
}
if rt := svc.Runtime(5); rt == nil || rt.LastState != "Paused" || rt.NextRun != "Paused" {
t.Errorf("runtime after disable = %+v", rt)
}
}
func TestUpdateJobReenablesPausedJob(t *testing.T) {
svc := newTempService(t, []domain.Job{{ID: 5, Name: "Old", Schedule: "@every 1m", Command: "echo", Enabled: false}})
if rt := svc.Runtime(5); rt.LastState != "Paused" {
t.Fatalf("precondition: runtime = %+v, want Paused", rt)
}
if err := svc.UpdateJob(domain.Job{ID: 5, Name: "Old", Schedule: "@every 1m", Command: "echo", Enabled: true}); err != nil {
t.Fatalf("UpdateJob: %v", err)
}
if rt := svc.Runtime(5); rt.LastState != "Ready" || rt.NextDue.IsZero() {
t.Errorf("re-enabled runtime = %+v, want Ready with a next-due", rt)
}
}
// runtimeForLocked lazily recreates a missing runtime entry so the Service stays
// robust if a job somehow lacks one. Dropping the entry and driving an operation
// that needs it exercises that path.
func TestRuntimeLazilyRecreated(t *testing.T) {
svc := newTempService(t, []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: true}})
svc.mu.Lock()
delete(svc.runtimes, 1)
svc.mu.Unlock()
if err := svc.SetEnabled(1, true); err != nil {
t.Fatalf("SetEnabled: %v", err)
}
if rt := svc.Runtime(1); rt == nil {
t.Error("runtime was not lazily recreated")
}
}
func TestUpdateJobNotFound(t *testing.T) {
svc := newTempService(t, nil)
if err := svc.UpdateJob(domain.Job{ID: 99, Name: "X", Schedule: "@every 1m", Command: "echo"}); err == nil {
t.Error("expected not-found error")
}
}
func TestDeleteJobRemovesEverything(t *testing.T) {
svc := newTempService(t, []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: true}})
rec := &recorder{}
svc.Subscribe(rec)
if err := svc.DeleteJob(1); err != nil {
t.Fatalf("DeleteJob: %v", err)
}
if got := svc.Jobs(); len(got) != 0 {
t.Errorf("jobs after delete = %+v", got)
}
if rt := svc.Runtime(1); rt != nil {
t.Errorf("runtime should be gone, got %+v", rt)
}
if recs := rec.records(); len(recs) != 1 || recs[0].State != "Deleted" {
t.Errorf("records = %+v, want one Deleted", recs)
}
if ids := rec.jobChanged(); len(ids) != 1 || ids[0] != 0 {
t.Errorf("JobChanged ids = %v, want [0] (broad)", ids)
}
}
func TestDeleteJobNotFound(t *testing.T) {
svc := newTempService(t, nil)
if err := svc.DeleteJob(42); err == nil {
t.Error("expected not-found error deleting unknown job")
}
}
func TestSetEnabledNotFound(t *testing.T) {
svc := newTempService(t, nil)
if err := svc.SetEnabled(42, true); err == nil {
t.Error("expected not-found error enabling unknown job")
}
}
func TestSetEnabledToggles(t *testing.T) {
svc := newTempService(t, []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: false}})
if err := svc.SetEnabled(1, true); err != nil {
t.Fatalf("SetEnabled true: %v", err)
}
if rt := svc.Runtime(1); rt.LastState != "Ready" || rt.NextDue.IsZero() {
t.Errorf("enabled runtime = %+v, want Ready with a next-due", rt)
}
if err := svc.SetEnabled(1, false); err != nil {
t.Fatalf("SetEnabled false: %v", err)
}
if rt := svc.Runtime(1); rt.LastState != "Paused" || !rt.NextDue.IsZero() {
t.Errorf("disabled runtime = %+v, want Paused with no next-due", rt)
}
}
func TestSetGlobalPauseUpdatesRuntimesAndEmits(t *testing.T) {
svc := newTempService(t, []domain.Job{
{ID: 1, Name: "On", Schedule: "@every 1m", Command: "echo", Enabled: true},
{ID: 2, Name: "Off", Schedule: "@every 1m", Command: "echo", Enabled: false},
})
rec := &recorder{}
svc.Subscribe(rec)
if err := svc.SetGlobalPause(true); err != nil {
t.Fatalf("SetGlobalPause: %v", err)
}
if rt := svc.Runtime(1); rt.NextRun != "Scheduler paused" {
t.Errorf("enabled job next-run = %q, want %q", rt.NextRun, "Scheduler paused")
}
if rt := svc.Runtime(2); rt.NextRun != "Paused" {
t.Errorf("disabled job next-run = %q, want %q", rt.NextRun, "Paused")
}
var sawState bool
for _, e := range rec.events {
if ss, ok := e.(SchedulerStateChanged); ok && ss.Paused {
sawState = true
}
}
if !sawState {
t.Error("expected a SchedulerStateChanged{Paused:true} event")
}
// Resuming recomputes a real next run for the enabled job.
if err := svc.SetGlobalPause(false); err != nil {
t.Fatalf("resume: %v", err)
}
if rt := svc.Runtime(1); rt.NextDue.IsZero() {
t.Errorf("resumed enabled job should have a next-due, got %+v", rt)
}
}
func TestRunNowUsesRunnerAndRecords(t *testing.T) {
svc := newTempService(t, []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: true}})
done := make(chan domain.RunRecord, 1)
svc.runJob = func(_ context.Context, job *domain.Job, trigger string, _ string) 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"}
}
svc.Subscribe(ObserverFunc(func(e Event) {
if rr, ok := e.(RunRecorded); ok && rr.Record.JobID == 1 {
select {
case done <- rr.Record:
default:
}
}
}))
if err := svc.RunNow(1); err != nil {
t.Fatalf("RunNow: %v", err)
}
select {
case record := <-done:
if record.State != "Success" {
t.Errorf("recorded state = %q, want Success", record.State)
}
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for run to be recorded")
}
if rt := svc.Runtime(1); rt.LastState != "Success" || rt.Output != "ok" {
t.Errorf("runtime after run = %+v", rt)
}
}
func TestRunNowNotFound(t *testing.T) {
svc := newTempService(t, nil)
if err := svc.RunNow(99); err == nil {
t.Error("expected not-found error for unknown job")
}
}
func TestRunNowRefusedWhileAlreadyRunning(t *testing.T) {
svc := newTempService(t, []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: true}})
// Park the job in the "Running" state so a second RunNow must refuse: the
// runner signals once it has started and then blocks until released.
entered := make(chan struct{}, 1)
release := make(chan struct{})
var calls int32
svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string) 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"}
}
done := make(chan struct{}, 1)
svc.Subscribe(ObserverFunc(func(e Event) {
if rr, ok := e.(RunRecorded); ok && rr.Record.State == "Success" {
select {
case done <- struct{}{}:
default:
}
}
}))
if err := svc.RunNow(1); err != nil {
t.Fatalf("first RunNow: %v", err)
}
<-entered // the run is now in-flight and blocked
if err := svc.RunNow(1); err == nil {
t.Error("expected RunNow to be refused while already running")
}
close(release)
// Wait for the in-flight run to finish before returning so its background
// writes complete before t.TempDir cleanup removes the directory.
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for the in-flight run to complete")
}
// Only the first run should ever have reached the runner.
if got := atomic.LoadInt32(&calls); got != 1 {
t.Errorf("runner called %d times, want 1", got)
}
}
func TestRunNowRefusedWhilePaused(t *testing.T) {
svc := newTempService(t, []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: true}})
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)
}
if err := svc.RunNow(1); err == nil {
t.Error("expected RunNow to be refused while paused")
}
if ran {
t.Error("runner must not be invoked while paused")
}
}
func TestRunDueStartsDueJob(t *testing.T) {
svc := newTempService(t, []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: true}})
done := make(chan domain.RunRecord, 1)
svc.runJob = func(_ context.Context, job *domain.Job, trigger string, _ string) 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"}
}
svc.Subscribe(ObserverFunc(func(e Event) {
if rr, ok := e.(RunRecorded); ok && rr.Record.JobID == 1 && rr.Record.State == "Success" {
select {
case done <- rr.Record:
default:
}
}
}))
// The job's next-due was primed ~1m ahead at construction; tick well past it.
svc.RunDue(time.Now().Add(2 * time.Minute))
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("RunDue did not start the due job")
}
if rt := svc.Runtime(1); rt.LastState != "Success" || rt.Output != "ok" {
t.Errorf("runtime after scheduled run = %+v", rt)
}
}
func TestRunDueSkipsJobNotYetDue(t *testing.T) {
svc := newTempService(t, []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: true}})
var ran int32
svc.runJob = func(context.Context, *domain.Job, string, string) domain.RunRecord {
atomic.AddInt32(&ran, 1)
return domain.RunRecord{}
}
// Next-due is ~1m out, so nothing is due "now".
svc.RunDue(time.Now())
time.Sleep(50 * time.Millisecond)
if atomic.LoadInt32(&ran) != 0 {
t.Error("RunDue ran a job before it was due")
}
}
// TestRunDueSkipsJobInRunningState verifies that RunDue will not start a second
// concurrent instance of a job that is already in "Running" state — even if the
// job's NextDue is in the past. This guards against the window between
// executeRun completing and refreshNextRunLocked setting a new NextDue.
func TestRunDueSkipsJobInRunningState(t *testing.T) {
svc := newTempService(t, []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: true}})
var calls int32
svc.runJob = func(context.Context, *domain.Job, string, string) domain.RunRecord {
atomic.AddInt32(&calls, 1)
return domain.RunRecord{State: "Success"}
}
// 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) domain.RunRecord {
atomic.AddInt32(&ran, 1)
return domain.RunRecord{}
}
if err := svc.SetGlobalPause(true); err != nil {
t.Fatalf("SetGlobalPause: %v", err)
}
svc.RunDue(time.Now().Add(2 * time.Minute))
time.Sleep(50 * time.Millisecond)
if atomic.LoadInt32(&ran) != 0 {
t.Error("RunDue ran a job while globally paused")
}
}
// appFakeClock is a scheduler.Clock whose tick and "now" the test controls, used
// to verify Start wires the loop to RunDue without the wall clock.
type appFakeClock struct {
ticks chan time.Time
now time.Time
}
func (c *appFakeClock) Now() time.Time { return c.now }
func (c *appFakeClock) Ticks() <-chan time.Time { return c.ticks }
func (c *appFakeClock) Stop() {}
func TestStartDrivesRunDueOnTick(t *testing.T) {
svc := newTempService(t, []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: true}})
done := make(chan struct{}, 1)
svc.runJob = func(context.Context, *domain.Job, string, string) domain.RunRecord {
select {
case done <- struct{}{}:
default:
}
return domain.RunRecord{State: "Success"}
}
clock := &appFakeClock{ticks: make(chan time.Time, 1), now: time.Now().Add(2 * time.Minute)}
svc.StartWith(clock)
defer svc.Stop()
clock.ticks <- clock.now
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("Start did not drive a run from a clock tick")
}
}
func TestUpdateSettingsPersistsAndValidates(t *testing.T) {
svc := newTempService(t, nil)
bad := svc.store.Config
bad.MaxLogFiles = 0
if err := svc.UpdateSettings(bad); err == nil {
t.Error("expected validation error for non-positive max log files")
}
good := svc.store.Config
good.NotifyOnFailure = false
good.MaxLogAgeDays = 7
if err := svc.UpdateSettings(good); err != nil {
t.Fatalf("UpdateSettings: %v", err)
}
if svc.Store().Config.MaxLogAgeDays != 7 || svc.Store().Config.NotifyOnFailure {
t.Errorf("config not applied: %+v", svc.Store().Config)
}
}
func TestUpdateSettingsRejectsInvalidConfigs(t *testing.T) {
svc := newTempService(t, nil)
base := svc.store.Config
tests := []struct {
name string
mutate func(c *domain.Config)
}{
{"missing jobs dir", func(c *domain.Config) { c.JobsDir = " " }},
{"missing logs dir", func(c *domain.Config) { c.LogsDir = "" }},
{"non-positive max files", func(c *domain.Config) { c.MaxLogFiles = 0 }},
{"non-positive max age", func(c *domain.Config) { c.MaxLogAgeDays = -1 }},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
cfg := base
tc.mutate(&cfg)
if err := svc.UpdateSettings(cfg); err == nil {
t.Errorf("expected validation error for %s", tc.name)
}
})
}
}
func TestPrependLogCapsActivityList(t *testing.T) {
runtime := &domain.JobRuntime{}
for i := 0; i < maxJobLogs+10; i++ {
prependLog(runtime, domain.RunRecord{Detail: "r"})
}
if len(runtime.Logs) != maxJobLogs {
t.Errorf("activity list len = %d, want capped at %d", len(runtime.Logs), maxJobLogs)
}
}
+43
View File
@@ -0,0 +1,43 @@
package app
import (
"gitea.mixdep.ru/mix/gosentry/src/platform/desktop"
)
// InstallDesktopIcon installs the application's .desktop file and icon on
// Linux (no-op on other platforms). The resulting icon path is stored in
// store.Paths.DesktopIcon so ApplyAutostart can reference it.
func (s *Service) InstallDesktopIcon(appID string, iconBytes []byte) {
if iconPath, err := desktop.InstallDesktopIntegration(appID, s.store.Paths.ExecutablePath, iconBytes); err == nil {
s.store.Paths.DesktopIcon = iconPath
}
}
// AutostartStatus reports whether the platform autostart entry matches the
// current StartOnLogin setting in the stored config.
func (s *Service) AutostartStatus() (ok bool, message string) {
s.mu.Lock()
enabled := s.store.Config.StartOnLogin
execPath := s.store.Paths.ExecutablePath
manager := s.manager
s.mu.Unlock()
if manager == nil {
return false, "autostart not available"
}
return manager.Status(enabled, execPath)
}
// ApplyAutostart writes or removes the platform autostart entry to match the
// current StartOnLogin setting in the stored config. Call after UpdateSettings.
func (s *Service) ApplyAutostart() error {
s.mu.Lock()
enabled := s.store.Config.StartOnLogin
execPath := s.store.Paths.ExecutablePath
iconPath := s.store.Paths.DesktopIcon
manager := s.manager
s.mu.Unlock()
if manager == nil {
return nil
}
return manager.Set(enabled, execPath, iconPath)
}
+178
View File
@@ -0,0 +1,178 @@
package app
import (
"context"
"sync"
"time"
"gitea.mixdep.ru/mix/gosentry/src/domain"
"gitea.mixdep.ru/mix/gosentry/src/platform/autostart"
"gitea.mixdep.ru/mix/gosentry/src/runner"
"gitea.mixdep.ru/mix/gosentry/src/scheduler"
"gitea.mixdep.ru/mix/gosentry/src/storage"
)
// Service is the application-service layer: the single owner of GoSentry's
// in-memory state. It holds the durable jobs slice, the transient runtime map
// keyed by Job.ID, and a reference to the store that persists them. All access
// to that state goes through a mutex so the GUI and the scheduler can no longer
// race on a shared *[]Job.
//
// 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.
type Service struct {
mu sync.Mutex
store *storage.Store
jobs []domain.Job
runtimes map[int]*domain.JobRuntime
// schedules caches a parsed Schedule per job ID so timing math does not
// re-parse the schedule string on every use. paused is the global pause flag.
// Both are guarded by mu.
schedules map[int]domain.Schedule
paused bool
// runJob is the run seam. It defaults to runner.RunJob and is overridden in
// tests with a fake so the run paths can be exercised without spawning real
// processes. ctx is the lifecycle context passed to runs; Start replaces it
// with a cancelable context so Stop can abort in-flight runs, and until Start
// it is context.Background().
runJob func(ctx context.Context, job *domain.Job, trigger string, logsDir string) domain.RunRecord
ctx context.Context
// sched is the timing loop installed by Start; cancel tears down ctx on Stop.
// Both are guarded by mu.
sched *scheduler.Scheduler
cancel context.CancelFunc
// manager is the platform autostart implementation. It is nil in tests that
// do not exercise autostart; Open() wires it via autostart.New().
manager autostart.Manager
// observers and their guard live in events.go. dispatchMu is separate from mu
// so that emitting an event never requires (or is held under) the state lock:
// the Service must release mu before dispatching, per the locking contract.
dispatchMu sync.Mutex
observers []Observer
}
// NewService wires the Service to a loaded store and its jobs. It builds the
// initial runtime map from the durable jobs so every job has transient state
// from the moment the Service exists, and parses each job's schedule once. The
// store is the Service's sole channel to persistence.
func NewService(store *storage.Store, jobs []domain.Job) *Service {
s := &Service{
store: store,
jobs: jobs,
runtimes: domain.NewRuntimes(jobs),
schedules: make(map[int]domain.Schedule, len(jobs)),
runJob: runner.RunJob,
ctx: context.Background(),
}
// 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
}
// Start begins scheduling with the real wall clock. It is the production entry
// point; tests should call StartWith and supply a fake clock instead. Start is
// expected once, during setup, before any concurrent use.
func (s *Service) Start() {
s.StartWith(scheduler.NewRealClock())
}
// StartWith begins scheduling driven by the given clock; every tick calls
// RunDue. Used by tests to inject a fake clock.
func (s *Service) StartWith(clock scheduler.Clock) {
s.mu.Lock()
ctx, cancel := context.WithCancel(context.Background())
s.ctx = ctx
s.cancel = cancel
s.sched = scheduler.NewScheduler(clock, s.RunDue)
sched := s.sched
s.mu.Unlock()
sched.Start()
}
// Stop halts scheduling and cancels the run context so in-flight runs see a
// canceled context. It is safe to call when Start was never called.
func (s *Service) Stop() {
s.mu.Lock()
sched := s.sched
cancel := s.cancel
s.mu.Unlock()
if sched != nil {
sched.Stop()
}
if cancel != nil {
cancel()
}
}
// Open loads the store and constructs a Service from it in one step. It is the
// convenience entry point for the application; tests inject a pre-built store
// via NewService instead.
func Open() (*Service, error) {
store, jobs, err := storage.OpenStore()
if err != nil {
return nil, err
}
svc := NewService(store, jobs)
svc.manager = autostart.New()
return svc, nil
}
// Store returns the underlying store. It is exposed so callers that still need
// resolved paths and config (the GUI, during the transition) can reach them;
// later phases narrow this surface.
func (s *Service) Store() *storage.Store {
return s.store
}
// Jobs returns a copy of the durable jobs slice. Returning a copy keeps callers
// from mutating Service-owned state behind its back: the Service stays the sole
// writer.
func (s *Service) Jobs() []domain.Job {
s.mu.Lock()
defer s.mu.Unlock()
jobs := make([]domain.Job, len(s.jobs))
copy(jobs, s.jobs)
return jobs
}
// Runtime returns the transient runtime state for a job ID, or nil if no job
// with that ID is loaded. The returned pointer is the live runtime; reads of it
// are only safe while no concurrent mutation is in flight. The 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()
return s.runtimes[id]
}
+57
View File
@@ -0,0 +1,57 @@
package app
import (
"testing"
"gitea.mixdep.ru/mix/gosentry/src/domain"
"gitea.mixdep.ru/mix/gosentry/src/storage"
)
func newTestService(jobs []domain.Job) *Service {
return NewService(&storage.Store{}, jobs)
}
func TestNewServiceBuildsRuntimePerJob(t *testing.T) {
jobs := []domain.Job{
{ID: 1, Name: "Enabled", Enabled: true},
{ID: 2, Name: "Disabled", Enabled: false},
}
svc := newTestService(jobs)
if got := svc.Runtime(1); got == nil {
t.Fatal("expected runtime for enabled job 1")
} else if got.LastState != "Ready" {
t.Errorf("enabled job runtime state = %q, want %q", got.LastState, "Ready")
}
if got := svc.Runtime(2); got == nil {
t.Fatal("expected runtime for disabled job 2")
} else if got.LastState != "Paused" {
t.Errorf("disabled job runtime state = %q, want %q", got.LastState, "Paused")
}
if got := svc.Runtime(99); got != nil {
t.Errorf("expected nil runtime for unknown job, got %+v", got)
}
}
func TestJobsReturnsCopy(t *testing.T) {
jobs := []domain.Job{{ID: 1, Name: "Original"}}
svc := newTestService(jobs)
snapshot := svc.Jobs()
if len(snapshot) != 1 {
t.Fatalf("Jobs() len = %d, want 1", len(snapshot))
}
// Mutating the returned slice must not affect Service-owned state.
snapshot[0].Name = "Mutated"
if again := svc.Jobs(); again[0].Name != "Original" {
t.Errorf("Service state leaked through Jobs(): name = %q, want %q", again[0].Name, "Original")
}
}
func TestStoreReturnsWiredStore(t *testing.T) {
store := &storage.Store{}
svc := NewService(store, nil)
if svc.Store() != store {
t.Error("Store() did not return the wired store")
}
}
+2 -2
View File
@@ -1,6 +1,6 @@
package core
package app
// Version is the application version shown in the GUI and used by build
// scripts in artifact names. It is a var rather than a const so release builds
// can override it with Go ldflags when CI tags a build.
var Version = "0.3.2"
var Version = "0.4.0"
-19
View File
@@ -1,19 +0,0 @@
//go:build !windows && !linux
package core
import "fmt"
func SetAutostart(enabled bool, executablePath string, iconPath string) error {
if !enabled {
return nil
}
return fmt.Errorf("autostart is not implemented for this platform")
}
func AutostartStatus(expectedEnabled bool, executablePath string) (bool, string) {
if !expectedEnabled {
return true, "Autostart is off"
}
return false, "Autostart is not implemented for this platform"
}
-68
View File
@@ -1,68 +0,0 @@
package core
import "time"
// StartInTrayArgument is written to the Windows Startup shortcut so autostart
// can keep the scheduler running without flashing the main window. Manual
// launches omit this flag and open the normal window.
const StartInTrayArgument = "--start-in-tray"
// Config is stored in gosentry.yaml next to the program. It contains only
// application-level choices: where to read jobs from, where to write logs, and
// how the desktop shell should behave.
type Config struct {
JobsDir string `yaml:"jobs_dir"`
LogsDir string `yaml:"logs_dir"`
MaxLogFiles int `yaml:"max_log_files"`
MaxLogAgeDays int `yaml:"max_log_age_days"`
StartOnLogin bool `yaml:"start_on_login"`
KeepRunningInTray bool `yaml:"keep_running_in_tray"`
NotifyOnFailure bool `yaml:"notify_on_failure"`
}
// JobsFile is the on-disk shape of jobs.yaml. Wrapping the slice in a top-level
// object leaves room for future metadata without breaking the basic file format.
type JobsFile struct {
Jobs []Job `yaml:"jobs"`
}
// Job is the user-visible scheduled command.
//
// Fields with yaml:"-" are deliberately runtime-only. They are useful in the GUI
// while GoSentry is running, but writing them to jobs.yaml would make the jobs
// file noisy and would mix durable configuration with transient execution state.
type Job struct {
ID int `yaml:"id"`
Name string `yaml:"name"`
Folder string `yaml:"folder,omitempty"`
Schedule string `yaml:"schedule"`
Command string `yaml:"command"`
Arguments string `yaml:"arguments,omitempty"`
SuccessExitCodes string `yaml:"success_exit_codes,omitempty"`
StartOnly bool `yaml:"start_only,omitempty"`
Enabled bool `yaml:"enabled"`
LastRun string `yaml:"-"`
NextRun string `yaml:"-"`
LastState string `yaml:"-"`
Logs []RunRecord `yaml:"-"`
Output string `yaml:"-"`
// nextDue is kept as time.Time for scheduler comparisons. The formatted
// NextRun string above exists only for display in the GUI and YAML rewriting
// must not persist it.
nextDue time.Time
}
// RunRecord represents one visible activity item. Scheduled and manual command
// output is also written to a log file; the in-memory Output copy exists so the
// latest run can be displayed without reopening the log on every repaint.
type RunRecord struct {
Time string `yaml:"time"`
JobID int `yaml:"job_id"`
JobName string `yaml:"job_name"`
Trigger string `yaml:"trigger,omitempty"`
State string `yaml:"state"`
Detail string `yaml:"detail"`
LogFile string `yaml:"log_file,omitempty"`
Output string `yaml:"output,omitempty"`
}
-339
View File
@@ -1,339 +0,0 @@
package core
import (
"bytes"
"context"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
"unicode"
)
const commandTimeout = 30 * time.Second
const commandWaitDelay = 2 * time.Second
func RunJob(ctx context.Context, job *Job, trigger string, logsDir string) RunRecord {
started := time.Now()
// Commands can hang forever if a script waits for input or a child process
// stalls. A fixed timeout is a conservative first guardrail for a desktop
// scheduler; later it can become a per-job setting without changing the
// runner contract.
runCtx, cancel := context.WithTimeout(ctx, commandTimeout)
defer cancel()
var stdout bytes.Buffer
var stderr bytes.Buffer
var output string
var state string
var detail string
if job.StartOnly {
invocation := jobInvocation(context.Background(), *job)
state, detail, output = startJobOnly(invocation, *job, started)
} else {
invocation := jobInvocation(runCtx, *job)
command := invocation.command
command.WaitDelay = commandWaitDelay
if invocation.hideWindow {
configureHiddenWindow(command)
}
command.Stdout = &stdout
command.Stderr = &stderr
err := command.Run()
duration := time.Since(started).Round(time.Millisecond)
output = formatOutput(stdout.String(), stderr.String())
state, detail = runStateDetail(err, runCtx.Err(), duration, *job)
}
now := time.Now()
job.LastRun = now.Format("2006-01-02 15:04:05")
job.LastState = state
job.Output = output
logFile := writeRunLog(logsDir, *job, trigger, state, detail, output, now)
record := RunRecord{
Time: job.LastRun,
JobID: job.ID,
JobName: job.Name,
Trigger: trigger,
State: state,
Detail: detail,
LogFile: logFile,
Output: output,
}
// Keep a small in-memory history for the currently running GUI. Full command
// output is persisted to files, so retaining every past record in RAM would
// only duplicate data and make long sessions grow without bound.
job.Logs = append([]RunRecord{record}, job.Logs...)
if len(job.Logs) > 50 {
job.Logs = job.Logs[:50]
}
return record
}
func CleanupLogs(logsDir string, maxFiles int, maxAgeDays int) error {
entries, err := os.ReadDir(logsDir)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil
}
return err
}
type logFile struct {
path string
modTime time.Time
}
var logs []logFile
cutoff := time.Now().AddDate(0, 0, -maxAgeDays)
for _, entry := range entries {
// Only GoSentry run logs are managed here. Directories and non-.log files
// are intentionally ignored so the user can keep notes or other artifacts
// in the same folder without the cleanup policy deleting them.
if entry.IsDir() || !strings.HasSuffix(strings.ToLower(entry.Name()), ".log") {
continue
}
path := filepath.Join(logsDir, entry.Name())
info, err := entry.Info()
if err != nil {
continue
}
if maxAgeDays > 0 && info.ModTime().Before(cutoff) {
// Cleanup is best-effort: failing to delete one file should not block
// the scheduler from running future jobs.
_ = os.Remove(path)
continue
}
logs = append(logs, logFile{path: path, modTime: info.ModTime()})
}
if maxFiles <= 0 || len(logs) <= maxFiles {
return nil
}
sort.Slice(logs, func(i int, j int) bool {
// Newest files are kept first, then everything after maxFiles is removed.
// This matches the user's expectation that the most recent failures and
// command output remain available for investigation.
return logs[i].modTime.After(logs[j].modTime)
})
for _, old := range logs[maxFiles:] {
_ = os.Remove(old.path)
}
return nil
}
func writeRunLog(logsDir string, job Job, trigger string, state string, detail string, output string, started time.Time) string {
if strings.TrimSpace(logsDir) == "" {
return ""
}
if err := os.MkdirAll(logsDir, 0o755); err != nil {
return ""
}
// The timestamp comes first so a plain directory listing is naturally sorted
// by run time. The job name is included for human scanning, but sanitized to
// avoid characters that are invalid on Windows or awkward on shells.
fileName := started.Format("20060102-150405") + "_" + sanitizeFileName(job.Name) + ".log"
path := filepath.Join(logsDir, fileName)
content := fmt.Sprintf("time: %s\njob_id: %d\njob_name: %s\ntrigger: %s\nstate: %s\ndetail: %s\ncommand: %s\narguments: %s\nsuccess_exit_codes: %s\nstart_only: %t\n\n%s\n",
started.Format("2006-01-02 15:04:05"), job.ID, job.Name, trigger, state, detail, job.Command, logArguments(job.Arguments), successExitCodesText(job), job.StartOnly, output)
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
return ""
}
return path
}
func sanitizeFileName(name string) string {
name = strings.TrimSpace(name)
if name == "" {
return "job"
}
var builder strings.Builder
for _, r := range name {
switch {
case unicode.IsLetter(r), unicode.IsDigit(r):
builder.WriteRune(r)
case r == '-', r == '_':
builder.WriteRune(r)
default:
builder.WriteRune('_')
}
}
result := strings.Trim(builder.String(), "_")
if result == "" {
return "job"
}
return result
}
func startJobOnly(invocation commandInvocation, job Job, started time.Time) (string, string, string) {
command := invocation.command
if invocation.hideWindow {
configureHiddenWindow(command)
}
err := command.Start()
duration := time.Since(started).Round(time.Millisecond)
if err != nil {
return "Failed", fmt.Sprintf("%T: %v", err, err), startOnlyOutput(job, 0)
}
pid := command.Process.Pid
if releaseErr := command.Process.Release(); releaseErr != nil {
return "Failed", fmt.Sprintf("process started with pid %d, but release failed: %T: %v", pid, releaseErr, releaseErr), startOnlyOutput(job, pid)
}
return "OK", fmt.Sprintf("Started in %s (pid %d); not waiting for process exit", duration, pid), startOnlyOutput(job, pid)
}
func startOnlyOutput(job Job, pid int) string {
var builder strings.Builder
builder.WriteString("status:\n")
if pid > 0 {
builder.WriteString(fmt.Sprintf("Started process pid %d. GoSentry is not waiting for it to exit.\n\n", pid))
} else {
builder.WriteString("Process did not start.\n\n")
}
builder.WriteString("command:\n")
builder.WriteString(job.Command + "\n\n")
builder.WriteString("arguments:\n")
builder.WriteString(logArguments(job.Arguments))
builder.WriteString("\n\nstart_only:\ntrue")
return builder.String()
}
func runStateDetail(err error, runErr error, duration time.Duration, job Job) (string, string) {
if err == nil {
return "OK", fmt.Sprintf("Completed in %s (exit code 0)", duration)
}
if errors.Is(runErr, context.DeadlineExceeded) {
return "Failed", fmt.Sprintf("Timed out after %s", commandTimeout)
}
if errors.Is(err, exec.ErrWaitDelay) {
return "OK", fmt.Sprintf("Completed; output capture stopped after %s because a child process kept the stream open", commandWaitDelay)
}
var exitError *exec.ExitError
if errors.As(err, &exitError) {
exitCode := exitError.ExitCode()
if acceptedExitCode(exitCode, job.SuccessExitCodes) {
return "OK", fmt.Sprintf("Completed in %s with accepted exit code %d", duration, exitCode)
}
return "Failed", fmt.Sprintf("Exit code %d is not in success_exit_codes (%s)", exitCode, successExitCodesText(job))
}
return "Failed", fmt.Sprintf("%T: %v", err, err)
}
func acceptedExitCode(exitCode int, successExitCodes string) bool {
for _, accepted := range parseExitCodes(successExitCodes) {
if exitCode == accepted {
return true
}
}
return false
}
func parseExitCodes(value string) []int {
value = strings.TrimSpace(value)
if value == "" {
return []int{0}
}
fields := strings.FieldsFunc(value, func(r rune) bool {
return r == ',' || r == ';' || r == ' ' || r == '\t' || r == '\n' || r == '\r'
})
result := make([]int, 0, len(fields))
seen := map[int]bool{}
for _, field := range fields {
code, err := strconv.Atoi(strings.TrimSpace(field))
if err != nil || seen[code] {
continue
}
seen[code] = true
result = append(result, code)
}
if len(result) == 0 {
return []int{0}
}
return result
}
func successExitCodesText(job Job) string {
codes := parseExitCodes(job.SuccessExitCodes)
parts := make([]string, 0, len(codes))
for _, code := range codes {
parts = append(parts, strconv.Itoa(code))
}
return strings.Join(parts, ",")
}
type commandInvocation struct {
command *exec.Cmd
hideWindow bool
}
func jobInvocation(ctx context.Context, job Job) commandInvocation {
command := strings.TrimSpace(job.Command)
arguments := commandArguments(job.Arguments)
if len(arguments) > 0 || commandPathExists(command) {
return commandInvocation{
command: exec.CommandContext(ctx, unquoteCommandPath(command), arguments...),
hideWindow: false,
}
}
// Shell mode remains for existing jobs and for commands that intentionally
// use builtins, redirection, variables, or chained command syntax.
return commandInvocation{
command: shellCommand(ctx, command),
hideWindow: true,
}
}
func commandArguments(arguments string) []string {
var result []string
for _, line := range strings.FieldsFunc(arguments, func(r rune) bool {
return r == '\n' || r == '\r'
}) {
line = strings.TrimSpace(line)
if line != "" {
result = append(result, line)
}
}
return result
}
func commandPathExists(command string) bool {
command = unquoteCommandPath(strings.TrimSpace(command))
if command == "" {
return false
}
info, err := os.Stat(command)
return err == nil && !info.IsDir()
}
func unquoteCommandPath(command string) string {
return strings.Trim(strings.TrimSpace(command), `"`)
}
func logArguments(arguments string) string {
if strings.TrimSpace(arguments) == "" {
return "<empty>"
}
return strings.ReplaceAll(strings.TrimSpace(arguments), "\r\n", "\n")
}
func formatOutput(stdout string, stderr string) string {
stdout = strings.TrimSpace(stdout)
stderr = strings.TrimSpace(stderr)
if stdout == "" {
// Showing an explicit placeholder is clearer than an empty panel in the
// GUI: the user can tell that the command ran but produced no stream data.
stdout = "<empty>"
}
if stderr == "" {
stderr = "<empty>"
}
return "stdout:\n" + stdout + "\n\nstderr:\n" + stderr
}
-252
View File
@@ -1,252 +0,0 @@
package core
import (
"context"
"fmt"
"strings"
"sync"
"time"
"github.com/robfig/cron/v3"
)
var cronParser = cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow | cron.Descriptor)
// Scheduler owns the timing loop for jobs that are currently loaded in the GUI.
// It receives a pointer to the jobs slice because the GUI edits the same slice;
// this keeps the early architecture simple while storage and scheduling are
// still in one desktop process.
type Scheduler struct {
store *Store
jobs *[]Job
onChange func(RunRecord)
mu sync.Mutex
ctx context.Context
cancel context.CancelFunc
paused bool
}
func NewScheduler(store *Store, jobs *[]Job, onChange func(RunRecord)) *Scheduler {
ctx, cancel := context.WithCancel(context.Background())
s := &Scheduler{
store: store,
jobs: jobs,
onChange: onChange,
ctx: ctx,
cancel: cancel,
}
s.resetNextRuns(time.Now())
return s
}
func (s *Scheduler) Start() {
// A one-second ticker is accurate enough for cron-style desktop automation
// and avoids the complexity of maintaining one timer per job. Five-field cron
// expressions have minute precision, while @every values may be shorter for
// testing and lightweight local tasks.
ticker := time.NewTicker(time.Second)
go func() {
defer ticker.Stop()
for {
select {
case <-s.ctx.Done():
return
case now := <-ticker.C:
s.tick(now)
}
}
}()
}
func (s *Scheduler) Stop() {
s.cancel()
}
func (s *Scheduler) SetPaused(paused bool) {
s.mu.Lock()
defer s.mu.Unlock()
s.paused = paused
now := time.Now()
// Pause state is reflected into each job's display string so the list view is
// understandable even before the next scheduler tick.
for index := range *s.jobs {
job := &(*s.jobs)[index]
if !job.Enabled {
job.NextRun = "Paused"
continue
}
if paused {
job.NextRun = "Scheduler paused"
continue
}
s.prepareNextRun(job, now)
}
_ = s.store.SaveJobs(*s.jobs)
}
func (s *Scheduler) RunNow(index int) bool {
s.mu.Lock()
defer s.mu.Unlock()
if index < 0 || index >= len(*s.jobs) {
return false
}
// Manual runs share the same runner and log writer as scheduled runs. The
// Trigger field is the only difference, which keeps History comparable and
// prevents "Run now" from becoming a separate behavior path.
return s.startRunLocked(index, "Manual")
}
func (s *Scheduler) RefreshSchedule(index int) {
s.mu.Lock()
defer s.mu.Unlock()
if index < 0 || index >= len(*s.jobs) {
return
}
job := &(*s.jobs)[index]
if !job.Enabled {
job.NextRun = "Paused"
return
}
if s.paused {
job.NextRun = "Scheduler paused"
return
}
s.prepareNextRun(job, time.Now())
}
func (s *Scheduler) tick(now time.Time) {
var changed bool
s.mu.Lock()
if !s.paused {
for index := range *s.jobs {
job := &(*s.jobs)[index]
if !job.Enabled || job.nextDue.IsZero() || now.Before(job.nextDue) {
continue
}
// Run only one due job per tick for now. That avoids overlapping shell
// commands in the GUI process and keeps the first version predictable;
// a future worker pool can add concurrency once cancellation and status
// reporting are more explicit.
changed = s.startRunLocked(index, "Schedule")
break
}
}
s.mu.Unlock()
_ = changed
}
func (s *Scheduler) startRunLocked(index int, trigger string) bool {
job := &(*s.jobs)[index]
if job.LastState == "Running" {
return false
}
jobCopy := *job
job.LastState = "Running"
job.NextRun = "Running"
job.Output = runningOutput(jobCopy, trigger, time.Now())
job.nextDue = time.Time{}
_ = s.store.SaveJobs(*s.jobs)
go func() {
record := RunJob(s.ctx, &jobCopy, trigger, s.store.Paths.LogsDir)
s.mu.Lock()
if current := s.findJobByIDLocked(jobCopy.ID); current != nil {
current.LastRun = record.Time
current.LastState = record.State
current.Output = record.Output
current.Logs = append([]RunRecord{record}, current.Logs...)
if len(current.Logs) > 50 {
current.Logs = current.Logs[:50]
}
s.prepareNextRun(current, time.Now())
_ = CleanupLogs(s.store.Paths.LogsDir, s.store.Config.MaxLogFiles, s.store.Config.MaxLogAgeDays)
_ = s.store.SaveJobs(*s.jobs)
}
s.mu.Unlock()
if s.onChange != nil {
s.onChange(record)
}
}()
return true
}
func (s *Scheduler) findJobByIDLocked(id int) *Job {
for index := range *s.jobs {
if (*s.jobs)[index].ID == id {
return &(*s.jobs)[index]
}
}
return nil
}
func runningOutput(job Job, trigger string, started time.Time) string {
var builder strings.Builder
builder.WriteString("status:\n")
builder.WriteString("Running since " + started.Format("2006-01-02 15:04:05") + "\n\n")
builder.WriteString("trigger:\n")
builder.WriteString(trigger + "\n\n")
builder.WriteString("command:\n")
builder.WriteString(job.Command + "\n\n")
builder.WriteString("arguments:\n")
builder.WriteString(logArguments(job.Arguments))
builder.WriteString("\n\nsuccess_exit_codes:\n")
builder.WriteString(successExitCodesText(job))
builder.WriteString("\n\nstart_only:\n")
builder.WriteString(fmt.Sprintf("%t", job.StartOnly))
return builder.String()
}
func (s *Scheduler) resetNextRuns(now time.Time) {
for index := range *s.jobs {
job := &(*s.jobs)[index]
if !job.Enabled {
job.NextRun = "Paused"
continue
}
s.prepareNextRun(job, now)
}
_ = s.store.SaveJobs(*s.jobs)
}
func (s *Scheduler) prepareNextRun(job *Job, from time.Time) {
next, ok := nextRunTime(job.Schedule, from)
if !ok {
job.NextRun = "Invalid schedule"
job.nextDue = time.Time{}
return
}
job.nextDue = next
job.NextRun = job.nextDue.Format("2006-01-02 15:04:05")
}
func nextRunTime(schedule string, from time.Time) (time.Time, bool) {
schedule = strings.TrimSpace(schedule)
if schedule == "" {
return time.Time{}, false
}
if strings.HasPrefix(schedule, "@every ") {
// @every is kept alongside cron because it is convenient for quick tests
// and for simple intervals that are awkward to express as five fields.
interval, err := time.ParseDuration(strings.TrimSpace(strings.TrimPrefix(schedule, "@every ")))
if err != nil || interval <= 0 {
return time.Time{}, false
}
return from.Add(interval), true
}
// Standard five-field cron keeps GoSentry compatible with the mental model
// users already know from Unix cron, while robfig/cron handles edge cases
// such as ranges, steps, and day-of-week names.
parsed, err := cronParser.Parse(schedule)
if err != nil {
return time.Time{}, false
}
return parsed.Next(from), true
}
-54
View File
@@ -1,54 +0,0 @@
package core
import (
"strings"
"testing"
"time"
)
func TestNextRunTimeSupportsEvery(t *testing.T) {
from := time.Date(2026, 6, 14, 12, 0, 0, 0, time.UTC)
next, ok := nextRunTime("@every 10s", from)
if !ok {
t.Fatal("expected @every schedule to parse")
}
if want := from.Add(10 * time.Second); !next.Equal(want) {
t.Fatalf("expected %s, got %s", want, next)
}
}
func TestNextRunTimeSupportsCron(t *testing.T) {
from := time.Date(2026, 6, 14, 12, 3, 0, 0, time.UTC)
next, ok := nextRunTime("*/5 * * * *", from)
if !ok {
t.Fatal("expected cron schedule to parse")
}
want := time.Date(2026, 6, 14, 12, 5, 0, 0, time.UTC)
if !next.Equal(want) {
t.Fatalf("expected %s, got %s", want, next)
}
}
func TestRunningOutputIncludesInvocation(t *testing.T) {
started := time.Date(2026, 6, 17, 23, 40, 0, 0, time.Local)
job := Job{
Name: "Backup",
Command: `C:\Program Files\FreeFileSync\FreeFileSync.exe`,
Arguments: `D:\Local\Jobs\Auto.ffs_batch`,
SuccessExitCodes: "0,1",
}
output := runningOutput(job, "Manual", started)
for _, want := range []string{
"Running since 2026-06-17 23:40:00",
"Manual",
job.Command,
job.Arguments,
"0,1",
"start_only",
} {
if !strings.Contains(output, want) {
t.Fatalf("expected running output to contain %q, got:\n%s", want, output)
}
}
}
-38
View File
@@ -1,38 +0,0 @@
package core
import (
"strings"
"testing"
"go.yaml.in/yaml/v4"
)
func TestJobsYAMLDoesNotPersistRuntimeNoise(t *testing.T) {
jobs := []Job{
{
ID: 1,
Name: "Clean job",
Schedule: "@every 10s",
Command: echoCommand("ok"),
Enabled: true,
LastRun: "2026-06-14 12:00:00",
NextRun: "2026-06-14 12:00:10",
LastState: "OK",
Output: "stdout: ok",
Logs: []RunRecord{
{Time: "2026-06-14 12:00:00", JobName: "Clean job", Output: "stdout: ok"},
},
},
}
data, err := yaml.Marshal(JobsFile{Jobs: jobs})
if err != nil {
t.Fatal(err)
}
text := string(data)
for _, unwanted := range []string{"last_run", "next_run", "last_state", "activity", "last_output", "stdout"} {
if strings.Contains(text, unwanted) {
t.Fatalf("jobs yaml should not contain %q:\n%s", unwanted, text)
}
}
}
+25
View File
@@ -0,0 +1,25 @@
package domain
// StartInTrayArgument is written to the Windows Startup shortcut so autostart
// can keep the scheduler running without flashing the main window. Manual
// launches omit this flag and open the normal window.
const StartInTrayArgument = "--start-in-tray"
// Config is stored in gosentry.yaml next to the program. It contains only
// application-level choices: where to read jobs from, where to write logs, and
// how the desktop shell should behave.
type Config struct {
JobsDir string `yaml:"jobs_dir"`
LogsDir string `yaml:"logs_dir"`
MaxLogFiles int `yaml:"max_log_files"`
MaxLogAgeDays int `yaml:"max_log_age_days"`
StartOnLogin bool `yaml:"start_on_login"`
KeepRunningInTray bool `yaml:"keep_running_in_tray"`
NotifyOnFailure bool `yaml:"notify_on_failure"`
}
// JobsFile is the on-disk shape of jobs.yaml. Wrapping the slice in a top-level
// object leaves room for future metadata without breaking the basic file format.
type JobsFile struct {
Jobs []Job `yaml:"jobs"`
}
+18
View File
@@ -0,0 +1,18 @@
package domain
// Job is the user-visible scheduled command. It contains only durable
// 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 `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"`
}
+15
View File
@@ -0,0 +1,15 @@
package domain
// RunRecord represents one visible activity item. Scheduled and manual command
// output is also written to a log file; the in-memory Output copy exists so the
// latest run can be displayed without reopening the log on every repaint.
type RunRecord struct {
Time string `yaml:"time"`
JobID int `yaml:"job_id"`
JobName string `yaml:"job_name"`
Trigger string `yaml:"trigger,omitempty"`
State string `yaml:"state"`
Detail string `yaml:"detail"`
LogFile string `yaml:"log_file,omitempty"`
Output string `yaml:"output,omitempty"`
}
+49
View File
@@ -0,0 +1,49 @@
package domain
import "time"
// JobRuntime is the transient execution state for a Job. It is never written to
// 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.
type JobRuntime struct {
LastRun string
NextRun string
LastState string
Output string
Logs []RunRecord
// NextDue is the next scheduled execution time, kept as time.Time for
// scheduler comparisons. NextRun above is its formatted display string and is
// the only form shown in the GUI.
NextDue time.Time
}
// NewRuntime builds the initial runtime state for a freshly loaded or created
// job. Enabled jobs start "Ready" and wait for the scheduler to compute their
// first run; disabled jobs start "Paused".
func NewRuntime(job Job) *JobRuntime {
runtime := &JobRuntime{
LastRun: "Never",
Output: "No command output captured yet.",
}
if job.Enabled {
runtime.LastState = "Ready"
runtime.NextRun = "After start"
} else {
runtime.LastState = "Paused"
runtime.NextRun = "Paused"
}
return runtime
}
// NewRuntimes builds a runtime map for a slice of jobs, keyed by Job.ID. It is
// the convenience entry point used when a whole jobs file has just been loaded.
func NewRuntimes(jobs []Job) map[int]*JobRuntime {
runtimes := make(map[int]*JobRuntime, len(jobs))
for _, job := range jobs {
runtimes[job.ID] = NewRuntime(job)
}
return runtimes
}
+87
View File
@@ -0,0 +1,87 @@
package domain
import (
"fmt"
"strings"
"time"
"github.com/robfig/cron/v3"
)
// cronParser accepts standard five-field cron expressions (minute, hour, day of
// month, month, day of week) plus descriptors such as "@daily". It is the single
// source of truth for what GoSentry considers a valid cron schedule.
var cronParser = cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow | cron.Descriptor)
// everyPrefix marks the "@every <duration>" form, which is kept alongside cron
// because it is convenient for quick tests and for simple intervals that are
// awkward to express as five fields.
const everyPrefix = "@every "
// Schedule is a parsed, validated job schedule. It supports two forms:
//
// - "@every <duration>" intervals (e.g. "@every 10s"), and
// - standard five-field cron expressions (e.g. "*/5 * * * *").
//
// Parsing once and reusing the value avoids re-validating and re-parsing the
// same string on every scheduler tick. A zero Schedule is invalid; its Next
// method returns the zero time.
type Schedule struct {
raw string
every time.Duration // > 0 when the schedule is an "@every" interval
cron cron.Schedule // non-nil when the schedule is a cron expression
}
// Parse validates spec and returns a reusable Schedule. It returns an error
// describing why the schedule is unusable, which callers can surface to the user.
func Parse(spec string) (Schedule, error) {
trimmed := strings.TrimSpace(spec)
if trimmed == "" {
return Schedule{}, fmt.Errorf("schedule is empty")
}
if strings.HasPrefix(trimmed, everyPrefix) {
interval, err := time.ParseDuration(strings.TrimSpace(strings.TrimPrefix(trimmed, everyPrefix)))
if err != nil {
return Schedule{}, fmt.Errorf("invalid %q duration: %w", strings.TrimSpace(everyPrefix), err)
}
if interval <= 0 {
return Schedule{}, fmt.Errorf("%q duration must be positive, got %s", strings.TrimSpace(everyPrefix), interval)
}
return Schedule{raw: trimmed, every: interval}, nil
}
// robfig/cron handles edge cases such as ranges, steps, and day-of-week names,
// keeping GoSentry compatible with the mental model users know from Unix cron.
parsed, err := cronParser.Parse(trimmed)
if err != nil {
return Schedule{}, fmt.Errorf("invalid cron expression: %w", err)
}
return Schedule{raw: trimmed, cron: parsed}, nil
}
// Validate reports whether spec is a usable schedule string. It is a convenience
// wrapper around Parse for callers (such as form validation) that only need the
// yes/no answer and the error message.
func Validate(spec string) error {
_, err := Parse(spec)
return err
}
// Next returns the next time the schedule fires strictly after from. For an
// "@every" interval this is from plus the interval; for a cron expression it is
// the cron library's next matching time. A zero (unparsed) Schedule returns the
// zero time.
func (s Schedule) Next(from time.Time) time.Time {
switch {
case s.every > 0:
return from.Add(s.every)
case s.cron != nil:
return s.cron.Next(from)
default:
return time.Time{}
}
}
// String returns the original, trimmed schedule specification.
func (s Schedule) String() string {
return s.raw
}
+103
View File
@@ -0,0 +1,103 @@
package domain
import (
"testing"
"time"
)
func TestParseRejectsInvalidSchedules(t *testing.T) {
cases := []struct {
spec string
desc string
}{
{"", "empty string"},
{" ", "whitespace only"},
{"@every", "bare @every without duration"},
{"@every ", "@every with trailing space but no duration"},
{"@every xyz", "invalid @every duration string"},
{"@every -1s", "negative @every duration"},
{"@every 0s", "zero @every duration"},
{"not-a-cron", "invalid cron expression"},
{"60 * * * *", "cron minute out of range"},
{"* * * *", "too few cron fields"},
}
for _, tc := range cases {
if _, err := Parse(tc.spec); err == nil {
t.Errorf("Parse(%q) [%s]: expected error, got nil", tc.spec, tc.desc)
}
if err := Validate(tc.spec); err == nil {
t.Errorf("Validate(%q) [%s]: expected error, got nil", tc.spec, tc.desc)
}
}
}
func TestParseEveryInterval(t *testing.T) {
from := time.Date(2026, 6, 14, 12, 0, 0, 0, time.UTC)
s, err := Parse("@every 10s")
if err != nil {
t.Fatalf("Parse(@every 10s): unexpected error: %v", err)
}
if got, want := s.Next(from), from.Add(10*time.Second); !got.Equal(want) {
t.Fatalf("Next: got %s, want %s", got, want)
}
}
func TestParseEveryTrimsSurroundingWhitespace(t *testing.T) {
from := time.Date(2026, 6, 14, 12, 0, 0, 0, time.UTC)
s, err := Parse(" @every 90m ")
if err != nil {
t.Fatalf("Parse: unexpected error: %v", err)
}
if got, want := s.Next(from), from.Add(90*time.Minute); !got.Equal(want) {
t.Fatalf("Next: got %s, want %s", got, want)
}
}
func TestParseCronExpression(t *testing.T) {
from := time.Date(2026, 6, 14, 12, 3, 0, 0, time.UTC)
s, err := Parse("*/5 * * * *")
if err != nil {
t.Fatalf("Parse(*/5 * * * *): unexpected error: %v", err)
}
want := time.Date(2026, 6, 14, 12, 5, 0, 0, time.UTC)
if got := s.Next(from); !got.Equal(want) {
t.Fatalf("Next: got %s, want %s", got, want)
}
}
func TestParseCronDescriptor(t *testing.T) {
from := time.Date(2026, 6, 14, 12, 3, 0, 0, time.UTC)
s, err := Parse("@daily")
if err != nil {
t.Fatalf("Parse(@daily): unexpected error: %v", err)
}
want := time.Date(2026, 6, 15, 0, 0, 0, 0, time.UTC)
if got := s.Next(from); !got.Equal(want) {
t.Fatalf("Next: got %s, want %s", got, want)
}
}
func TestValidateAcceptsValidSchedules(t *testing.T) {
for _, spec := range []string{"@every 1s", "*/5 * * * *", "0 9 * * 1", "@hourly"} {
if err := Validate(spec); err != nil {
t.Errorf("Validate(%q): unexpected error: %v", spec, err)
}
}
}
func TestZeroScheduleNextIsZero(t *testing.T) {
var s Schedule
if got := s.Next(time.Now()); !got.IsZero() {
t.Fatalf("zero Schedule Next: got %s, want zero time", got)
}
}
func TestStringReturnsTrimmedSpec(t *testing.T) {
s, err := Parse(" */5 * * * * ")
if err != nil {
t.Fatalf("Parse: unexpected error: %v", err)
}
if got, want := s.String(), "*/5 * * * *"; got != want {
t.Fatalf("String: got %q, want %q", got, want)
}
}
-1057
View File
File diff suppressed because it is too large Load Diff
+9
View File
@@ -0,0 +1,9 @@
package autostart
// Manager controls platform autostart for the application.
type Manager interface {
// Set writes or removes the platform autostart entry to match enabled.
Set(enabled bool, executablePath, iconPath string) error
// Status reports whether the platform autostart entry matches expectedEnabled.
Status(expectedEnabled bool, executablePath string) (ok bool, message string)
}
@@ -1,6 +1,6 @@
//go:build linux
package core
package autostart
import (
"fmt"
@@ -9,8 +9,23 @@ import (
"path/filepath"
"strconv"
"strings"
"gitea.mixdep.ru/mix/gosentry/src/domain"
)
type linuxManager struct{}
// New returns the Linux autostart Manager.
func New() Manager { return linuxManager{} }
func (linuxManager) Set(enabled bool, executablePath, iconPath string) error {
return SetAutostart(enabled, executablePath, iconPath)
}
func (linuxManager) Status(expectedEnabled bool, executablePath string) (bool, string) {
return AutostartStatus(expectedEnabled, executablePath)
}
const autostartDesktopFileName = "gosentry.desktop"
const legacyAutostartDesktopFileName = "pysentry.desktop"
@@ -43,7 +58,7 @@ Exec=%s %s
%s
Terminal=false
X-GNOME-Autostart-enabled=true
`, quoteDesktopExec(executablePath), StartInTrayArgument, desktopIconLine(iconPath))
`, quoteDesktopExec(executablePath), domain.StartInTrayArgument, desktopIconLine(iconPath))
return os.WriteFile(desktopPath, []byte(desktopFile), 0o644)
}
@@ -75,7 +90,7 @@ func AutostartStatus(expectedEnabled bool, executablePath string) (bool, string)
if readErr != nil {
return false, "Autostart desktop entry is missing"
}
expectedExec := "Exec=" + quoteDesktopExec(executablePath) + " " + StartInTrayArgument
expectedExec := "Exec=" + quoteDesktopExec(executablePath) + " " + domain.StartInTrayArgument
if !strings.Contains(string(data), expectedExec) {
return false, "Autostart desktop entry points to another executable"
}
@@ -1,12 +1,14 @@
//go:build linux
package core
package autostart
import (
"os"
"path/filepath"
"strings"
"testing"
"gitea.mixdep.ru/mix/gosentry/src/domain"
)
func TestLinuxAutostartStartsInTray(t *testing.T) {
@@ -26,7 +28,7 @@ func TestLinuxAutostartStartsInTray(t *testing.T) {
t.Fatalf("read desktop entry: %v", err)
}
expectedExec := "Exec=" + quoteDesktopExec(executablePath) + " " + StartInTrayArgument
expectedExec := "Exec=" + quoteDesktopExec(executablePath) + " " + domain.StartInTrayArgument
if !strings.Contains(string(data), expectedExec) {
t.Fatalf("desktop entry does not start in tray: %s", data)
}
+32
View File
@@ -0,0 +1,32 @@
//go:build !windows && !linux
package autostart
import "fmt"
type otherManager struct{}
// New returns the stub autostart Manager for unsupported platforms.
func New() Manager { return otherManager{} }
func (otherManager) Set(enabled bool, executablePath, iconPath string) error {
return SetAutostart(enabled, executablePath, iconPath)
}
func (otherManager) Status(expectedEnabled bool, executablePath string) (bool, string) {
return AutostartStatus(expectedEnabled, executablePath)
}
func SetAutostart(enabled bool, executablePath string, iconPath string) error {
if !enabled {
return nil
}
return fmt.Errorf("autostart is not implemented for this platform")
}
func AutostartStatus(expectedEnabled bool, executablePath string) (bool, string) {
if !expectedEnabled {
return true, "Autostart is off"
}
return false, "Autostart is not implemented for this platform"
}
@@ -1,4 +1,4 @@
package core
package autostart
import (
"fmt"
@@ -6,8 +6,24 @@ import (
"os/exec"
"path/filepath"
"strings"
"gitea.mixdep.ru/mix/gosentry/src/domain"
"gitea.mixdep.ru/mix/gosentry/src/platform/winproc"
)
type windowsManager struct{}
// New returns the Windows autostart Manager.
func New() Manager { return windowsManager{} }
func (windowsManager) Set(enabled bool, executablePath, iconPath string) error {
return SetAutostart(enabled, executablePath, iconPath)
}
func (windowsManager) Status(expectedEnabled bool, executablePath string) (bool, string) {
return AutostartStatus(expectedEnabled, executablePath)
}
const autostartName = "GoSentry"
const legacyAutostartName = "PySentry"
const startupShortcutFile = autostartName + ".lnk"
@@ -69,7 +85,7 @@ func AutostartStatus(expectedEnabled bool, executablePath string) (bool, string)
if !sameWindowsPath(actual, executablePath) {
return false, "Autostart shortcut points to another executable"
}
if strings.TrimSpace(arguments) != StartInTrayArgument {
if strings.TrimSpace(arguments) != domain.StartInTrayArgument {
return false, "Autostart shortcut does not start in tray"
}
return true, "Autostart is configured"
@@ -101,11 +117,11 @@ func createStartupShortcut(shortcutPath string, executablePath string, iconPath
command.Env = append(os.Environ(),
"GOSENTRY_SHORTCUT_PATH="+shortcutPath,
"GOSENTRY_TARGET_PATH="+executablePath,
"GOSENTRY_ARGUMENTS="+StartInTrayArgument,
"GOSENTRY_ARGUMENTS="+domain.StartInTrayArgument,
"GOSENTRY_WORKING_DIRECTORY="+workingDirectory,
"GOSENTRY_ICON_PATH="+iconPath,
)
configureHiddenWindow(command)
winproc.ConfigureHiddenWindow(command)
if output, err := command.CombinedOutput(); err != nil {
return fmt.Errorf("create startup shortcut: %w: %s", err, strings.TrimSpace(string(output)))
}
@@ -123,7 +139,7 @@ func readShortcut(shortcutPath string) (string, string, error) {
script := `[Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false); $shell = New-Object -ComObject WScript.Shell; $shortcut = $shell.CreateShortcut($env:GOSENTRY_SHORTCUT_PATH); [Console]::Out.Write($shortcut.TargetPath + [Environment]::NewLine + $shortcut.Arguments)`
command := exec.Command("powershell.exe", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", script)
command.Env = append(os.Environ(), "GOSENTRY_SHORTCUT_PATH="+shortcutPath)
configureHiddenWindow(command)
winproc.ConfigureHiddenWindow(command)
output, err := command.CombinedOutput()
if err != nil {
return "", "", fmt.Errorf("read startup shortcut: %w: %s", err, strings.TrimSpace(string(output)))
@@ -153,7 +169,7 @@ func removeIfExists(path string) error {
func cleanupLegacyRegistryAutostart() error {
for _, name := range []string{legacyAutostartName, autostartName} {
command := exec.Command("reg.exe", "delete", `HKCU\Software\Microsoft\Windows\CurrentVersion\Run`, "/v", name, "/f")
configureHiddenWindow(command)
winproc.ConfigureHiddenWindow(command)
_ = command.Run()
}
return nil
@@ -162,7 +178,7 @@ func cleanupLegacyRegistryAutostart() error {
func legacyRegistryAutostartExists() bool {
for _, name := range []string{legacyAutostartName, autostartName} {
command := exec.Command("reg.exe", "query", `HKCU\Software\Microsoft\Windows\CurrentVersion\Run`, "/v", name)
configureHiddenWindow(command)
winproc.ConfigureHiddenWindow(command)
if command.Run() == nil {
return true
}
@@ -1,12 +1,14 @@
//go:build windows
package core
package autostart
import (
"os"
"path/filepath"
"syscall"
"testing"
"gitea.mixdep.ru/mix/gosentry/src/domain"
)
func TestParseRegistryRunValue(t *testing.T) {
@@ -111,8 +113,8 @@ func TestCreateStartupShortcutHandlesCyrillicPath(t *testing.T) {
if !sameWindowsPath(actual, targetPath) {
t.Fatalf("shortcut target mismatch: got %q want %q", actual, targetPath)
}
if arguments != StartInTrayArgument {
t.Fatalf("shortcut arguments mismatch: got %q want %q", arguments, StartInTrayArgument)
if arguments != domain.StartInTrayArgument {
t.Fatalf("shortcut arguments mismatch: got %q want %q", arguments, domain.StartInTrayArgument)
}
}
@@ -138,7 +140,7 @@ func TestCreateStartupShortcutHandlesSpaces(t *testing.T) {
if !sameWindowsPath(actual, targetPath) {
t.Fatalf("shortcut target mismatch: got %q want %q", actual, targetPath)
}
if arguments != StartInTrayArgument {
t.Fatalf("shortcut arguments mismatch: got %q want %q", arguments, StartInTrayArgument)
if arguments != domain.StartInTrayArgument {
t.Fatalf("shortcut arguments mismatch: got %q want %q", arguments, domain.StartInTrayArgument)
}
}
@@ -1,11 +1,12 @@
//go:build linux
package core
package desktop
import (
"fmt"
"os"
"path/filepath"
"strconv"
)
func InstallDesktopIntegration(appID string, executablePath string, icon []byte) (string, error) {
@@ -40,6 +41,10 @@ StartupWMClass=%s
return iconPath, nil
}
func quoteDesktopExec(path string) string {
return strconv.Quote(path)
}
func xdgDataHome() (string, error) {
dataHome := os.Getenv("XDG_DATA_HOME")
if dataHome == "" {
@@ -1,6 +1,6 @@
//go:build !linux
package core
package desktop
func InstallDesktopIntegration(appID string, executablePath string, icon []byte) (string, error) {
return "", nil
+10
View File
@@ -0,0 +1,10 @@
//go:build !windows
package winproc
import "os/exec"
// ConfigureHiddenWindow is a no-op on non-Windows platforms: launching sh -c
// from a desktop process does not create a new console window in the same way
// Windows does.
func ConfigureHiddenWindow(command *exec.Cmd) {}
+18
View File
@@ -0,0 +1,18 @@
package winproc
import (
"os/exec"
"syscall"
)
// ConfigureHiddenWindow suppresses the console window that Windows would
// otherwise flash when running a child process from a GUI application.
// CREATE_NO_WINDOW keeps cmd.exe and simple console tools quiet while
// stdout/stderr are still captured through pipes.
func ConfigureHiddenWindow(command *exec.Cmd) {
if command.SysProcAttr == nil {
command.SysProcAttr = &syscall.SysProcAttr{}
}
command.SysProcAttr.CreationFlags |= 0x08000000
command.SysProcAttr.HideWindow = true
}
+61
View File
@@ -0,0 +1,61 @@
package runner
import (
"errors"
"os"
"path/filepath"
"sort"
"strings"
"time"
)
func CleanupLogs(logsDir string, maxFiles int, maxAgeDays int) error {
entries, err := os.ReadDir(logsDir)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil
}
return err
}
type logFile struct {
path string
modTime time.Time
}
var logs []logFile
cutoff := time.Now().AddDate(0, 0, -maxAgeDays)
for _, entry := range entries {
// Only GoSentry run logs are managed here. Directories and non-.log files
// are intentionally ignored so the user can keep notes or other artifacts
// in the same folder without the cleanup policy deleting them.
if entry.IsDir() || !strings.HasSuffix(strings.ToLower(entry.Name()), ".log") {
continue
}
path := filepath.Join(logsDir, entry.Name())
info, err := entry.Info()
if err != nil {
continue
}
if maxAgeDays > 0 && info.ModTime().Before(cutoff) {
// Cleanup is best-effort: failing to delete one file should not block
// the scheduler from running future jobs.
_ = os.Remove(path)
continue
}
logs = append(logs, logFile{path: path, modTime: info.ModTime()})
}
if maxFiles <= 0 || len(logs) <= maxFiles {
return nil
}
sort.Slice(logs, func(i int, j int) bool {
// Newest files are kept first, then everything after maxFiles is removed.
// This matches the user's expectation that the most recent failures and
// command output remain available for investigation.
return logs[i].modTime.After(logs[j].modTime)
})
for _, old := range logs[maxFiles:] {
_ = os.Remove(old.path)
}
return nil
}
+154
View File
@@ -0,0 +1,154 @@
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)
}
}
func TestCleanupLogsKeepsFilesWithinAgeLimit(t *testing.T) {
dir := t.TempDir()
for i := 1; i <= 3; i++ {
path := writeLogFile(t, dir, fmt.Sprintf("job_%d.log", i))
setModTime(t, path, time.Duration(i)*24*time.Hour)
}
if err := CleanupLogs(dir, 100, 30); err != nil {
t.Fatal(err)
}
entries, _ := os.ReadDir(dir)
if len(entries) != 3 {
t.Errorf("expected 3 files kept within age limit, got %d", len(entries))
}
}
// TestCleanupLogsByCountDeletesOldest verifies the count-based policy: when more
// than maxFiles log files exist the oldest (by modification time) are removed.
// maxAgeDays=0 disables age-based cleanup so the test exercises count only.
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) }
+68
View File
@@ -0,0 +1,68 @@
package runner
import (
"context"
"os"
"os/exec"
"strings"
"gitea.mixdep.ru/mix/gosentry/src/domain"
)
type commandInvocation struct {
command *exec.Cmd
hideWindow bool
}
func jobInvocation(ctx context.Context, job domain.Job) commandInvocation {
command := strings.TrimSpace(job.Command)
arguments := commandArguments(job.Arguments)
if len(arguments) > 0 || commandPathExists(command) {
return commandInvocation{
command: exec.CommandContext(ctx, unquoteCommandPath(command), arguments...),
hideWindow: false,
}
}
// Shell mode remains for existing jobs and for commands that intentionally
// use builtins, redirection, variables, or chained command syntax.
return commandInvocation{
command: shellCommand(ctx, command),
hideWindow: true,
}
}
func commandArguments(arguments string) []string {
var result []string
for _, line := range strings.FieldsFunc(arguments, func(r rune) bool {
return r == '\n' || r == '\r'
}) {
line = strings.TrimSpace(line)
if line != "" {
result = append(result, line)
}
}
return result
}
func commandPathExists(command string) bool {
command = unquoteCommandPath(strings.TrimSpace(command))
if command == "" {
return false
}
info, err := os.Stat(command)
return err == nil && !info.IsDir()
}
func unquoteCommandPath(command string) string {
return strings.Trim(strings.TrimSpace(command), `"`)
}
func LogArguments(arguments string) string {
if strings.TrimSpace(arguments) == "" {
return "<empty>"
}
return strings.ReplaceAll(strings.TrimSpace(arguments), "\r\n", "\n")
}
func logArguments(arguments string) string { return LogArguments(arguments) }
@@ -1,6 +1,6 @@
//go:build !windows
package core
package runner
import (
"context"
@@ -12,9 +12,3 @@ func shellCommand(ctx context.Context, command string) *exec.Cmd {
// and avoids a hard dependency on a larger shell such as bash.
return exec.CommandContext(ctx, "sh", "-c", command)
}
func configureHiddenWindow(command *exec.Cmd) {
// Non-Windows platforms do not create a new console window for sh -c from a
// desktop process in the same way Windows does, so no extra process attribute
// is required here.
}
@@ -1,4 +1,4 @@
package core
package runner
import (
"context"
@@ -56,14 +56,3 @@ func startsWithWindowsRootedPath(command string) bool {
command[1] == ':' &&
(command[2] == '\\' || command[2] == '/')
}
func configureHiddenWindow(command *exec.Cmd) {
// GoSentry is a GUI scheduler, so child commands should not flash a console
// window on Windows. CREATE_NO_WINDOW keeps cmd.exe and simple console tools
// quiet while stdout/stderr are still captured through pipes.
if command.SysProcAttr == nil {
command.SysProcAttr = &syscall.SysProcAttr{}
}
command.SysProcAttr.CreationFlags |= 0x08000000
command.SysProcAttr.HideWindow = true
}
+55
View File
@@ -0,0 +1,55 @@
package runner
import (
"fmt"
"os"
"path/filepath"
"strings"
"time"
"unicode"
"gitea.mixdep.ru/mix/gosentry/src/domain"
)
func writeRunLog(logsDir string, job domain.Job, trigger string, state string, detail string, output string, started time.Time) string {
if strings.TrimSpace(logsDir) == "" {
return ""
}
if err := os.MkdirAll(logsDir, 0o755); err != nil {
return ""
}
// The timestamp comes first so a plain directory listing is naturally sorted
// by run time. The job name is included for human scanning, but sanitized to
// avoid characters that are invalid on Windows or awkward on shells.
fileName := started.Format("20060102-150405") + "_" + sanitizeFileName(job.Name) + ".log"
path := filepath.Join(logsDir, fileName)
content := fmt.Sprintf("time: %s\njob_id: %d\njob_name: %s\ntrigger: %s\nstate: %s\ndetail: %s\ncommand: %s\narguments: %s\nsuccess_exit_codes: %s\nstart_only: %t\n\n%s\n",
started.Format("2006-01-02 15:04:05"), job.ID, job.Name, trigger, state, detail, job.Command, logArguments(job.Arguments), successExitCodesText(job), job.StartOnly, output)
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
return ""
}
return path
}
func sanitizeFileName(name string) string {
name = strings.TrimSpace(name)
if name == "" {
return "job"
}
var builder strings.Builder
for _, r := range name {
switch {
case unicode.IsLetter(r), unicode.IsDigit(r):
builder.WriteRune(r)
case r == '-', r == '_':
builder.WriteRune(r)
default:
builder.WriteRune('_')
}
}
result := strings.Trim(builder.String(), "_")
if result == "" {
return "job"
}
return result
}
+137
View File
@@ -0,0 +1,137 @@
package runner
import (
"context"
"errors"
"fmt"
"os/exec"
"strings"
"time"
"gitea.mixdep.ru/mix/gosentry/src/domain"
"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) domain.RunRecord {
started := time.Now()
// Commands can hang forever if a script waits for input or a child process
// stalls. A fixed timeout is a conservative first guardrail for a desktop
// scheduler; later it can become a per-job setting without changing the
// runner contract.
runCtx, cancel := context.WithTimeout(ctx, commandTimeout)
defer cancel()
var output string
var state string
var detail string
if job.StartOnly {
invocation := jobInvocation(context.Background(), *job)
state, detail, output = startJobOnly(invocation, *job, started)
} else {
var stdoutBuf strings.Builder
var stderrBuf strings.Builder
invocation := jobInvocation(runCtx, *job)
command := invocation.command
command.WaitDelay = commandWaitDelay
if invocation.hideWindow {
winproc.ConfigureHiddenWindow(command)
}
command.Stdout = &stdoutBuf
command.Stderr = &stderrBuf
err := command.Run()
duration := time.Since(started).Round(time.Millisecond)
output = formatOutput(stdoutBuf.String(), stderrBuf.String())
state, detail = runStateDetail(err, runCtx.Err(), duration, *job)
}
now := time.Now()
timestamp := now.Format("2006-01-02 15:04:05")
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,
}
}
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)
if err != nil {
return "Failed", fmt.Sprintf("%T: %v", err, err), startOnlyOutput(job, 0)
}
pid := command.Process.Pid
if releaseErr := command.Process.Release(); releaseErr != nil {
return "Failed", fmt.Sprintf("process started with pid %d, but release failed: %T: %v", pid, releaseErr, releaseErr), startOnlyOutput(job, pid)
}
return "OK", fmt.Sprintf("Started in %s (pid %d); not waiting for process exit", duration, pid), startOnlyOutput(job, pid)
}
func startOnlyOutput(job domain.Job, pid int) string {
var builder strings.Builder
builder.WriteString("status:\n")
if pid > 0 {
builder.WriteString(fmt.Sprintf("Started process pid %d. GoSentry is not waiting for it to exit.\n\n", pid))
} else {
builder.WriteString("Process did not start.\n\n")
}
builder.WriteString("command:\n")
builder.WriteString(job.Command + "\n\n")
builder.WriteString("arguments:\n")
builder.WriteString(logArguments(job.Arguments))
builder.WriteString("\n\nstart_only:\ntrue")
return builder.String()
}
func runStateDetail(err error, runErr error, duration time.Duration, job 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", commandTimeout)
}
if errors.Is(err, exec.ErrWaitDelay) {
return "OK", fmt.Sprintf("Completed; output capture stopped after %s because a child process kept the stream open", commandWaitDelay)
}
var exitError *exec.ExitError
if errors.As(err, &exitError) {
exitCode := exitError.ExitCode()
if acceptedExitCode(exitCode, job.SuccessExitCodes) {
return "OK", fmt.Sprintf("Completed in %s with accepted exit code %d", duration, exitCode)
}
return "Failed", fmt.Sprintf("Exit code %d is not in success_exit_codes (%s)", exitCode, successExitCodesText(job))
}
return "Failed", fmt.Sprintf("%T: %v", err, err)
}
func formatOutput(stdout string, stderr string) string {
stdout = strings.TrimSpace(stdout)
stderr = strings.TrimSpace(stderr)
if stdout == "" {
// Showing an explicit placeholder is clearer than an empty panel in the
// GUI: the user can tell that the command ran but produced no stream data.
stdout = "<empty>"
}
if stderr == "" {
stderr = "<empty>"
}
return "stdout:\n" + stdout + "\n\nstderr:\n" + stderr
}
@@ -1,4 +1,4 @@
package core
package runner
import (
"context"
@@ -7,11 +7,155 @@ import (
"runtime"
"strings"
"testing"
"time"
"gitea.mixdep.ru/mix/gosentry/src/domain"
"gitea.mixdep.ru/mix/gosentry/src/platform/winproc"
)
func echoCommand(message string) string {
if runtime.GOOS == "windows" {
return "echo " + message
}
return "echo '" + strings.ReplaceAll(message, "'", "'\\''") + "'"
}
func TestRunJobLogFileAllHeaders(t *testing.T) {
logsDir := t.TempDir()
job := domain.Job{
ID: 99,
Name: "Log Header Test",
Command: echoCommand("header test output"),
SuccessExitCodes: "0,1",
}
record := RunJob(context.Background(), &job, "Schedule", logsDir)
if record.LogFile == "" {
t.Fatal("expected log file to be written")
}
data, err := os.ReadFile(record.LogFile)
if err != nil {
t.Fatal(err)
}
content := string(data)
for _, want := range []string{
"job_id: 99",
"job_name: Log Header Test",
"trigger: Schedule",
"state: OK",
"detail: ",
"command: " + job.Command,
"arguments: <empty>",
"success_exit_codes: 0,1",
"start_only: false",
"stdout:",
"stderr:",
} {
if !strings.Contains(content, want) {
t.Errorf("log file missing %q:\n%s", want, content)
}
}
// The time header must use the documented format.
for _, line := range strings.Split(content, "\n") {
if strings.HasPrefix(line, "time: ") {
ts := strings.TrimPrefix(line, "time: ")
if _, err := time.Parse("2006-01-02 15:04:05", ts); err != nil {
t.Errorf("time header %q does not match format 2006-01-02 15:04:05: %v", ts, err)
}
break
}
}
}
func TestRunJobRecordFields(t *testing.T) {
job := domain.Job{
ID: 55,
Name: "Record Fields Test",
Command: echoCommand("record field check"),
}
record := RunJob(context.Background(), &job, "Schedule", t.TempDir())
if record.JobID != job.ID {
t.Errorf("JobID: got %d, want %d", record.JobID, job.ID)
}
if record.JobName != job.Name {
t.Errorf("JobName: got %q, want %q", record.JobName, job.Name)
}
if record.Trigger != "Schedule" {
t.Errorf("Trigger: got %q, want 'Schedule'", record.Trigger)
}
if record.State != "OK" {
t.Errorf("State: got %q, want 'OK' (detail: %q)", record.State, record.Detail)
}
if record.LogFile == "" {
t.Error("LogFile should be a non-empty path")
}
if _, err := time.Parse("2006-01-02 15:04:05", record.Time); err != nil {
t.Errorf("Time format wrong, got %q: %v", record.Time, err)
}
if !strings.Contains(record.Output, "stdout:") {
t.Errorf("Output missing 'stdout:', got:\n%s", record.Output)
}
if !strings.Contains(record.Output, "stderr:") {
t.Errorf("Output missing 'stderr:', got:\n%s", record.Output)
}
}
func TestFormatOutput(t *testing.T) {
got := formatOutput("hello world", "some error")
want := "stdout:\nhello world\n\nstderr:\nsome error"
if got != want {
t.Errorf("formatOutput:\ngot: %q\nwant: %q", got, want)
}
}
func TestFormatOutputEmptyStreams(t *testing.T) {
got := formatOutput("", "")
if !strings.Contains(got, "stdout:\n<empty>") {
t.Errorf("empty stdout should show <empty>, got:\n%s", got)
}
if !strings.Contains(got, "stderr:\n<empty>") {
t.Errorf("empty stderr should show <empty>, got:\n%s", got)
}
}
func TestLogArguments(t *testing.T) {
cases := []struct{ input, want string }{
{"", "<empty>"},
{" ", "<empty>"},
{"--flag", "--flag"},
{"--flag\r\n--value", "--flag\n--value"},
{"--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)
}
}
}
func TestSanitizeFileName(t *testing.T) {
cases := []struct{ input, want string }{
{"Hello Test", "Hello_Test"},
{"job-1_ok", "job-1_ok"},
{"!!!", "job"},
{"", "job"},
{"A/B:C", "A_B_C"},
}
for _, tc := range cases {
if got := sanitizeFileName(tc.input); got != tc.want {
t.Errorf("sanitizeFileName(%q) = %q, want %q", tc.input, got, tc.want)
}
}
}
func TestRunJobWritesLogFile(t *testing.T) {
logsDir := t.TempDir()
job := Job{
job := domain.Job{
ID: 42,
Name: "Hello Test",
Command: echoCommand("hello from test"),
@@ -46,7 +190,7 @@ func TestRunJobRunsQuotedWindowsExecutable(t *testing.T) {
}
logsDir := t.TempDir()
job := Job{
job := domain.Job{
ID: 43,
Name: "Quoted Windows Command",
Command: `"C:\Windows\System32\cmd.exe" /C echo quoted command ok`,
@@ -75,7 +219,7 @@ func TestRunJobRunsUnquotedWindowsProgramPathWithSpaces(t *testing.T) {
if err := os.WriteFile(scriptPath, []byte("@echo off\r\necho unquoted command ok\r\n"), 0o755); err != nil {
t.Fatal(err)
}
job := Job{
job := domain.Job{
ID: 44,
Name: "Unquoted Windows Command",
Command: scriptPath,
@@ -96,7 +240,7 @@ func TestRunJobRunsWindowsCommandWithSeparateArguments(t *testing.T) {
}
logsDir := t.TempDir()
job := Job{
job := domain.Job{
ID: 45,
Name: "Separate Arguments",
Command: `C:\Windows\System32\cmd.exe`,
@@ -117,7 +261,7 @@ func TestRunJobAcceptsConfiguredExitCode(t *testing.T) {
if runtime.GOOS == "windows" {
command = `C:\Windows\System32\cmd.exe`
}
job := Job{
job := domain.Job{
ID: 46,
Name: "Accepted Exit Code",
Command: command,
@@ -141,7 +285,7 @@ func TestRunJobRejectsUnconfiguredExitCode(t *testing.T) {
if runtime.GOOS == "windows" {
command = `C:\Windows\System32\cmd.exe`
}
job := Job{
job := domain.Job{
ID: 47,
Name: "Rejected Exit Code",
Command: command,
@@ -167,7 +311,7 @@ func TestRunJobStartOnlyDoesNotWaitForExitCode(t *testing.T) {
command = `C:\Windows\System32\cmd.exe`
arguments = "/C\nexit /b 7"
}
job := Job{
job := domain.Job{
ID: 48,
Name: "Start Only",
Command: command,
@@ -188,7 +332,7 @@ func TestRunJobStartOnlyDoesNotWaitForExitCode(t *testing.T) {
}
func TestRunJobStartOnlyReportsStartFailure(t *testing.T) {
job := Job{
job := domain.Job{
ID: 49,
Name: "Missing Start Only",
Command: "definitely-missing-gosentry-command",
@@ -223,7 +367,7 @@ func TestDirectCommandDoesNotHideWindow(t *testing.T) {
t.Skip("Windows window visibility only")
}
invocation := jobInvocation(context.Background(), Job{
invocation := jobInvocation(context.Background(), domain.Job{
Command: `C:\Windows\System32\cmd.exe`,
Arguments: "/C\necho visible direct process",
})
@@ -237,11 +381,11 @@ func TestShellCommandHidesWindow(t *testing.T) {
t.Skip("Windows window visibility only")
}
invocation := jobInvocation(context.Background(), Job{Command: "echo hidden shell process"})
invocation := jobInvocation(context.Background(), domain.Job{Command: "echo hidden shell process"})
if !invocation.hideWindow {
t.Fatal("shell command should request hidden startup window")
}
configureHiddenWindow(invocation.command)
winproc.ConfigureHiddenWindow(invocation.command)
if invocation.command.SysProcAttr == nil || !invocation.command.SysProcAttr.HideWindow {
t.Fatal("expected shell command to be hidden")
}
@@ -253,7 +397,7 @@ func TestShellCommandUsesWindowsSafeQuoting(t *testing.T) {
}
command := shellCommand(context.Background(), `"C:\Program Files\FreeFileSync\FreeFileSync.exe" "D:\Local\Programs\FreeFileSync\Jobs\Auto.ffs_batch"`)
configureHiddenWindow(command)
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 {
+55
View File
@@ -0,0 +1,55 @@
package scheduler
import "time"
// Clock supplies the scheduler with the current time and a stream of ticks.
// Hiding both behind an interface lets tests drive the loop deterministically —
// firing ticks and controlling "now" — instead of waiting on the wall clock.
// Production uses RealClock.
type Clock interface {
// Now returns the current time. It is the value passed to the tick callback
// on each tick, so a fake can make due-evaluation deterministic.
Now() time.Time
// Ticks returns a channel that delivers a value on every scheduler tick. The
// scheduler reads it for the lifetime of the loop.
Ticks() <-chan time.Time
// Stop releases the resources backing Ticks. The scheduler calls it once when
// the loop exits.
Stop()
}
// RealClock is the production Clock: wall-clock time and a one-second ticker.
//
// A one-second cadence is accurate enough for cron-style desktop automation —
// five-field cron expressions have minute precision, while @every values may be
// shorter for testing and lightweight local tasks — and it keeps a single timer
// instead of one per job.
type RealClock struct {
ticker *time.Ticker
}
// NewRealClock returns a real clock. The underlying ticker is created lazily on
// the first Ticks call so a clock that is never started leaks nothing.
func NewRealClock() *RealClock {
return &RealClock{}
}
// Now returns the wall-clock time.
func (c *RealClock) Now() time.Time {
return time.Now()
}
// Ticks starts (once) and returns the one-second ticker channel.
func (c *RealClock) Ticks() <-chan time.Time {
if c.ticker == nil {
c.ticker = time.NewTicker(time.Second)
}
return c.ticker.C
}
// Stop halts the ticker if it was ever started.
func (c *RealClock) Stop() {
if c.ticker != nil {
c.ticker.Stop()
}
}
+56
View File
@@ -0,0 +1,56 @@
package scheduler
import (
"context"
"time"
)
// Scheduler is a thin timing loop. It owns no job or runtime state: on every
// clock tick it calls the injected tick function with the current time, and that
// function — the application service's RunDue — decides what, if anything, to
// run. Keeping all state and mutation in the service makes the service the sole
// writer (resolving the old shared-*[]Job data race) and reduces the scheduler
// to a loop that is trivially testable with a fake Clock.
type Scheduler struct {
clock Clock
tick func(now time.Time)
ctx context.Context
cancel context.CancelFunc
}
// NewScheduler builds a scheduler that calls tick on every Clock tick. The clock
// is injected so tests can drive the loop without the wall clock.
func NewScheduler(clock Clock, tick func(now time.Time)) *Scheduler {
ctx, cancel := context.WithCancel(context.Background())
return &Scheduler{
clock: clock,
tick: tick,
ctx: ctx,
cancel: cancel,
}
}
// Start launches the loop on its own goroutine and returns immediately.
func (s *Scheduler) Start() {
go func() {
ticks := s.clock.Ticks()
defer s.clock.Stop()
for {
select {
case <-s.ctx.Done():
return
case <-ticks:
// Pass the clock's notion of "now" rather than the tick value so a
// fake clock can control due-evaluation precisely.
s.tick(s.clock.Now())
}
}
}()
}
// Stop ends the loop. A tick already in progress finishes; no further ticks are
// delivered.
func (s *Scheduler) Stop() {
s.cancel()
}
+85
View File
@@ -0,0 +1,85 @@
package scheduler
import (
"sync"
"testing"
"time"
)
// fakeClock is a Clock whose ticks and "now" are driven by the test instead of
// the wall clock, so the scheduler loop can be exercised deterministically.
type fakeClock struct {
ticks chan time.Time
mu sync.Mutex
now time.Time
stopped bool
}
func newFakeClock(now time.Time) *fakeClock {
return &fakeClock{ticks: make(chan time.Time, 1), now: now}
}
func (c *fakeClock) Now() time.Time {
c.mu.Lock()
defer c.mu.Unlock()
return c.now
}
func (c *fakeClock) Ticks() <-chan time.Time { return c.ticks }
func (c *fakeClock) Stop() {
c.mu.Lock()
c.stopped = true
c.mu.Unlock()
}
func (c *fakeClock) isStopped() bool {
c.mu.Lock()
defer c.mu.Unlock()
return c.stopped
}
// fire advances the clock to t and delivers one tick.
func (c *fakeClock) fire(t time.Time) {
c.mu.Lock()
c.now = t
c.mu.Unlock()
c.ticks <- t
}
func TestSchedulerCallsTickWithClockNow(t *testing.T) {
clock := newFakeClock(time.Unix(0, 0))
got := make(chan time.Time, 1)
s := NewScheduler(clock, func(now time.Time) { got <- now })
s.Start()
defer s.Stop()
want := time.Date(2026, 6, 19, 12, 0, 0, 0, time.UTC)
clock.fire(want)
select {
case now := <-got:
if !now.Equal(want) {
t.Errorf("tick now = %v, want %v", now, want)
}
case <-time.After(time.Second):
t.Fatal("scheduler did not call tick after a clock tick")
}
}
func TestSchedulerStopReleasesClock(t *testing.T) {
clock := newFakeClock(time.Now())
s := NewScheduler(clock, func(time.Time) {})
s.Start()
s.Stop()
// After Stop the loop exits and releases the clock via the deferred Stop.
deadline := time.Now().Add(time.Second)
for !clock.isStopped() {
if time.Now().After(deadline) {
t.Fatal("clock was not stopped after scheduler Stop")
}
time.Sleep(time.Millisecond)
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
package core
package storage
import (
"os"
+19 -32
View File
@@ -1,4 +1,4 @@
package core
package storage
import (
"errors"
@@ -7,15 +7,16 @@ import (
"runtime"
"strings"
"gitea.mixdep.ru/mix/gosentry/src/domain"
"go.yaml.in/yaml/v4"
)
type Store struct {
Paths Paths
Config Config
Config domain.Config
}
func OpenStore() (*Store, []Job, error) {
func OpenStore() (*Store, []domain.Job, error) {
paths, err := ResolvePaths()
if err != nil {
return nil, nil, err
@@ -57,17 +58,17 @@ func (s *Store) SaveConfig() error {
return writeYAML(s.Paths.ConfigPath, s.Config)
}
func (s *Store) SaveJobs(jobs []Job) error {
func (s *Store) SaveJobs(jobs []domain.Job) error {
if err := os.MkdirAll(s.Paths.JobsDir, 0o755); err != nil {
return err
}
return writeYAML(s.Paths.JobsPath, JobsFile{Jobs: jobs})
return writeYAML(s.Paths.JobsPath, domain.JobsFile{Jobs: jobs})
}
func loadOrCreateConfig(paths Paths) (Config, error) {
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 := Config{
config := domain.Config{
JobsDir: ".",
LogsDir: "logs",
MaxLogFiles: 100,
@@ -98,10 +99,10 @@ func loadOrCreateConfig(paths Paths) (Config, error) {
data, err := os.ReadFile(configPath)
if err != nil {
return Config{}, err
return domain.Config{}, err
}
if err := yaml.Unmarshal(data, &config); err != nil {
return Config{}, err
return domain.Config{}, err
}
if strings.TrimSpace(config.JobsDir) == "" {
// Empty paths are treated as missing values rather than intentional root
@@ -120,27 +121,27 @@ func loadOrCreateConfig(paths Paths) (Config, error) {
return config, nil
}
func loadOrCreateJobs(path string) ([]Job, error) {
func loadOrCreateJobs(path string) ([]domain.Job, error) {
if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
// The first run creates harmless sample jobs so a new user can immediately
// see scheduled and manual execution without inventing a command.
jobs := defaultJobs()
normalizeJobs(jobs)
return jobs, writeYAML(path, JobsFile{Jobs: jobs})
return jobs, writeYAML(path, domain.JobsFile{Jobs: jobs})
}
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var file JobsFile
var file domain.JobsFile
if err := yaml.Unmarshal(data, &file); err != nil {
return nil, err
}
return file.Jobs, nil
}
func normalizeJobs(jobs []Job) {
func normalizeJobs(jobs []domain.Job) {
next := 1
for index := range jobs {
job := &jobs[index]
@@ -168,23 +169,9 @@ func normalizeJobs(jobs []Job) {
if job.SuccessExitCodes == "" {
job.SuccessExitCodes = "0"
}
if job.LastRun == "" {
job.LastRun = "Never"
}
if job.Output == "" {
job.Output = "No command output captured yet."
}
if job.Enabled {
job.LastState = "Ready"
job.NextRun = "After start"
} else {
job.LastState = "Paused"
job.NextRun = "Paused"
}
// Runtime fields are reconstructed each time the app starts. Persisted run
// records live in log files, not in jobs.yaml, to keep the jobs file easy
// to review and edit by hand.
job.Logs = nil
// 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.
}
}
@@ -222,8 +209,8 @@ func writeYAML(path string, value any) error {
return os.WriteFile(path, data, 0o644)
}
func defaultJobs() []Job {
return []Job{
func defaultJobs() []domain.Job {
return []domain.Job{
{
ID: 1,
Name: "Hello scheduler",
+256
View File
@@ -0,0 +1,256 @@
package storage
import (
"os"
"path/filepath"
"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.yaml")
original := []domain.Job{
{
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 := writeYAML(path, domain.JobsFile{Jobs: original}); err != nil {
t.Fatal(err)
}
got, err := loadOrCreateJobs(path)
if err != nil {
t.Fatal(err)
}
if len(got) != 1 {
t.Fatalf("expected 1 job, got %d", len(got))
}
g, w := got[0], original[0]
if g.ID != w.ID {
t.Errorf("ID: got %d, want %d", g.ID, w.ID)
}
if g.Name != w.Name {
t.Errorf("Name: got %q, want %q", g.Name, w.Name)
}
if g.Folder != w.Folder {
t.Errorf("Folder: got %q, want %q", g.Folder, w.Folder)
}
if g.Schedule != w.Schedule {
t.Errorf("Schedule: got %q, want %q", g.Schedule, w.Schedule)
}
if g.Command != w.Command {
t.Errorf("Command: got %q, want %q", g.Command, w.Command)
}
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)
}
if g.Enabled != w.Enabled {
t.Errorf("Enabled: got %v, want %v", g.Enabled, w.Enabled)
}
// Runtime state no longer lives on Job at all (it moved to domain.JobRuntime),
// so there is nothing transient that could survive the save→load round-trip.
}
func TestConfigRoundTrip(t *testing.T) {
dir := t.TempDir()
paths := Paths{
AppDir: dir,
ConfigPath: filepath.Join(dir, ConfigFileName),
}
want := domain.Config{
JobsDir: "/custom/jobs",
LogsDir: "/custom/logs",
MaxLogFiles: 50,
MaxLogAgeDays: 14,
StartOnLogin: true,
KeepRunningInTray: false,
NotifyOnFailure: false,
}
if err := writeYAML(paths.ConfigPath, want); err != nil {
t.Fatal(err)
}
got, err := loadOrCreateConfig(paths)
if err != nil {
t.Fatal(err)
}
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)
}
if got.MaxLogFiles != want.MaxLogFiles {
t.Errorf("MaxLogFiles: got %d, want %d", got.MaxLogFiles, want.MaxLogFiles)
}
if got.MaxLogAgeDays != want.MaxLogAgeDays {
t.Errorf("MaxLogAgeDays: got %d, want %d", got.MaxLogAgeDays, want.MaxLogAgeDays)
}
if got.StartOnLogin != want.StartOnLogin {
t.Errorf("StartOnLogin: got %v, want %v", got.StartOnLogin, want.StartOnLogin)
}
if got.KeepRunningInTray != want.KeepRunningInTray {
t.Errorf("KeepRunningInTray: got %v, want %v", got.KeepRunningInTray, want.KeepRunningInTray)
}
if got.NotifyOnFailure != want.NotifyOnFailure {
t.Errorf("NotifyOnFailure: got %v, want %v", got.NotifyOnFailure, want.NotifyOnFailure)
}
}
func TestNormalizeJobsFillsDefaults(t *testing.T) {
jobs := []domain.Job{
{Enabled: true},
{Enabled: false},
{ID: 5, Name: "Kept", Schedule: "*/10 * * * *", SuccessExitCodes: "0,1", Enabled: true},
}
normalizeJobs(jobs)
// 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 {
t.Errorf("first auto ID: got %d, want 1", jobs[0].ID)
}
if jobs[0].Name != "Untitled job" {
t.Errorf("default name: got %q, want 'Untitled job'", jobs[0].Name)
}
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)
}
if jobs[2].SuccessExitCodes != "0,1" {
t.Errorf("pre-set exit codes should be preserved: got %q, want '0,1'", jobs[2].SuccessExitCodes)
}
}
// TestLoadOrCreateConfigMigratesFromLegacy verifies that when gosentry.yaml is
// absent but pysentry.yaml exists the config is read from the legacy file. This
// lets portable installs that still carry a pysentry.yaml start without manual
// migration.
func TestLoadOrCreateConfigMigratesFromLegacy(t *testing.T) {
dir := t.TempDir()
paths := Paths{
AppDir: dir,
ConfigPath: filepath.Join(dir, ConfigFileName), // gosentry.yaml — not created
}
legacy := domain.Config{
JobsDir: "/legacy/jobs",
LogsDir: "/legacy/logs",
MaxLogFiles: 77,
MaxLogAgeDays: 13,
StartOnLogin: true,
}
if err := writeYAML(filepath.Join(dir, LegacyConfigFileName), legacy); err != nil {
t.Fatal(err)
}
got, err := loadOrCreateConfig(paths)
if err != nil {
t.Fatal(err)
}
if got.JobsDir != legacy.JobsDir {
t.Errorf("JobsDir: got %q, want %q", got.JobsDir, legacy.JobsDir)
}
if got.LogsDir != legacy.LogsDir {
t.Errorf("LogsDir: got %q, want %q", got.LogsDir, legacy.LogsDir)
}
if got.MaxLogFiles != legacy.MaxLogFiles {
t.Errorf("MaxLogFiles: got %d, want %d", got.MaxLogFiles, legacy.MaxLogFiles)
}
if got.MaxLogAgeDays != legacy.MaxLogAgeDays {
t.Errorf("MaxLogAgeDays: got %d, want %d", got.MaxLogAgeDays, legacy.MaxLogAgeDays)
}
if got.StartOnLogin != legacy.StartOnLogin {
t.Errorf("StartOnLogin: got %v, want %v", got.StartOnLogin, legacy.StartOnLogin)
}
}
// TestLoadOrCreateConfigCreatesDefaultsOnFirstRun verifies that the first run
// (no config files present) writes gosentry.yaml and returns sensible defaults.
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.JobsDir != "." {
t.Errorf("default JobsDir = %q, want '.'", got.JobsDir)
}
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)
}
// The function must have written the defaults to gosentry.yaml.
if _, err := os.Stat(paths.ConfigPath); err != nil {
t.Errorf("gosentry.yaml should have been created: %v", err)
}
}
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 yaml tag.
jobs := []domain.Job{
{
ID: 1,
Name: "Clean job",
Schedule: "@every 10s",
Command: echoCommand("ok"),
Enabled: true,
},
}
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 yaml should not contain %q:\n%s", unwanted, text)
}
}
}
+151
View File
@@ -0,0 +1,151 @@
package ui
import (
"sort"
"strings"
"time"
"gitea.mixdep.ru/mix/gosentry/src/domain"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/widget"
)
func newEvent(jobID int, jobName string, state string, detail string) event {
// Use the same timestamp shape as command run records so the History tab is
// visually consistent across startup, UI actions, manual runs, and schedules.
return event{
Time: time.Now().Format("2006-01-02 15:04:05"),
JobID: jobID,
JobName: jobName,
Trigger: "UI",
State: state,
Detail: detail,
}
}
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...)
}
}
sort.SliceStable(events, func(left int, right int) bool {
return events[left].Time < events[right].Time
})
return events
}
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 desc"
}
return "Time asc"
}
if id.Row < 0 && id.Col >= 0 && id.Col < len(headers) {
return headers[id.Col]
}
return ""
}
sortedEvents := func() []event {
result := append([]event(nil), (*events)...)
sort.SliceStable(result, func(left int, right int) bool {
if descending {
return result[left].Time > result[right].Time
}
return result[left].Time < result[right].Time
})
return result
}
table := widget.NewTable(
func() (int, int) {
return len(*events), 6
},
func() fyne.CanvasObject {
label := widget.NewLabel("")
label.Wrapping = fyne.TextTruncate
return label
},
func(id widget.TableCellID, item fyne.CanvasObject) {
label := item.(*widget.Label)
label.SetText(historyCellText(id, sortedEvents()))
label.TextStyle = fyne.TextStyle{}
label.Refresh()
},
)
table.ShowHeaderRow = true
table.CreateHeader = func() fyne.CanvasObject {
label := widget.NewLabel("")
label.Wrapping = fyne.TextTruncate
return label
}
table.UpdateHeader = func(id widget.TableCellID, item fyne.CanvasObject) {
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)
}
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 {
if id.Row < 0 || id.Row >= len(events) {
return ""
}
current := events[id.Row]
trigger := current.Trigger
if trigger == "" {
trigger = "Unknown"
}
switch id.Col {
case 0:
return current.Time
case 1:
return trigger
case 2:
return current.JobName
case 3:
return current.State
case 4:
return current.Detail
case 5:
return logFileName(current.LogFile)
default:
return ""
}
}
func logFileName(path string) string {
path = strings.TrimSpace(path)
if path == "" {
return ""
}
path = strings.ReplaceAll(path, "\\", "/")
if slash := strings.LastIndex(path, "/"); slash >= 0 {
return path[slash+1:]
}
return path
}
+90
View File
@@ -0,0 +1,90 @@
package ui
import (
"fmt"
"strings"
"gitea.mixdep.ru/mix/gosentry/src/app"
"gitea.mixdep.ru/mix/gosentry/src/domain"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/dialog"
"fyne.io/fyne/v2/widget"
)
// 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.
func showJobDialog(w fyne.Window, title string, current job, onSave func(job)) {
name := widget.NewEntry()
name.SetPlaceHolder("Nightly backup")
name.SetText(current.Name)
folderEntry := widget.NewEntry()
folderEntry.SetPlaceHolder("Maintenance")
folderEntry.SetText(current.Folder)
scheduleEntry := widget.NewEntry()
scheduleEntry.SetPlaceHolder("@every 1m")
scheduleEntry.SetText(current.Schedule)
commandEntry := widget.NewEntry()
commandEntry.SetPlaceHolder(`C:\Program Files\App\App.exe`)
commandEntry.SetText(current.Command)
argumentsEntry := widget.NewMultiLineEntry()
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)
form := dialog.NewForm(
title,
"Save",
"Cancel",
[]*widget.FormItem{
widget.NewFormItem("Name", name),
widget.NewFormItem("Folder", folderEntry),
widget.NewFormItem("Schedule", scheduleEntry),
widget.NewFormItem("Command", commandEntry),
widget.NewFormItem("Arguments", argumentsEntry),
widget.NewFormItem("Success exit codes", successExitCodesEntry),
widget.NewFormItem("", startOnly),
widget.NewFormItem("", enabled),
},
func(saved bool) {
if !saved {
return
}
if strings.TrimSpace(name.Text) == "" || strings.TrimSpace(scheduleEntry.Text) == "" || strings.TrimSpace(commandEntry.Text) == "" {
// These three fields are the minimum executable job definition.
// Folder is optional because ungrouped jobs are a supported workflow.
dialog.ShowError(fmt.Errorf("name, schedule, and command are required"), w)
return
}
if err := domain.Validate(strings.TrimSpace(scheduleEntry.Text)); err != nil {
dialog.ShowError(fmt.Errorf("invalid schedule: %w", err), w)
return
}
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
// 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.
onSave(current)
},
w,
)
form.Resize(fyne.NewSize(640, 460))
form.Show()
}
+415
View File
@@ -0,0 +1,415 @@
package ui
import (
"fmt"
"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/layout"
"fyne.io/fyne/v2/theme"
"fyne.io/fyne/v2/widget"
)
const allFolders = "All"
const noFolder = "No folder"
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
// whenever the service state may have changed (e.g., from the event subscriber
// in mainwindow.go). The refresh function re-reads the service snapshot and
// redraws all widgets in the jobs view; it does NOT touch history or settings.
func newJobsView(w fyne.Window, svc *app.Service) (fyne.CanvasObject, func()) {
jobs := svc.Jobs()
runtimes := make(map[int]*domain.JobRuntime, len(jobs))
syncFromService := func() {
jobs = svc.Jobs()
for id := range runtimes {
delete(runtimes, id)
}
for _, current := range jobs {
if rt := svc.Runtime(current.ID); rt != nil {
runtimes[current.ID] = rt
}
}
}
syncFromService()
runtimeFor := func(index int) *domain.JobRuntime {
if index < 0 || index >= len(jobs) {
return &domain.JobRuntime{}
}
if rt := runtimes[jobs[index].ID]; rt != nil {
return rt
}
return &domain.JobRuntime{}
}
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...)
}
// list and folderSelect are declared early so closures below can reference
// them before the widget.NewList / widget.NewSelect calls assign the values.
var list *widget.List
var folderSelect *widget.Select
refreshView := func() {
syncFromService()
filteredJobs = filteredJobIndexes(jobs, selectedFolder)
updateDetails(selected)
jobLogs.Refresh()
if list != nil {
list.Refresh()
}
}
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)
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
}
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
}
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()
}
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
}
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)
}
}
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
}
+95
View File
@@ -0,0 +1,95 @@
package ui
import (
"testing"
"gitea.mixdep.ru/mix/gosentry/src/domain"
)
func TestFilterValue(t *testing.T) {
cases := []struct{ input, want string }{
{"", noFolder},
{" ", noFolder},
{"Maintenance", "Maintenance"},
{" Reports ", "Reports"},
}
for _, tc := range cases {
if got := filterValue(tc.input); got != tc.want {
t.Errorf("filterValue(%q) = %q, want %q", tc.input, got, tc.want)
}
}
}
func TestFolderOptionsAlwaysIncludesSentinels(t *testing.T) {
opts := folderOptions(nil)
if len(opts) < 2 || opts[0] != allFolders || opts[1] != noFolder {
t.Errorf("folderOptions(nil) = %v, want [%q %q ...]", opts, allFolders, noFolder)
}
}
func TestFolderOptionsAppendsUniqueFolders(t *testing.T) {
jobs := []domain.Job{
{Folder: "Maintenance"},
{Folder: ""}, // no folder → not a named folder
{Folder: " Backups "}, // trimmed to "Backups"
{Folder: "Maintenance"}, // duplicate → not added again
}
opts := folderOptions(jobs)
// Expected: All, No folder, Maintenance, Backups — 4 entries, no duplicates.
if len(opts) != 4 {
t.Errorf("expected 4 options, got %v", opts)
}
has := map[string]bool{}
for _, o := range opts {
has[o] = true
}
for _, want := range []string{allFolders, noFolder, "Maintenance", "Backups"} {
if !has[want] {
t.Errorf("expected option %q in %v", want, opts)
}
}
}
func TestFilteredJobIndexesAll(t *testing.T) {
jobs := []domain.Job{
{Folder: "Maintenance"},
{Folder: ""},
{Folder: "Reports"},
}
got := filteredJobIndexes(jobs, allFolders)
if len(got) != 3 {
t.Errorf("allFolders filter: got %d indexes, want 3", len(got))
}
}
func TestFilteredJobIndexesByNamedFolder(t *testing.T) {
jobs := []domain.Job{
{Folder: "Maintenance"}, // index 0
{Folder: ""}, // index 1
{Folder: "Maintenance"}, // index 2
{Folder: "Reports"}, // index 3
}
got := filteredJobIndexes(jobs, "Maintenance")
if len(got) != 2 || got[0] != 0 || got[1] != 2 {
t.Errorf("Maintenance filter: got %v, want [0 2]", got)
}
}
func TestFilteredJobIndexesNoFolder(t *testing.T) {
jobs := []domain.Job{
{Folder: "Maintenance"}, // index 0 — excluded
{Folder: ""}, // index 1 — no folder → included
{Folder: " "}, // index 2 — blank → included
}
got := filteredJobIndexes(jobs, noFolder)
if len(got) != 2 || got[0] != 1 || got[1] != 2 {
t.Errorf("noFolder filter: got %v, want [1 2]", got)
}
}
func TestFilteredJobIndexesEmptySlice(t *testing.T) {
got := filteredJobIndexes(nil, allFolders)
if len(got) != 0 {
t.Errorf("empty job list should return empty indexes, got %v", got)
}
}
+37
View File
@@ -0,0 +1,37 @@
package ui
import (
"fyne.io/fyne/v2"
)
type minWidthLayout struct {
width float32
}
func (l minWidthLayout) MinSize(objects []fyne.CanvasObject) fyne.Size {
width := l.width
var height float32
for _, object := range objects {
if !object.Visible() {
continue
}
min := object.MinSize()
if min.Width > width {
width = min.Width
}
if min.Height > height {
height = min.Height
}
}
return fyne.NewSize(width, height)
}
func (l minWidthLayout) Layout(objects []fyne.CanvasObject, size fyne.Size) {
for _, object := range objects {
if !object.Visible() {
continue
}
object.Move(fyne.NewPos(0, 0))
object.Resize(size)
}
}
+92
View File
@@ -0,0 +1,92 @@
package ui
import (
"time"
"gitea.mixdep.ru/mix/gosentry/assets"
"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/theme"
"fyne.io/fyne/v2/widget"
)
// The UI package aliases domain types to keep widget callbacks short. The actual
// durable model still lives in src/domain, so UI code does not define a second
// copy of the scheduler data.
type job = domain.Job
type event = domain.RunRecord
func newMainView(w fyne.Window) (fyne.CanvasObject, func(time.Duration, bool)) {
svc, err := app.Open()
if err != nil {
return container.NewPadded(widget.NewLabel("Failed to load GoSentry configuration: " + err.Error())), func(time.Duration, bool) {}
}
svc.InstallDesktopIcon(appID, assets.IconBytes())
// Build the initial event history from the current runtime state. Jobs and
// runtimes are read here only for this one-time initialization; the jobs view
// owns all subsequent state via its own syncFromService closure.
initialJobs := svc.Jobs()
initialRuntimes := make(map[int]*domain.JobRuntime, len(initialJobs))
for _, j := range initialJobs {
if rt := svc.Runtime(j.ID); rt != nil {
initialRuntimes[j.ID] = rt
}
}
events := collectActivity(initialJobs, initialRuntimes)
jobsPanel, refreshJobsView := newJobsView(w, svc)
history := newHistoryView(&events)
recordStartup := func(duration time.Duration, windowShown bool) {
// Startup is recorded as an in-memory History event instead of being
// persisted into jobs.yaml. It is session diagnostics, not durable job
// state, and keeping it ephemeral avoids polluting the human-editable YAML
// file with process-lifetime bookkeeping.
detail := "Window shown in " + duration.Round(time.Millisecond).String()
if !windowShown {
detail = "Started in tray in " + duration.Round(time.Millisecond).String()
}
events = append(events, newEvent(0, "Application", "Started", detail))
history.Refresh()
}
refresh := func() {
refreshJobsView()
history.Refresh()
}
// The Service announces every change through events. This single listener is
// where the UI reacts: it appends run/activity records to History and redraws.
// Events fire from two contexts — UI button handlers call into the Service
// synchronously (main goroutine), while scheduled and manual run completions
// emit from the run goroutine. fyne.Do marshals all of this widget work onto
// the main thread in both cases, so the engine never mutates Fyne state off
// the UI thread. This is the sole place events touch widgets. (Resolves #4.)
svc.Subscribe(app.ObserverFunc(func(ev app.Event) {
recorded, isRecorded := ev.(app.RunRecorded)
errOccurred, isError := ev.(app.ErrorOccurred)
fyne.Do(func() {
if isRecorded {
events = append(events, recorded.Record)
}
if isError {
events = append(events, newEvent(0, "Service", "Error", errOccurred.Err.Error()))
}
refresh()
})
}))
svc.Start()
tabs := container.NewAppTabs(
container.NewTabItemWithIcon("Jobs", theme.ListIcon(), jobsPanel),
container.NewTabItemWithIcon("History", theme.HistoryIcon(), history),
container.NewTabItemWithIcon("Settings", theme.SettingsIcon(), settingsView(w, svc)),
)
tabs.SetTabLocation(container.TabLocationTop)
return tabs, recordStartup
}
+60
View File
@@ -0,0 +1,60 @@
package ui
import (
"time"
"gitea.mixdep.ru/mix/gosentry/assets"
"gitea.mixdep.ru/mix/gosentry/src/app"
"fyne.io/fyne/v2"
fyneapp "fyne.io/fyne/v2/app"
)
const appID = "ru.mixdep.gosentry.desktop"
// Run is the application entry point. It owns the process lifecycle — single
// instance arbitration, Fyne app + window construction, tray wiring, and the
// startup-timing record — and delegates all view construction to newMainView in
// mainwindow.go. Keeping lifecycle here and the view there is the run.go /
// mainwindow.go split introduced in T4.1.
func Run(startInTray bool) {
started := time.Now()
instanceListener, primary := acquireSingleInstance(!startInTray)
if !primary {
return
}
if instanceListener != nil {
defer instanceListener.Close()
}
// A stable app ID lets Fyne persist desktop preferences consistently across
// launches and gives tray/window integration a predictable identity.
a := fyneapp.NewWithID(appID)
a.SetIcon(loadAppIcon())
w := a.NewWindow("GoSentry " + app.Version)
configureSystemTray(a, w)
w.Resize(fyne.NewSize(1120, 720))
content, recordStartup := newMainView(w)
w.SetContent(content)
serveSingleInstance(instanceListener, w)
if startInTray {
// Autostart launches intentionally stay hidden, so "window shown" would be
// a misleading metric. Record a separate startup event for the tray path
// instead of forcing one timing definition onto two different UX flows.
recordStartup(time.Since(started), false)
a.Run()
return
}
// Show the window before recording startup time. Measuring earlier, during
// widget construction, looked cheaper in History than the user-perceived
// startup really was. The current point is less abstract: it ends when the
// window has actually been handed to the desktop for display.
w.Show()
recordStartup(time.Since(started), true)
a.Run()
}
func loadAppIcon() fyne.Resource {
return assets.Icon()
}
+182
View File
@@ -0,0 +1,182 @@
package ui
import (
"net/url"
"runtime"
"runtime/debug"
"strconv"
"strings"
"gitea.mixdep.ru/mix/gosentry/src/app"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/dialog"
"fyne.io/fyne/v2/theme"
"fyne.io/fyne/v2/widget"
)
const settingsLabelWidth float32 = 140
const settingsControlWidth float32 = 330
const settingsStatusWidth float32 = 280
const projectRepositoryURL = "https://gitea.mixdep.ru/mix/gosentry"
func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
store := svc.Store()
startOnLogin := widget.NewCheck("Start on login", nil)
startOnLogin.SetChecked(store.Config.StartOnLogin)
autostartStatus := widget.NewLabel("")
refreshAutostartStatus := func() {
ok, message := svc.AutostartStatus()
if ok {
autostartStatus.SetText("OK: " + message)
return
}
autostartStatus.SetText("Problem: " + message)
}
startOnLogin.OnChanged = func(bool) {
if startOnLogin.Checked != store.Config.StartOnLogin {
autostartStatus.SetText("Pending: save settings to apply")
return
}
refreshAutostartStatus()
}
refreshAutostartStatus()
minimizeToTray := widget.NewCheck("Keep running in the system tray", nil)
minimizeToTray.SetChecked(store.Config.KeepRunningInTray)
notifications := widget.NewCheck("Show desktop notifications for failed jobs", nil)
notifications.SetChecked(store.Config.NotifyOnFailure)
jobsDir := widget.NewEntry()
jobsDir.SetText(store.Config.JobsDir)
jobsDirBrowse := widget.NewButtonWithIcon("Browse", theme.FolderOpenIcon(), func() {
chooseFolder(w, jobsDir)
})
logsDir := widget.NewEntry()
logsDir.SetText(store.Config.LogsDir)
logsDirBrowse := widget.NewButtonWithIcon("Browse", theme.FolderOpenIcon(), func() {
chooseFolder(w, logsDir)
})
maxLogFiles := widget.NewEntry()
maxLogFiles.SetText(strconv.Itoa(store.Config.MaxLogFiles))
maxLogAgeDays := widget.NewEntry()
maxLogAgeDays.SetText(strconv.Itoa(store.Config.MaxLogAgeDays))
settingsStatus := widget.NewLabel("")
saveSettings := widget.NewButtonWithIcon("Save settings", theme.DocumentSaveIcon(), func() {
files, err := strconv.Atoi(strings.TrimSpace(maxLogFiles.Text))
if err != nil || files <= 0 {
settingsStatus.SetText("Max log files must be a positive number")
return
}
days, err := strconv.Atoi(strings.TrimSpace(maxLogAgeDays.Text))
if err != nil || days <= 0 {
settingsStatus.SetText("Max log age days must be a positive number")
return
}
if strings.TrimSpace(jobsDir.Text) == "" {
settingsStatus.SetText("Jobs directory is required")
return
}
if strings.TrimSpace(logsDir.Text) == "" {
settingsStatus.SetText("Logs directory is required")
return
}
// Build the new config from the form and hand it to the Service, which
// validates it, persists config and jobs to the (possibly new) directory,
// and runs log cleanup so tightened retention limits take effect at once.
config := store.Config
config.JobsDir = strings.TrimSpace(jobsDir.Text)
config.LogsDir = strings.TrimSpace(logsDir.Text)
config.MaxLogFiles = files
config.MaxLogAgeDays = days
config.StartOnLogin = startOnLogin.Checked
config.KeepRunningInTray = minimizeToTray.Checked
config.NotifyOnFailure = notifications.Checked
if err := svc.UpdateSettings(config); err != nil {
settingsStatus.SetText("Save failed: " + err.Error())
return
}
if err := svc.ApplyAutostart(); err != nil {
refreshAutostartStatus()
settingsStatus.SetText("Saved, autostart failed: " + err.Error())
return
}
refreshAutostartStatus()
settingsStatus.SetText("Saved")
})
return container.NewPadded(container.NewVBox(
widget.NewLabelWithStyle("Application", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
settingsRowWithStatus("Autostart", startOnLogin, autostartStatus),
settingsRow("Tray", container.New(minWidthLayout{width: settingsControlWidth}, minimizeToTray)),
settingsRow("Notifications", container.New(minWidthLayout{width: settingsControlWidth}, notifications)),
widget.NewSeparator(),
widget.NewLabelWithStyle("Storage", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
settingsRow("Config YAML", widget.NewLabel(store.Paths.ConfigPath)),
settingsRow("Jobs directory", container.NewBorder(nil, nil, nil, jobsDirBrowse, jobsDir)),
settingsRow("Logs directory", container.NewBorder(nil, nil, nil, logsDirBrowse, logsDir)),
settingsRow("Max log files", maxLogFiles),
settingsRow("Max log age days", maxLogAgeDays),
saveSettings,
settingsStatus,
widget.NewSeparator(),
widget.NewLabelWithStyle("About", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
settingsRow("GoSentry", widget.NewLabel(app.Version)),
settingsRow("Go", widget.NewLabel(runtime.Version())),
settingsRow("Fyne", widget.NewLabel(fyneVersion())),
settingsRow("Repository", widget.NewHyperlink(projectRepositoryURL, mustParseURL(projectRepositoryURL))),
))
}
func fyneVersion() string {
info, ok := debug.ReadBuildInfo()
if !ok {
return "unknown"
}
for _, dependency := range info.Deps {
if dependency.Path == "fyne.io/fyne/v2" {
if dependency.Replace != nil && dependency.Replace.Version != "" {
return dependency.Replace.Version
}
if dependency.Version != "" {
return dependency.Version
}
return "local"
}
}
return "unknown"
}
func mustParseURL(raw string) *url.URL {
parsed, err := url.Parse(raw)
if err != nil {
return &url.URL{}
}
return parsed
}
func chooseFolder(w fyne.Window, target *widget.Entry) {
folderDialog := dialog.NewFolderOpen(func(uri fyne.ListableURI, err error) {
if err != nil || uri == nil {
return
}
target.SetText(uri.Path())
}, w)
// The default folder picker can be cramped on Windows. A larger size makes
// long paths readable and avoids forcing the user to resize it every time.
folderDialog.Resize(fyne.NewSize(900, 640))
folderDialog.Show()
}
func settingsRow(label string, value fyne.CanvasObject) fyne.CanvasObject {
caption := widget.NewLabelWithStyle(label, fyne.TextAlignLeading, fyne.TextStyle{Bold: true})
caption.Wrapping = fyne.TextTruncate
captionBox := container.New(minWidthLayout{width: settingsLabelWidth}, caption)
return container.NewBorder(nil, nil, captionBox, nil, value)
}
func settingsRowWithStatus(label string, value fyne.CanvasObject, status fyne.CanvasObject) fyne.CanvasObject {
valueBox := container.New(minWidthLayout{width: settingsControlWidth}, value)
statusBox := container.New(minWidthLayout{width: settingsStatusWidth}, status)
return settingsRow(label, container.NewBorder(nil, nil, valueBox, nil, statusBox))
}
+64
View File
@@ -0,0 +1,64 @@
package ui
import (
"io"
"net"
"strings"
"time"
"fyne.io/fyne/v2"
)
const singleInstanceAddress = "127.0.0.1:37653"
const singleInstanceShowCommand = "show"
func acquireSingleInstance(showExisting bool) (net.Listener, bool) {
listener, err := net.Listen("tcp", singleInstanceAddress)
if err == nil {
return listener, true
}
connection, dialErr := net.DialTimeout("tcp", singleInstanceAddress, time.Second)
if dialErr == nil {
// The first instance listens only on localhost and understands one tiny
// command: "show". That keeps the implementation dependency-free and easy
// to inspect, which matters more here than introducing a named-pipe or
// platform-specific IPC abstraction just to focus an existing window.
if showExisting {
_, _ = io.WriteString(connection, singleInstanceShowCommand)
}
_ = connection.Close()
return nil, false
}
// If the port is unavailable but does not answer as GoSentry, continue
// startup instead of making the application impossible to open because of an
// unrelated local listener. In the normal duplicate-start case the dial above
// succeeds and this process exits after waking the first instance.
return nil, true
}
func serveSingleInstance(listener net.Listener, w fyne.Window) {
if listener == nil {
return
}
go func() {
for {
connection, err := listener.Accept()
if err != nil {
return
}
command, _ := io.ReadAll(io.LimitReader(connection, 32))
_ = connection.Close()
if strings.TrimSpace(string(command)) != singleInstanceShowCommand {
continue
}
// Accept runs on its own goroutine, so focusing the window must be
// marshaled onto the main thread like every other widget update.
fyne.Do(func() {
w.Show()
w.RequestFocus()
})
}
}()
}
+40
View File
@@ -0,0 +1,40 @@
package ui
import (
"fyne.io/fyne/v2"
fynedesktop "fyne.io/fyne/v2/driver/desktop"
)
func configureSystemTray(a fyne.App, w fyne.Window) {
desk, ok := a.(fynedesktop.App)
if !ok {
// Not every Fyne driver exposes desktop tray features. Returning silently
// keeps the same binary usable on platforms or sessions without a tray.
return
}
// IsQuit marks this as the tray's quit item. Without it Fyne's
// addMissingQuitForMenu appends a second, localized Quit (e.g. "Выход" on a
// Russian system) because it only recognizes an existing quit by matching the
// localized label — which our literal "Quit" does not. Setting IsQuit makes
// Fyne reuse this item instead of adding a duplicate, regardless of locale.
quit := fyne.NewMenuItem("Quit", func() {
a.Quit()
})
quit.IsQuit = true
menu := fyne.NewMenu("GoSentry",
fyne.NewMenuItem("Show", func() {
w.Show()
w.RequestFocus()
}),
fyne.NewMenuItemSeparator(),
quit,
)
desk.SetSystemTrayMenu(menu)
w.SetCloseIntercept(func() {
// Closing hides the window instead of quitting because scheduler tools are
// expected to keep working in the background. The explicit Quit tray item
// remains the way to stop the process.
w.Hide()
})
}