Refactoring complete: v0.4.0 architectural milestone #1
@@ -9,6 +9,7 @@ Project notes:
|
|||||||
- [Changelog](docs/CHANGELOG.md)
|
- [Changelog](docs/CHANGELOG.md)
|
||||||
- [Roadmap](docs/ROADMAP.md)
|
- [Roadmap](docs/ROADMAP.md)
|
||||||
- [Architecture](docs/ARCHITECTURE.md)
|
- [Architecture](docs/ARCHITECTURE.md)
|
||||||
|
- [Refactoring plan](docs/REFACTORING.md)
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
@@ -351,14 +352,21 @@ Windows:
|
|||||||
|
|
||||||
## Project Layout
|
## Project Layout
|
||||||
|
|
||||||
- `cmd/gosentry` starts the desktop app.
|
- `cmd/gosentry` — entry point; starts the desktop app.
|
||||||
- `src/gui` contains the GUI.
|
- `src/domain` — pure value types: `Job`, `Config`, `RunRecord`, `Schedule`, `JobRuntime`.
|
||||||
- `src/core` contains YAML storage, command execution, scheduling, and log cleanup.
|
- `src/app` — `Service`: sole owner of job and runtime state; emits typed events to the UI.
|
||||||
- `assets` contains app icons that are embedded into the application binary.
|
- `src/scheduler` — pure timing loop; calls `Service.RunDue` on every tick.
|
||||||
- `scripts` contains build helpers.
|
- `src/runner` — shell command execution, log file writing, and log cleanup.
|
||||||
- `docs` contains architecture notes, the changelog, and the roadmap.
|
- `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
|
## Dependencies
|
||||||
|
|
||||||
|
|||||||
@@ -3,15 +3,15 @@ package main
|
|||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"gitea.mixdep.ru/mix/gosentry/src/core"
|
"gitea.mixdep.ru/mix/gosentry/src/domain"
|
||||||
"gitea.mixdep.ru/mix/gosentry/src/gui"
|
"gitea.mixdep.ru/mix/gosentry/src/ui"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
// The executable entry point intentionally delegates all startup work to the
|
// 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.
|
// 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 {
|
func hasArgument(argument string) bool {
|
||||||
|
|||||||
+72
-41
@@ -1,73 +1,104 @@
|
|||||||
# GoSentry Architecture
|
# GoSentry Architecture
|
||||||
|
|
||||||
This document shows the current component interaction model. GoSentry is still a
|
This document shows the current component interaction model. GoSentry is a
|
||||||
single desktop process: the GUI, scheduler, storage, and command runner live in
|
single desktop process: the GUI, application service, scheduler, storage, and
|
||||||
one application and communicate through Go function calls and shared in-memory
|
command runner live in one application. They communicate through typed events
|
||||||
job state.
|
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
|
## Component Diagram
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart LR
|
flowchart LR
|
||||||
user["Desktop user"]
|
user["Desktop user"]
|
||||||
gui["src/gui - Fyne windows, tabs, dialogs"]
|
ui["src/ui\nFyne windows, tabs, dialogs"]
|
||||||
store["src/core Store - YAML config and jobs"]
|
svc["src/app Service\nsole owner of job + runtime state"]
|
||||||
scheduler["src/core Scheduler - @every and cron timing"]
|
store["src/storage Store\nYAML config and jobs"]
|
||||||
runner["src/core Runner - shell command execution"]
|
sched["src/scheduler Scheduler\npure timing loop"]
|
||||||
autostart["src/core Autostart - Windows Startup shortcut / Linux desktop startup"]
|
runner["src/runner\nshell command execution"]
|
||||||
config["gosentry.yaml - application settings"]
|
autostart["src/platform/autostart Manager\nWindows shortcut / Linux XDG"]
|
||||||
jobs["jobs.yaml - job definitions"]
|
config["gosentry.yaml\napplication settings"]
|
||||||
logs["logs_dir - per-run command output logs"]
|
jobs["jobs.yaml\njob definitions"]
|
||||||
shell["Platform shell - cmd.exe /C or sh -c"]
|
logs["logs_dir\nper-run command output logs"]
|
||||||
|
shell["Platform shell\ncmd.exe /C or sh -c"]
|
||||||
|
|
||||||
user -->|"edits jobs, settings, runs commands"| gui
|
user -->|"edits jobs, settings, runs commands"| ui
|
||||||
gui -->|"OpenStore, SaveConfig, SaveJobs"| store
|
ui -->|"CreateJob, UpdateJob, DeleteJob, RunNow, UpdateSettings, …"| svc
|
||||||
|
svc -->|"SaveJobs, SaveConfig, LoadJobs, LoadConfig"| store
|
||||||
store -->|"read/write"| config
|
store -->|"read/write"| config
|
||||||
store -->|"read/write"| jobs
|
store -->|"read/write"| jobs
|
||||||
|
|
||||||
gui -->|"Start, Pause, RunNow, RefreshSchedule"| scheduler
|
svc -->|"Start(RunDue)"| sched
|
||||||
scheduler -->|"SaveJobs after state changes"| store
|
sched -->|"RunDue(now)"| svc
|
||||||
scheduler -->|"RunJob(trigger)"| runner
|
svc -->|"RunJob"| runner
|
||||||
runner -->|"execute command"| shell
|
runner -->|"execute command"| shell
|
||||||
runner -->|"write stdout/stderr log"| logs
|
runner -->|"write stdout/stderr log"| logs
|
||||||
runner -->|"RunRecord with status, duration, log path"| scheduler
|
runner -->|"RunRecord"| svc
|
||||||
scheduler -->|"onChange RunRecord"| gui
|
svc -->|"emit JobChanged / RunRecorded / ErrorOccurred"| ui
|
||||||
gui -->|"display History, command output, job state"| user
|
ui -->|"display jobs, history, status"| user
|
||||||
|
|
||||||
gui -->|"SetAutostart, AutostartStatus"| autostart
|
ui -->|"SetAutostart, AutostartStatus"| autostart
|
||||||
autostart -->|"use executable path from resolved Paths"| config
|
svc -->|"Set / Status via Manager"| autostart
|
||||||
```
|
```
|
||||||
|
|
||||||
## Main Flows
|
## Main Flows
|
||||||
|
|
||||||
1. Startup:
|
1. Startup:
|
||||||
The executable starts `cmd/gosentry`, which calls the GUI package. The GUI
|
`cmd/gosentry` calls `ui.Run`, which creates an `app.Service`, opens the
|
||||||
opens the store, loads `gosentry.yaml` and `jobs.yaml`, creates the main tabs,
|
store, loads `gosentry.yaml` and `jobs.yaml`, subscribes the UI to service
|
||||||
then starts the scheduler with the loaded job slice.
|
events, builds the main window, and calls `Service.Start` to begin the
|
||||||
|
scheduler loop.
|
||||||
|
|
||||||
2. Editing settings or jobs:
|
2. Editing settings or jobs:
|
||||||
The GUI updates the in-memory job/config state and asks `Store` to write YAML
|
The UI calls mutating methods on `app.Service` (e.g. `CreateJob`,
|
||||||
back to disk. Job definitions stay in one `jobs.yaml`; runtime command output
|
`UpdateJob`, `UpdateSettings`). The Service validates the request, updates
|
||||||
is not stored there.
|
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:
|
3. Scheduled run:
|
||||||
`Scheduler` checks due jobs on a one-second ticker. When a job is due, it marks
|
`scheduler.Scheduler` fires a tick every second. On each tick it calls
|
||||||
the job as running, saves state, and starts `Runner` asynchronously.
|
`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:
|
4. Manual run:
|
||||||
`Run now` calls the same scheduler path as scheduled execution, but the
|
`Run now` in the UI calls `Service.RunNow`. The Service checks that the job
|
||||||
resulting history record uses the `Manual` trigger.
|
exists, is not already running, and that the scheduler is not paused, then
|
||||||
|
executes `runner.RunJob` with the `Manual` trigger.
|
||||||
|
|
||||||
5. Command execution:
|
5. Command execution:
|
||||||
`Runner` executes the command through the platform shell, captures stdout and
|
`runner.RunJob` builds the platform-specific invocation, executes the
|
||||||
stderr, writes one timestamped `.log` file, and returns a `RunRecord`.
|
command through the platform shell, captures stdout and stderr, writes one
|
||||||
|
timestamped `.log` file, and returns a `domain.RunRecord`.
|
||||||
|
|
||||||
6. History update:
|
6. History update:
|
||||||
The scheduler receives the `RunRecord`, updates the matching job, saves YAML,
|
When a run goroutine completes, `Service` updates the job's runtime, saves
|
||||||
runs log cleanup, and calls the GUI callback so the `History` tab refreshes.
|
YAML, triggers log cleanup, and emits `RunRecorded`. The UI observer appends
|
||||||
|
the record to the History tab.
|
||||||
|
|
||||||
7. Autostart:
|
7. Autostart:
|
||||||
The Settings tab calls the platform autostart implementation. Windows uses a
|
`UpdateSettings` in the Service calls `autostart.Manager.Set`. The Manager
|
||||||
shortcut in the current user's Startup folder. Linux uses a desktop-session
|
interface has two implementations: Windows writes a `.lnk` shortcut to the
|
||||||
startup entry. Both autostart mechanisms pass `--start-in-tray`, so the
|
user Startup folder; Linux writes an XDG Autostart `.desktop` file. Both
|
||||||
scheduler starts without opening the main window after sign-in.
|
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.
|
||||||
|
|||||||
@@ -2,6 +2,68 @@
|
|||||||
|
|
||||||
All notable GoSentry changes are recorded in this file.
|
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
|
## 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.
|
- Changed startup timing in History to measure until the main window is actually shown instead of stopping during UI construction.
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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
@@ -2,6 +2,32 @@
|
|||||||
|
|
||||||
This file tracks planned GoSentry work that is larger than a single bug fix.
|
This file tracks planned GoSentry work that is larger than a single bug fix.
|
||||||
|
|
||||||
|
## 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
|
## Post-Field-Test Cleanup
|
||||||
|
|
||||||
After real-world use confirms the main workflows, clean up temporary
|
After real-world use confirms the main workflows, clean up temporary
|
||||||
@@ -24,13 +50,25 @@ Cleanup checklist:
|
|||||||
|
|
||||||
## Tray Interaction
|
## 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.
|
- Unblocked by Fyne 2.7.0, which added `desktop.App.SetSystemTrayWindow(window)`.
|
||||||
- Current Fyne 2.5.3 desktop tray API exposes menu and icon setup, but does not
|
On Windows, macOS, and most Linux it shows the associated window on left-click;
|
||||||
expose click or double-click callbacks for the tray icon itself.
|
any tray menu then moves to right-click. There is still no raw click /
|
||||||
- Revisit when Fyne exposes this callback, or evaluate a small platform-specific
|
double-click callback, so the behavior is single left-click (the conventional
|
||||||
tray integration if the behavior becomes important enough.
|
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
|
## Delivery And Packaging
|
||||||
|
|
||||||
|
|||||||
+268
-89
@@ -1,146 +1,326 @@
|
|||||||
# GoSentry Test Suite
|
# 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
|
## Test Files Overview
|
||||||
|
|
||||||
### store_test.go
|
### src/domain/schedule_test.go
|
||||||
**Location:** `src/core/store_test.go`
|
|
||||||
**Package:** `core`
|
|
||||||
|
|
||||||
Tests YAML serialization and storage behavior.
|
**Package:** `domain`
|
||||||
|
|
||||||
|
Tests schedule parsing and validation.
|
||||||
|
|
||||||
| Test | Purpose |
|
| 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
|
### src/app/service_test.go
|
||||||
**Location:** `src/core/scheduler_test.go`
|
|
||||||
**Package:** `core`
|
|
||||||
|
|
||||||
Tests schedule parsing and job invocation output formatting.
|
**Package:** `app`
|
||||||
|
|
||||||
|
Tests `Service` construction and the state-accessor contract.
|
||||||
|
|
||||||
| Test | Purpose |
|
| Test | Purpose |
|
||||||
|------|---------|
|
|------|---------|
|
||||||
| `TestNextRunTimeSupportsEvery` | Verifies `@every` duration syntax (e.g., `@every 10s`) correctly calculates next run time. Tests with 10-second interval. |
|
| `TestNewServiceBuildsRuntimePerJob` | Verifies that `NewService` creates a `JobRuntime` entry for every loaded job. |
|
||||||
| `TestNextRunTimeSupportsCron` | Verifies standard 5-field cron expressions (e.g., `*/5 * * * *`) correctly calculate next run time. Tests 5-minute interval. |
|
| `TestJobsReturnsCopy` | Verifies that `Service.Jobs` returns a defensive copy so callers cannot mutate internal state. |
|
||||||
| `TestRunningOutputIncludesInvocation` | Verifies the running job output header includes all relevant invocation details: command, arguments, success exit codes, start time, and trigger type. |
|
| `TestStoreReturnsWiredStore` | Verifies that `Service.Store` returns the injected `storage.Store`. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### runner_test.go
|
### src/app/operations_test.go
|
||||||
**Location:** `src/core/runner_test.go`
|
|
||||||
**Package:** `core`
|
**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.
|
Tests command execution, exit code handling, output capture, and Windows-specific process behavior.
|
||||||
|
|
||||||
#### Log File Tests
|
#### Log file tests
|
||||||
|
|
||||||
| Test | Purpose |
|
| 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 |
|
| Test | Platform | Purpose |
|
||||||
|------|----------|---------|
|
|------|----------|---------|
|
||||||
| `TestRunJobRunsQuotedWindowsExecutable` | Windows | Verifies that executable paths with quotes (e.g., `"C:\Program Files\..."`) are executed correctly via cmd.exe. |
|
| `TestRunJobRunsQuotedWindowsExecutable` | Windows | Verifies that executable paths with quotes 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. |
|
| `TestRunJobRunsUnquotedWindowsProgramPathWithSpaces` | Windows | Verifies that unquoted executable paths with spaces are quoted and executed correctly. |
|
||||||
| `TestRunJobRunsWindowsCommandWithSeparateArguments` | Windows | Verifies that command and arguments separated in the Job struct are combined 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 |
|
| 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. |
|
| `TestRunJobAcceptsConfiguredExitCode` | Verifies that exit codes in `SuccessExitCodes` result in "OK" status even when nonzero. |
|
||||||
| `TestRunJobRejectsUnconfiguredExitCode` | Verifies that exit codes not listed in `SuccessExitCodes` result in "Failed" status with exit code detail. |
|
| `TestRunJobRejectsUnconfiguredExitCode` | Verifies that exit codes absent from `SuccessExitCodes` result in "Failed" status. |
|
||||||
|
|
||||||
#### Start-Only Mode Tests
|
#### Start-only mode
|
||||||
|
|
||||||
| Test | Purpose |
|
| 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. |
|
| `TestRunJobStartOnlyDoesNotWaitForExitCode` | Verifies that `StartOnly: true` jobs launch and return "OK" immediately without waiting for the process to exit. |
|
||||||
| `TestRunJobStartOnlyReportsStartFailure` | Verifies that jobs with `StartOnly: true` still report "Failed" if the process fails to start (e.g., executable not found). |
|
| `TestRunJobStartOnlyReportsStartFailure` | Verifies that `StartOnly: true` jobs still report "Failed" if the process cannot be started. |
|
||||||
|
|
||||||
#### Utility Function Tests
|
#### Utility / Windows invocation
|
||||||
|
|
||||||
| Test | Platform | Purpose |
|
| Test | Platform | Purpose |
|
||||||
|------|----------|---------|
|
|------|----------|---------|
|
||||||
| `TestParseExitCodes` | All | Verifies that exit code strings with mixed separators (comma, semicolon, newline) are correctly parsed into integer slice. |
|
| `TestParseExitCodes` | All | Verifies that mixed-separator exit-code strings (comma, semicolon, newline) are parsed correctly. |
|
||||||
| `TestDirectCommandDoesNotHideWindow` | Windows | Verifies that direct executable commands (with explicit path and arguments) do not request hidden window startup. |
|
| `TestDirectCommandDoesNotHideWindow` | Windows | Verifies that direct executable commands do not request hidden-window startup. |
|
||||||
| `TestShellCommandHidesWindow` | Windows | Verifies that shell commands (passed to cmd.exe) request hidden window startup to prevent console flash. |
|
| `TestShellCommandHidesWindow` | Windows | Verifies that shell commands 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. |
|
| `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. |
|
| `TestWindowsShellCommandLineQuotesUnquotedProgramPath` | Windows | Verifies that unquoted program paths in shell commands are quoted while preserving already-quoted arguments. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### autostart_windows_test.go
|
### src/runner/cleanup_test.go
|
||||||
**Location:** `src/core/autostart_windows_test.go`
|
|
||||||
**Package:** `core`
|
|
||||||
**Build Tags:** `//go:build windows` (Windows only)
|
|
||||||
|
|
||||||
Tests Windows autostart entry creation via shortcuts in the Startup folder.
|
**Package:** `runner`
|
||||||
|
|
||||||
|
Tests log-file cleanup by age and by count.
|
||||||
|
|
||||||
| Test | Purpose |
|
| Test | Purpose |
|
||||||
|------|---------|
|
|------|---------|
|
||||||
| `TestParseRegistryRunValue` | Verifies that legacy Windows Registry `Run` entry values are correctly parsed from `reg query` output (for migration/cleanup). |
|
| `TestCleanupLogsMissingDirReturnsNil` | Verifies that cleanup returns nil (not an error) when the logs directory does not exist. |
|
||||||
| `TestSameWindowsPathIgnoresCaseAndQuotes` | Verifies that Windows path comparison is case-insensitive and handles quote marks correctly (e.g., `"D:\..."` matches `d:\...`). |
|
| `TestCleanupLogsRemovesFilesPastMaxAge` | Verifies that `.log` files older than `MaxLogAgeDays` are deleted. |
|
||||||
| `TestSameWindowsPathHandlesSpaces` | Verifies that Windows path comparison correctly matches paths with spaces both with and without quotes. |
|
| `TestCleanupLogsKeepsFilesWithinAgeLimit` | Verifies that `.log` files within the age limit are retained. |
|
||||||
| `TestStartupShortcutPathUsesUserStartupFolder` | Verifies that the startup shortcut path resolves to the user's Startup folder using `%APPDATA%` environment variable. |
|
| `TestCleanupLogsByCountDeletesOldest` | Verifies that when file count exceeds `MaxLogFiles`, the oldest files are removed first. |
|
||||||
| `TestCreateStartupShortcutHandlesSpaces` | Verifies that `.lnk` shortcut files are created with correct `TargetPath` and `Arguments` (--start-in-tray) even when target path contains spaces. |
|
| `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
|
### src/platform/autostart/autostart_windows_test.go
|
||||||
**Location:** `src/core/autostart_linux_test.go`
|
|
||||||
**Package:** `core`
|
|
||||||
**Build Tags:** `//go:build linux` (Linux only)
|
|
||||||
|
|
||||||
Tests Linux autostart entry creation via XDG Desktop Entry files.
|
**Location:** `src/platform/autostart/autostart_windows_test.go`
|
||||||
|
**Build Tags:** `//go:build windows`
|
||||||
|
|
||||||
|
Tests Windows autostart via shortcuts in the Startup folder.
|
||||||
|
|
||||||
| Test | Purpose |
|
| 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. |
|
| `TestParseRegistryRunValue` | Verifies that legacy `HKCU\...\Run` entry values are parsed correctly from `reg query` output (for migration/cleanup). |
|
||||||
| `TestLinuxAutostartRemovesLegacyDesktopEntry` | Verifies that legacy autostart entries (from old PySentry implementation) are cleaned up when enabling autostart through the new system. |
|
| `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
|
**Location:** `src/platform/autostart/autostart_linux_test.go`
|
||||||
```bash
|
**Build Tags:** `//go:build linux`
|
||||||
cd D:\Local\Git\gosentry
|
|
||||||
go test ./src/core
|
|
||||||
```
|
|
||||||
|
|
||||||
### Run tests with verbose output
|
Tests Linux autostart via XDG Desktop Entry files.
|
||||||
```bash
|
|
||||||
go test -v ./src/core
|
|
||||||
```
|
|
||||||
|
|
||||||
### Run specific test by name
|
| Test | Purpose |
|
||||||
```bash
|
|------|---------|
|
||||||
go test -run TestRunJobWritesLogFile ./src/core
|
| `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)
|
### src/ui/jobs_view_test.go
|
||||||
```bash
|
|
||||||
go test -v ./src/core # Linux build tags are active
|
|
||||||
```
|
|
||||||
|
|
||||||
### Run with code coverage
|
**Package:** `ui`
|
||||||
```bash
|
|
||||||
go test -cover ./src/core
|
Tests pure helper functions in the jobs view (no Fyne widget construction).
|
||||||
go test -coverprofile=coverage.out ./src/core
|
|
||||||
go tool cover -html=coverage.out
|
| 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.
|
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:
|
- GUI integration tests — Fyne widget interaction is not yet tested end-to-end
|
||||||
- Job group/folder filtering and persistence
|
- Job history on-disk retrieval — RunRecord file reading is not covered
|
||||||
- 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
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ module gitea.mixdep.ru/mix/gosentry
|
|||||||
go 1.22
|
go 1.22
|
||||||
|
|
||||||
require (
|
require (
|
||||||
fyne.io/fyne/v2 v2.5.3
|
fyne.io/fyne/v2 v2.6.3
|
||||||
github.com/robfig/cron/v3 v3.0.1
|
github.com/robfig/cron/v3 v3.0.1
|
||||||
go.yaml.in/yaml/v4 v4.0.0-rc.5
|
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/BurntSushi/toml v1.4.0 // indirect
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
github.com/fredbi/uri v1.1.0 // indirect
|
github.com/fredbi/uri v1.1.0 // indirect
|
||||||
github.com/fsnotify/fsnotify v1.7.0 // indirect
|
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||||
github.com/fyne-io/gl-js v0.0.0-20220119005834-d2da28d9ccfe // indirect
|
github.com/fyne-io/gl-js v0.2.0 // indirect
|
||||||
github.com/fyne-io/glfw-js v0.0.0-20241126112943-313d8a0fe1d0 // indirect
|
github.com/fyne-io/glfw-js v0.3.0 // indirect
|
||||||
github.com/fyne-io/image v0.0.0-20220602074514-4956b0afb3d2 // indirect
|
github.com/fyne-io/image v0.1.1 // indirect
|
||||||
github.com/go-gl/gl v0.0.0-20211210172815-726fda9656d6 // 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-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a // indirect
|
||||||
github.com/go-text/render v0.2.0 // 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/godbus/dbus/v5 v5.1.0 // indirect
|
||||||
github.com/gopherjs/gopherjs v1.17.2 // indirect
|
github.com/hack-pad/go-indexeddb v0.3.2 // indirect
|
||||||
github.com/jeandeaual/go-locale v0.0.0-20240223122105-ce5225dcaa49 // indirect
|
github.com/hack-pad/safejs v0.1.0 // indirect
|
||||||
github.com/jsummers/gobmp v0.0.0-20151104160322-e2ba15ffa76e // indirect
|
github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade // indirect
|
||||||
github.com/nicksnyder/go-i18n/v2 v2.4.0 // 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/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/oksvg v0.0.0-20221011165216-be6e8873101c // indirect
|
||||||
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef // indirect
|
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef // indirect
|
||||||
github.com/stretchr/testify v1.8.4 // indirect
|
github.com/stretchr/testify v1.10.0 // indirect
|
||||||
github.com/yuin/goldmark v1.7.1 // indirect
|
github.com/yuin/goldmark v1.7.8 // indirect
|
||||||
golang.org/x/image v0.18.0 // indirect
|
golang.org/x/image v0.24.0 // indirect
|
||||||
golang.org/x/mobile v0.0.0-20231127183840-76ac6878050a // indirect
|
golang.org/x/net v0.35.0 // indirect
|
||||||
golang.org/x/net v0.25.0 // indirect
|
golang.org/x/sys v0.30.0 // indirect
|
||||||
golang.org/x/sys v0.20.0 // indirect
|
golang.org/x/text v0.22.0 // indirect
|
||||||
golang.org/x/text v0.16.0 // indirect
|
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,660 +1,84 @@
|
|||||||
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
fyne.io/fyne/v2 v2.6.3 h1:cvtM2KHeRuH+WhtHiA63z5wJVBkQ9+Ay0UMl9PxFHyA=
|
||||||
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
fyne.io/fyne/v2 v2.6.3/go.mod h1:NGSurpRElVoI1G3h+ab2df3O5KLGh1CGbsMMcX0bPIs=
|
||||||
cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU=
|
|
||||||
cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU=
|
|
||||||
cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY=
|
|
||||||
cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc=
|
|
||||||
cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0=
|
|
||||||
cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To=
|
|
||||||
cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4=
|
|
||||||
cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M=
|
|
||||||
cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc=
|
|
||||||
cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk=
|
|
||||||
cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs=
|
|
||||||
cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc=
|
|
||||||
cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY=
|
|
||||||
cloud.google.com/go v0.72.0/go.mod h1:M+5Vjvlc2wnp6tjzE102Dw08nGShTscUx2nZMufOKPI=
|
|
||||||
cloud.google.com/go v0.74.0/go.mod h1:VV1xSbzvo+9QJOxLDaJfTjx5e+MePCpCWwvftOeQmWk=
|
|
||||||
cloud.google.com/go v0.78.0/go.mod h1:QjdrLG0uq+YwhjoVOLsS1t7TW8fs36kLs4XO5R5ECHg=
|
|
||||||
cloud.google.com/go v0.79.0/go.mod h1:3bzgcEeQlzbuEAYu4mrWhKqWjmpprinYgKJLgKHnbb8=
|
|
||||||
cloud.google.com/go v0.81.0/go.mod h1:mk/AM35KwGk/Nm2YSeZbxXdrNK3KZOYHmLkOqC2V6E0=
|
|
||||||
cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=
|
|
||||||
cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE=
|
|
||||||
cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc=
|
|
||||||
cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg=
|
|
||||||
cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc=
|
|
||||||
cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ=
|
|
||||||
cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE=
|
|
||||||
cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk=
|
|
||||||
cloud.google.com/go/firestore v1.1.0/go.mod h1:ulACoGHTpvq5r8rxGJ4ddJZBZqakUQqClKRT5SZwBmk=
|
|
||||||
cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I=
|
|
||||||
cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw=
|
|
||||||
cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA=
|
|
||||||
cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU=
|
|
||||||
cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw=
|
|
||||||
cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos=
|
|
||||||
cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk=
|
|
||||||
cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs=
|
|
||||||
cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0=
|
|
||||||
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
|
|
||||||
fyne.io/fyne/v2 v2.5.3 h1:k6LjZx6EzRZhClsuzy6vucLZBstdH2USDGHSGWq8ly8=
|
|
||||||
fyne.io/fyne/v2 v2.5.3/go.mod h1:0GOXKqyvNwk3DLmsFu9v0oYM0ZcD1ysGnlHCerKoAmo=
|
|
||||||
fyne.io/systray v1.11.0 h1:D9HISlxSkx+jHSniMBR6fCFOUjk1x/OOOJLa9lJYAKg=
|
fyne.io/systray v1.11.0 h1:D9HISlxSkx+jHSniMBR6fCFOUjk1x/OOOJLa9lJYAKg=
|
||||||
fyne.io/systray v1.11.0/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs=
|
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 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0=
|
||||||
github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
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/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||||
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/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
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/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 h1:VvyZxILNuCiUCSXtPtYmmtGvb65nqXh2QFWc0Wpf2/g=
|
||||||
github.com/felixge/fgprof v0.9.3/go.mod h1:RdbpDgzqYVh/T9fPELJyV7EYJuHB55UTEULNun8eiPw=
|
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 h1:OqLpTXtyRg9ABReqvDGdJPqZUxs8cyBDOMXBbskCaB8=
|
||||||
github.com/fredbi/uri v1.1.0/go.mod h1:aYTUoAXBOq7BLfVJ8GnKmfcuURosB1xyHDIfWeC/iW4=
|
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.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||||
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
|
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||||
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
|
github.com/fyne-io/gl-js v0.2.0 h1:+EXMLVEa18EfkXBVKhifYB6OGs3HwKO3lUElA0LlAjs=
|
||||||
github.com/fyne-io/gl-js v0.0.0-20220119005834-d2da28d9ccfe h1:A/wiwvQ0CAjPkuJytaD+SsXkPU0asQ+guQEIg1BJGX4=
|
github.com/fyne-io/gl-js v0.2.0/go.mod h1:ZcepK8vmOYLu96JoxbCKJy2ybr+g1pTnaBDdl7c3ajI=
|
||||||
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.3.0 h1:d8k2+Y7l+zy2pc7wlGRyPfTgZoqDf3AI4G+2zOWhWUk=
|
||||||
github.com/fyne-io/glfw-js v0.0.0-20241126112943-313d8a0fe1d0 h1:/1YRWFv9bAWkoo3SuxpFfzpXH0D/bQnTjNXyF4ih7Os=
|
github.com/fyne-io/glfw-js v0.3.0/go.mod h1:Ri6te7rdZtBgBpxLW19uBpp3Dl6K9K/bRaYdJ22G8Jk=
|
||||||
github.com/fyne-io/glfw-js v0.0.0-20241126112943-313d8a0fe1d0/go.mod h1:gsGA2dotD4v0SR6PmPCYvS9JuOeMwAtmfvDE7mbYXMY=
|
github.com/fyne-io/image v0.1.1 h1:WH0z4H7qfvNUw5l4p3bC1q70sa5+YWVt6HCj7y4VNyA=
|
||||||
github.com/fyne-io/image v0.0.0-20220602074514-4956b0afb3d2 h1:hnLq+55b7Zh7/2IRzWCpiTcAvjv/P8ERF+N7+xXbZhk=
|
github.com/fyne-io/image v0.1.1/go.mod h1:xrfYBh6yspc+KjkgdZU/ifUC9sPA5Iv7WYUBzQKK7JM=
|
||||||
github.com/fyne-io/image v0.0.0-20220602074514-4956b0afb3d2/go.mod h1:eO7W361vmlPOrykIg+Rsh1SZ3tQBaOsfzZhsIOb/Lm0=
|
github.com/fyne-io/oksvg v0.1.0 h1:7EUKk3HV3Y2E+qypp3nWqMXD7mum0hCw2KEGhI1fnBw=
|
||||||
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
|
github.com/fyne-io/oksvg v0.1.0/go.mod h1:dJ9oEkPiWhnTFNCmRgEze+YNprJF7YRbpjgpWS4kzoI=
|
||||||
github.com/go-gl/gl v0.0.0-20211210172815-726fda9656d6 h1:zDw5v7qm4yH7N8C8uWd+8Ii9rROdgWxQuGoJ9WDXxfk=
|
github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71 h1:5BVwOaUSBTlVZowGO6VZGw2H/zl9nrd3eCZfYV+NfQA=
|
||||||
github.com/go-gl/gl v0.0.0-20211210172815-726fda9656d6/go.mod h1:9YTyiznxEY1fVinfM7RvRcjRHbw2xLBJ3AAGIT0I4Nw=
|
github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71/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/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 h1:vxnBhFDDT+xzxf1jTJKMKZw3H0swfWk9RpWbBbDK5+0=
|
||||||
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
|
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 h1:LBYoTmp5jYiJ4NPqDc2pz17MLmA3wHw1dZSVGcOdeAc=
|
||||||
github.com/go-text/render v0.2.0/go.mod h1:CkiqfukRGKJA5vZZISkjSYrcdtgKQWRa2HIzvwNN5SU=
|
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.1 h1:x0jMOGyO3d1qFAPI0j4GSsh7M0Q3Ypjzr4+CEVg82V8=
|
||||||
github.com/go-text/typesetting v0.2.0/go.mod h1:2+owI/sxa73XA581LAzVuEBZ3WEEV2pXeDswCH/3i1I=
|
github.com/go-text/typesetting v0.2.1/go.mod h1:mTOxEwasOFpAMBjEQDhdWRckoLLeI/+qrQeBCTGEt6M=
|
||||||
github.com/go-text/typesetting-utils v0.0.0-20240317173224-1986cbe96c66 h1:GUrm65PQPlhFSKjLPGOZNPNxLCybjzjYBzjfoBGaDUY=
|
github.com/go-text/typesetting-utils v0.0.0-20241103174707-87a29e9e6066 h1:qCuYC+94v2xrb1PoS4NIDe7DGYtLnU2wWiQe9a1B1c0=
|
||||||
github.com/go-text/typesetting-utils v0.0.0-20240317173224-1986cbe96c66/go.mod h1:DDxDdQEnB70R8owOx3LVpEFvpMK9eeH1o2r0yZhFI9o=
|
github.com/go-text/typesetting-utils v0.0.0-20241103174707-87a29e9e6066/go.mod h1:DDxDdQEnB70R8owOx3LVpEFvpMK9eeH1o2r0yZhFI9o=
|
||||||
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
|
||||||
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
|
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/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 h1:1FjCyPC+syAzJ5/2S8fqdZK1R22vvA0J7JZKcuOIQ7Y=
|
||||||
github.com/google/pprof v0.0.0-20211214055906-6f57359322fd/go.mod h1:KgnwoLYCZ8IQu3XUZ8Nc/bM9CCZFOyjUNOSygVozoDg=
|
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/hack-pad/go-indexeddb v0.3.2 h1:DTqeJJYc1usa45Q5r52t01KhvlSN02+Oq+tQbSBI91A=
|
||||||
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/hack-pad/go-indexeddb v0.3.2/go.mod h1:QvfTevpDVlkfomY498LhstjwbPW6QC4VC/lxYb0Kom0=
|
||||||
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
|
github.com/hack-pad/safejs v0.1.0 h1:qPS6vjreAqh2amUqj4WNG1zIw7qlRQJ9K10eDKMCnE8=
|
||||||
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
|
github.com/hack-pad/safejs v0.1.0/go.mod h1:HdS+bKF1NrE72VoXZeWzxFOVQVUSqZJAG0xNCnb+Tio=
|
||||||
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
|
github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade h1:FmusiCI1wHw+XQbvL9M+1r/C3SPqKrmBaIOYwVfQoDE=
|
||||||
github.com/gopherjs/gopherjs v0.0.0-20211219123610-ec9572f70e60/go.mod h1:cz9oNYuRUWGdHmLF2IodMLkAhcPtXeULvcBNagUrxTI=
|
github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade/go.mod h1:ZDXo8KHryOWSIqnsb/CiDq7hQUYryCgdVnxbj8tDG7o=
|
||||||
github.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g=
|
github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 h1:YLvr1eE6cdCqjOe972w/cYF+FjW34v27+9Vo5106B4M=
|
||||||
github.com/gopherjs/gopherjs v1.17.2/go.mod h1:pRRIvn/QzFLrKfvEz3qUuEhtE/zLCWfreZ6J5gM2i+k=
|
github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25/go.mod h1:kLgvv7o6UM+0QSf0QjAse3wReFDsb9qbZJdfexWlrQw=
|
||||||
github.com/goxjs/gl v0.0.0-20210104184919-e3fafc6f8f2a/go.mod h1:dy/f2gjY09hwVfIyATps4G2ai7/hLwLkc5TrPqONuXY=
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw=
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
github.com/hashicorp/consul/api v1.1.0/go.mod h1:VmuI/Lkw1nC05EYQWNKwWGbkg+FbDBtguAZLlVdkD9Q=
|
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
|
||||||
github.com/hashicorp/consul/sdk v0.1.1/go.mod h1:VKf9jXwCTEY1QZP2MOLRhb5i/I/ssyNV1vwHyQBF0x8=
|
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
|
||||||
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
github.com/nicksnyder/go-i18n/v2 v2.5.1 h1:IxtPxYsR9Gp60cGXjfuR/llTqV8aYMsC472zD0D1vHk=
|
||||||
github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80=
|
github.com/nicksnyder/go-i18n/v2 v2.5.1/go.mod h1:DrhgsSDZxoAfvVrBVLXoxZn/pN5TXqaDbq7ju94viiQ=
|
||||||
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/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
|
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/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 h1:hnbDkaNWPCLMO9wGLdBFTIZvzDrDfBM2072E1S9gJkA=
|
||||||
github.com/pkg/profile v1.7.0/go.mod h1:8Uer0jas47ZQMJ7VD+OHknK4YDY07LPUC6dEvqDjvNo=
|
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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
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 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||||
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
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/rymdport/portal v0.4.1 h1:2dnZhjf5uEaeDjeF/yBIeeRo6pNI2QAKm7kq1w/kbnA=
|
||||||
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
|
github.com/rymdport/portal v0.4.1/go.mod h1:kFF4jslnJ8pD5uCi17brj/ODlfIidOxlgUDTO5ncnC4=
|
||||||
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/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiYWEedbTT0qnSxrCjsVbb7yKY1KE=
|
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/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 h1:Ch6Q+AZUxDBCVqdkI8FSpFyZDtCVBc2VmejdNrm5rRQ=
|
||||||
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef/go.mod h1:nXTWP6+gD5+LUJ8krVhhoeHjvHTutPxMYl5SvkcnJNE=
|
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.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
|
||||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
|
||||||
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
|
|
||||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
|
||||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
|
||||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
|
||||||
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
|
||||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
|
||||||
github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw=
|
|
||||||
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
|
||||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
|
||||||
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
|
||||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
|
||||||
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
|
|
||||||
github.com/yuin/goldmark v1.4.0/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
|
|
||||||
github.com/yuin/goldmark v1.7.1 h1:3bajkSilaCbjdKVsKdZjZCLBNPL9pYzrCakKaf4U49U=
|
|
||||||
github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
|
|
||||||
go.etcd.io/etcd/api/v3 v3.5.0/go.mod h1:cbVKeC6lCfl7j/8jBhAK6aIYO9XOjdptoxU/nLQcPvs=
|
|
||||||
go.etcd.io/etcd/client/pkg/v3 v3.5.0/go.mod h1:IJHfcCEKxYu1Os13ZdwCwIUTUVGYTSAM3YSwc9/Ac1g=
|
|
||||||
go.etcd.io/etcd/client/v2 v2.305.0/go.mod h1:h9puh54ZTgAKtEbut2oe9P4L/oqKCVB6xsXlzd7alYQ=
|
|
||||||
go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
|
|
||||||
go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
|
|
||||||
go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
|
|
||||||
go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
|
|
||||||
go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
|
|
||||||
go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk=
|
|
||||||
go.opencensus.io v0.23.0/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E=
|
|
||||||
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
|
|
||||||
go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU=
|
|
||||||
go.uber.org/zap v1.17.0/go.mod h1:MXVU+bhUf/A7Xi2HNOnopQOrmycQ5Ih87HtOu4q5SSo=
|
|
||||||
go.yaml.in/yaml/v4 v4.0.0-rc.5 h1:JVliQq9EGOYaTgMi+k8BhUJyqcGk4ZqeuiN1Cirba9c=
|
go.yaml.in/yaml/v4 v4.0.0-rc.5 h1:JVliQq9EGOYaTgMi+k8BhUJyqcGk4ZqeuiN1Cirba9c=
|
||||||
go.yaml.in/yaml/v4 v4.0.0-rc.5/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
|
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/image v0.24.0 h1:AN7zRgVsbvmTfNyqIbbOraYL8mSwcKncEj8ofjgzcMQ=
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8=
|
||||||
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
|
||||||
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
|
||||||
golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
|
||||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
|
||||||
golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
|
||||||
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=
|
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
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 h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
|
||||||
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
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=
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ set -euo pipefail
|
|||||||
# Optional first argument mirrors build-linux.sh. The Docker build still writes
|
# 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
|
# the final artifact into the local dist/ tree, not into the container. The
|
||||||
# default includes the application version and target platform.
|
# 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}"
|
version="${version:-0.0.0-dev}"
|
||||||
tag="gitea.mixdep.ru/mix/gosentry-builder:${version}"
|
tag="gitea.mixdep.ru/mix/gosentry-builder:${version}"
|
||||||
output="${1:-dist/linux/gosentry-${version}-linux-amd64}"
|
output="${1:-dist/linux/gosentry-${version}-linux-amd64}"
|
||||||
@@ -26,7 +26,7 @@ docker run --rm \
|
|||||||
-v "$(pwd):/src" \
|
-v "$(pwd):/src" \
|
||||||
-w /src \
|
-w /src \
|
||||||
"$tag" \
|
"$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
|
# Icons are embedded in the Go binary, so there is no assets directory to copy
|
||||||
# after extracting the Linux executable.
|
# after extracting the Linux executable.
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ set -euo pipefail
|
|||||||
|
|
||||||
# Optional first argument lets a developer or CI job choose the output path. The
|
# Optional first argument lets a developer or CI job choose the output path. The
|
||||||
# default includes the application version and target platform.
|
# 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}"
|
version="${version:-0.0.0-dev}"
|
||||||
output="${1:-dist/linux/gosentry-${version}-linux-amd64}"
|
output="${1:-dist/linux/gosentry-${version}-linux-amd64}"
|
||||||
mkdir -p "$(dirname "$output")"
|
mkdir -p "$(dirname "$output")"
|
||||||
@@ -17,7 +17,7 @@ export GOARCH=amd64
|
|||||||
|
|
||||||
# -trimpath removes local machine paths from debug/build metadata. -s -w strips
|
# -trimpath removes local machine paths from debug/build metadata. -s -w strips
|
||||||
# symbol/debug tables to keep the desktop binary smaller.
|
# 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
|
# The application icon is embedded by Go, so the Linux build does not need a
|
||||||
# sidecar assets directory beside the executable.
|
# sidecar assets directory beside the executable.
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|||||||
repo_root="$(cd "${script_dir}/.." && pwd)"
|
repo_root="$(cd "${script_dir}/.." && pwd)"
|
||||||
cd "$repo_root"
|
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}"
|
version="${version:-0.0.0-dev}"
|
||||||
tag="gitea.mixdep.ru/mix/gosentry-builder:${version}"
|
tag="gitea.mixdep.ru/mix/gosentry-builder:${version}"
|
||||||
|
|
||||||
@@ -99,15 +99,15 @@ run_in_builder() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
build_linux_amd64() {
|
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() {
|
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() {
|
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]++')
|
mapfile -t targets < <(choose_targets "$@" | normalize_targets | awk '!seen[$0]++')
|
||||||
|
|||||||
@@ -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.
|
REM relative paths such as .\cmd\gosentry and packaging\windows\gosentry.rc.
|
||||||
cd /d "%~dp0\.."
|
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"
|
if "%VERSION%"=="" set "VERSION=0.0.0-dev"
|
||||||
set "VERSION=%VERSION:"=%"
|
set "VERSION=%VERSION:"=%"
|
||||||
|
|
||||||
@@ -47,7 +47,7 @@ if %ERRORLEVEL%==0 (
|
|||||||
REM -trimpath removes local machine paths from the binary, -s -w reduce binary
|
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 size, and -H=windowsgui prevents a separate console window from opening when
|
||||||
REM the GUI app starts from Explorer or a shortcut.
|
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
|
if errorlevel 1 exit /b 1
|
||||||
|
|
||||||
REM Icons are embedded into the executable, so no assets directory is copied next
|
REM Icons are embedded into the executable, so no assets directory is copied next
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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"
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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]
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
package core
|
package app
|
||||||
|
|
||||||
// Version is the application version shown in the GUI and used by build
|
// Version is the application version shown in the GUI and used by build
|
||||||
// scripts in artifact names. It is a var rather than a const so release builds
|
// scripts in artifact names. It is a var rather than a const so release builds
|
||||||
// can override it with Go ldflags when CI tags a build.
|
// can override it with Go ldflags when CI tags a build.
|
||||||
var Version = "0.3.2"
|
var Version = "0.4.0"
|
||||||
@@ -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"
|
|
||||||
}
|
|
||||||
@@ -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"`
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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"`
|
||||||
|
}
|
||||||
@@ -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"`
|
||||||
|
}
|
||||||
@@ -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"`
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
|
//go:build linux
|
||||||
|
|
||||||
package core
|
package autostart
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -9,8 +9,23 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"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 autostartDesktopFileName = "gosentry.desktop"
|
||||||
const legacyAutostartDesktopFileName = "pysentry.desktop"
|
const legacyAutostartDesktopFileName = "pysentry.desktop"
|
||||||
|
|
||||||
@@ -43,7 +58,7 @@ Exec=%s %s
|
|||||||
%s
|
%s
|
||||||
Terminal=false
|
Terminal=false
|
||||||
X-GNOME-Autostart-enabled=true
|
X-GNOME-Autostart-enabled=true
|
||||||
`, quoteDesktopExec(executablePath), StartInTrayArgument, desktopIconLine(iconPath))
|
`, quoteDesktopExec(executablePath), domain.StartInTrayArgument, desktopIconLine(iconPath))
|
||||||
return os.WriteFile(desktopPath, []byte(desktopFile), 0o644)
|
return os.WriteFile(desktopPath, []byte(desktopFile), 0o644)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,7 +90,7 @@ func AutostartStatus(expectedEnabled bool, executablePath string) (bool, string)
|
|||||||
if readErr != nil {
|
if readErr != nil {
|
||||||
return false, "Autostart desktop entry is missing"
|
return false, "Autostart desktop entry is missing"
|
||||||
}
|
}
|
||||||
expectedExec := "Exec=" + quoteDesktopExec(executablePath) + " " + StartInTrayArgument
|
expectedExec := "Exec=" + quoteDesktopExec(executablePath) + " " + domain.StartInTrayArgument
|
||||||
if !strings.Contains(string(data), expectedExec) {
|
if !strings.Contains(string(data), expectedExec) {
|
||||||
return false, "Autostart desktop entry points to another executable"
|
return false, "Autostart desktop entry points to another executable"
|
||||||
}
|
}
|
||||||
@@ -1,12 +1,14 @@
|
|||||||
//go:build linux
|
//go:build linux
|
||||||
|
|
||||||
package core
|
package autostart
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"gitea.mixdep.ru/mix/gosentry/src/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestLinuxAutostartStartsInTray(t *testing.T) {
|
func TestLinuxAutostartStartsInTray(t *testing.T) {
|
||||||
@@ -26,7 +28,7 @@ func TestLinuxAutostartStartsInTray(t *testing.T) {
|
|||||||
t.Fatalf("read desktop entry: %v", err)
|
t.Fatalf("read desktop entry: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
expectedExec := "Exec=" + quoteDesktopExec(executablePath) + " " + StartInTrayArgument
|
expectedExec := "Exec=" + quoteDesktopExec(executablePath) + " " + domain.StartInTrayArgument
|
||||||
if !strings.Contains(string(data), expectedExec) {
|
if !strings.Contains(string(data), expectedExec) {
|
||||||
t.Fatalf("desktop entry does not start in tray: %s", data)
|
t.Fatalf("desktop entry does not start in tray: %s", data)
|
||||||
}
|
}
|
||||||
@@ -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 (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -6,8 +6,24 @@ import (
|
|||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"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 autostartName = "GoSentry"
|
||||||
const legacyAutostartName = "PySentry"
|
const legacyAutostartName = "PySentry"
|
||||||
const startupShortcutFile = autostartName + ".lnk"
|
const startupShortcutFile = autostartName + ".lnk"
|
||||||
@@ -69,7 +85,7 @@ func AutostartStatus(expectedEnabled bool, executablePath string) (bool, string)
|
|||||||
if !sameWindowsPath(actual, executablePath) {
|
if !sameWindowsPath(actual, executablePath) {
|
||||||
return false, "Autostart shortcut points to another executable"
|
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 false, "Autostart shortcut does not start in tray"
|
||||||
}
|
}
|
||||||
return true, "Autostart is configured"
|
return true, "Autostart is configured"
|
||||||
@@ -101,11 +117,11 @@ func createStartupShortcut(shortcutPath string, executablePath string, iconPath
|
|||||||
command.Env = append(os.Environ(),
|
command.Env = append(os.Environ(),
|
||||||
"GOSENTRY_SHORTCUT_PATH="+shortcutPath,
|
"GOSENTRY_SHORTCUT_PATH="+shortcutPath,
|
||||||
"GOSENTRY_TARGET_PATH="+executablePath,
|
"GOSENTRY_TARGET_PATH="+executablePath,
|
||||||
"GOSENTRY_ARGUMENTS="+StartInTrayArgument,
|
"GOSENTRY_ARGUMENTS="+domain.StartInTrayArgument,
|
||||||
"GOSENTRY_WORKING_DIRECTORY="+workingDirectory,
|
"GOSENTRY_WORKING_DIRECTORY="+workingDirectory,
|
||||||
"GOSENTRY_ICON_PATH="+iconPath,
|
"GOSENTRY_ICON_PATH="+iconPath,
|
||||||
)
|
)
|
||||||
configureHiddenWindow(command)
|
winproc.ConfigureHiddenWindow(command)
|
||||||
if output, err := command.CombinedOutput(); err != nil {
|
if output, err := command.CombinedOutput(); err != nil {
|
||||||
return fmt.Errorf("create startup shortcut: %w: %s", err, strings.TrimSpace(string(output)))
|
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)`
|
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 := exec.Command("powershell.exe", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", script)
|
||||||
command.Env = append(os.Environ(), "GOSENTRY_SHORTCUT_PATH="+shortcutPath)
|
command.Env = append(os.Environ(), "GOSENTRY_SHORTCUT_PATH="+shortcutPath)
|
||||||
configureHiddenWindow(command)
|
winproc.ConfigureHiddenWindow(command)
|
||||||
output, err := command.CombinedOutput()
|
output, err := command.CombinedOutput()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", fmt.Errorf("read startup shortcut: %w: %s", err, strings.TrimSpace(string(output)))
|
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 {
|
func cleanupLegacyRegistryAutostart() error {
|
||||||
for _, name := range []string{legacyAutostartName, autostartName} {
|
for _, name := range []string{legacyAutostartName, autostartName} {
|
||||||
command := exec.Command("reg.exe", "delete", `HKCU\Software\Microsoft\Windows\CurrentVersion\Run`, "/v", name, "/f")
|
command := exec.Command("reg.exe", "delete", `HKCU\Software\Microsoft\Windows\CurrentVersion\Run`, "/v", name, "/f")
|
||||||
configureHiddenWindow(command)
|
winproc.ConfigureHiddenWindow(command)
|
||||||
_ = command.Run()
|
_ = command.Run()
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -162,7 +178,7 @@ func cleanupLegacyRegistryAutostart() error {
|
|||||||
func legacyRegistryAutostartExists() bool {
|
func legacyRegistryAutostartExists() bool {
|
||||||
for _, name := range []string{legacyAutostartName, autostartName} {
|
for _, name := range []string{legacyAutostartName, autostartName} {
|
||||||
command := exec.Command("reg.exe", "query", `HKCU\Software\Microsoft\Windows\CurrentVersion\Run`, "/v", name)
|
command := exec.Command("reg.exe", "query", `HKCU\Software\Microsoft\Windows\CurrentVersion\Run`, "/v", name)
|
||||||
configureHiddenWindow(command)
|
winproc.ConfigureHiddenWindow(command)
|
||||||
if command.Run() == nil {
|
if command.Run() == nil {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
+7
-5
@@ -1,12 +1,14 @@
|
|||||||
//go:build windows
|
//go:build windows
|
||||||
|
|
||||||
package core
|
package autostart
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"syscall"
|
"syscall"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"gitea.mixdep.ru/mix/gosentry/src/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestParseRegistryRunValue(t *testing.T) {
|
func TestParseRegistryRunValue(t *testing.T) {
|
||||||
@@ -111,8 +113,8 @@ func TestCreateStartupShortcutHandlesCyrillicPath(t *testing.T) {
|
|||||||
if !sameWindowsPath(actual, targetPath) {
|
if !sameWindowsPath(actual, targetPath) {
|
||||||
t.Fatalf("shortcut target mismatch: got %q want %q", actual, targetPath)
|
t.Fatalf("shortcut target mismatch: got %q want %q", actual, targetPath)
|
||||||
}
|
}
|
||||||
if arguments != StartInTrayArgument {
|
if arguments != domain.StartInTrayArgument {
|
||||||
t.Fatalf("shortcut arguments mismatch: got %q want %q", arguments, 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) {
|
if !sameWindowsPath(actual, targetPath) {
|
||||||
t.Fatalf("shortcut target mismatch: got %q want %q", actual, targetPath)
|
t.Fatalf("shortcut target mismatch: got %q want %q", actual, targetPath)
|
||||||
}
|
}
|
||||||
if arguments != StartInTrayArgument {
|
if arguments != domain.StartInTrayArgument {
|
||||||
t.Fatalf("shortcut arguments mismatch: got %q want %q", arguments, StartInTrayArgument)
|
t.Fatalf("shortcut arguments mismatch: got %q want %q", arguments, domain.StartInTrayArgument)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
//go:build linux
|
//go:build linux
|
||||||
|
|
||||||
package core
|
package desktop
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
)
|
)
|
||||||
|
|
||||||
func InstallDesktopIntegration(appID string, executablePath string, icon []byte) (string, error) {
|
func InstallDesktopIntegration(appID string, executablePath string, icon []byte) (string, error) {
|
||||||
@@ -40,6 +41,10 @@ StartupWMClass=%s
|
|||||||
return iconPath, nil
|
return iconPath, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func quoteDesktopExec(path string) string {
|
||||||
|
return strconv.Quote(path)
|
||||||
|
}
|
||||||
|
|
||||||
func xdgDataHome() (string, error) {
|
func xdgDataHome() (string, error) {
|
||||||
dataHome := os.Getenv("XDG_DATA_HOME")
|
dataHome := os.Getenv("XDG_DATA_HOME")
|
||||||
if dataHome == "" {
|
if dataHome == "" {
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
//go:build !linux
|
//go:build !linux
|
||||||
|
|
||||||
package core
|
package desktop
|
||||||
|
|
||||||
func InstallDesktopIntegration(appID string, executablePath string, icon []byte) (string, error) {
|
func InstallDesktopIntegration(appID string, executablePath string, icon []byte) (string, error) {
|
||||||
return "", nil
|
return "", nil
|
||||||
@@ -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) {}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) }
|
||||||
@@ -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
|
//go:build !windows
|
||||||
|
|
||||||
package core
|
package runner
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"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.
|
// and avoids a hard dependency on a larger shell such as bash.
|
||||||
return exec.CommandContext(ctx, "sh", "-c", command)
|
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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
@@ -56,14 +56,3 @@ func startsWithWindowsRootedPath(command string) bool {
|
|||||||
command[1] == ':' &&
|
command[1] == ':' &&
|
||||||
(command[2] == '\\' || command[2] == '/')
|
(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
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
@@ -7,11 +7,155 @@ import (
|
|||||||
"runtime"
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"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) {
|
func TestRunJobWritesLogFile(t *testing.T) {
|
||||||
logsDir := t.TempDir()
|
logsDir := t.TempDir()
|
||||||
job := Job{
|
job := domain.Job{
|
||||||
ID: 42,
|
ID: 42,
|
||||||
Name: "Hello Test",
|
Name: "Hello Test",
|
||||||
Command: echoCommand("hello from test"),
|
Command: echoCommand("hello from test"),
|
||||||
@@ -46,7 +190,7 @@ func TestRunJobRunsQuotedWindowsExecutable(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
logsDir := t.TempDir()
|
logsDir := t.TempDir()
|
||||||
job := Job{
|
job := domain.Job{
|
||||||
ID: 43,
|
ID: 43,
|
||||||
Name: "Quoted Windows Command",
|
Name: "Quoted Windows Command",
|
||||||
Command: `"C:\Windows\System32\cmd.exe" /C echo quoted command ok`,
|
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 {
|
if err := os.WriteFile(scriptPath, []byte("@echo off\r\necho unquoted command ok\r\n"), 0o755); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
job := Job{
|
job := domain.Job{
|
||||||
ID: 44,
|
ID: 44,
|
||||||
Name: "Unquoted Windows Command",
|
Name: "Unquoted Windows Command",
|
||||||
Command: scriptPath,
|
Command: scriptPath,
|
||||||
@@ -96,7 +240,7 @@ func TestRunJobRunsWindowsCommandWithSeparateArguments(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
logsDir := t.TempDir()
|
logsDir := t.TempDir()
|
||||||
job := Job{
|
job := domain.Job{
|
||||||
ID: 45,
|
ID: 45,
|
||||||
Name: "Separate Arguments",
|
Name: "Separate Arguments",
|
||||||
Command: `C:\Windows\System32\cmd.exe`,
|
Command: `C:\Windows\System32\cmd.exe`,
|
||||||
@@ -117,7 +261,7 @@ func TestRunJobAcceptsConfiguredExitCode(t *testing.T) {
|
|||||||
if runtime.GOOS == "windows" {
|
if runtime.GOOS == "windows" {
|
||||||
command = `C:\Windows\System32\cmd.exe`
|
command = `C:\Windows\System32\cmd.exe`
|
||||||
}
|
}
|
||||||
job := Job{
|
job := domain.Job{
|
||||||
ID: 46,
|
ID: 46,
|
||||||
Name: "Accepted Exit Code",
|
Name: "Accepted Exit Code",
|
||||||
Command: command,
|
Command: command,
|
||||||
@@ -141,7 +285,7 @@ func TestRunJobRejectsUnconfiguredExitCode(t *testing.T) {
|
|||||||
if runtime.GOOS == "windows" {
|
if runtime.GOOS == "windows" {
|
||||||
command = `C:\Windows\System32\cmd.exe`
|
command = `C:\Windows\System32\cmd.exe`
|
||||||
}
|
}
|
||||||
job := Job{
|
job := domain.Job{
|
||||||
ID: 47,
|
ID: 47,
|
||||||
Name: "Rejected Exit Code",
|
Name: "Rejected Exit Code",
|
||||||
Command: command,
|
Command: command,
|
||||||
@@ -167,7 +311,7 @@ func TestRunJobStartOnlyDoesNotWaitForExitCode(t *testing.T) {
|
|||||||
command = `C:\Windows\System32\cmd.exe`
|
command = `C:\Windows\System32\cmd.exe`
|
||||||
arguments = "/C\nexit /b 7"
|
arguments = "/C\nexit /b 7"
|
||||||
}
|
}
|
||||||
job := Job{
|
job := domain.Job{
|
||||||
ID: 48,
|
ID: 48,
|
||||||
Name: "Start Only",
|
Name: "Start Only",
|
||||||
Command: command,
|
Command: command,
|
||||||
@@ -188,7 +332,7 @@ func TestRunJobStartOnlyDoesNotWaitForExitCode(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestRunJobStartOnlyReportsStartFailure(t *testing.T) {
|
func TestRunJobStartOnlyReportsStartFailure(t *testing.T) {
|
||||||
job := Job{
|
job := domain.Job{
|
||||||
ID: 49,
|
ID: 49,
|
||||||
Name: "Missing Start Only",
|
Name: "Missing Start Only",
|
||||||
Command: "definitely-missing-gosentry-command",
|
Command: "definitely-missing-gosentry-command",
|
||||||
@@ -223,7 +367,7 @@ func TestDirectCommandDoesNotHideWindow(t *testing.T) {
|
|||||||
t.Skip("Windows window visibility only")
|
t.Skip("Windows window visibility only")
|
||||||
}
|
}
|
||||||
|
|
||||||
invocation := jobInvocation(context.Background(), Job{
|
invocation := jobInvocation(context.Background(), domain.Job{
|
||||||
Command: `C:\Windows\System32\cmd.exe`,
|
Command: `C:\Windows\System32\cmd.exe`,
|
||||||
Arguments: "/C\necho visible direct process",
|
Arguments: "/C\necho visible direct process",
|
||||||
})
|
})
|
||||||
@@ -237,11 +381,11 @@ func TestShellCommandHidesWindow(t *testing.T) {
|
|||||||
t.Skip("Windows window visibility only")
|
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 {
|
if !invocation.hideWindow {
|
||||||
t.Fatal("shell command should request hidden startup window")
|
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 {
|
if invocation.command.SysProcAttr == nil || !invocation.command.SysProcAttr.HideWindow {
|
||||||
t.Fatal("expected shell command to be hidden")
|
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"`)
|
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""`
|
want := `cmd.exe /S /C ""C:\Program Files\FreeFileSync\FreeFileSync.exe" "D:\Local\Programs\FreeFileSync\Jobs\Auto.ffs_batch""`
|
||||||
if command.SysProcAttr == nil {
|
if command.SysProcAttr == nil {
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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,4 +1,4 @@
|
|||||||
package core
|
package storage
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package core
|
package storage
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
@@ -7,15 +7,16 @@ import (
|
|||||||
"runtime"
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"gitea.mixdep.ru/mix/gosentry/src/domain"
|
||||||
"go.yaml.in/yaml/v4"
|
"go.yaml.in/yaml/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Store struct {
|
type Store struct {
|
||||||
Paths Paths
|
Paths Paths
|
||||||
Config Config
|
Config domain.Config
|
||||||
}
|
}
|
||||||
|
|
||||||
func OpenStore() (*Store, []Job, error) {
|
func OpenStore() (*Store, []domain.Job, error) {
|
||||||
paths, err := ResolvePaths()
|
paths, err := ResolvePaths()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
@@ -57,17 +58,17 @@ func (s *Store) SaveConfig() error {
|
|||||||
return writeYAML(s.Paths.ConfigPath, s.Config)
|
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 {
|
if err := os.MkdirAll(s.Paths.JobsDir, 0o755); err != nil {
|
||||||
return err
|
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
|
// Defaults favor a portable installation: settings and jobs begin next to the
|
||||||
// executable, while logs are grouped under a dedicated subdirectory.
|
// executable, while logs are grouped under a dedicated subdirectory.
|
||||||
config := Config{
|
config := domain.Config{
|
||||||
JobsDir: ".",
|
JobsDir: ".",
|
||||||
LogsDir: "logs",
|
LogsDir: "logs",
|
||||||
MaxLogFiles: 100,
|
MaxLogFiles: 100,
|
||||||
@@ -98,10 +99,10 @@ func loadOrCreateConfig(paths Paths) (Config, error) {
|
|||||||
|
|
||||||
data, err := os.ReadFile(configPath)
|
data, err := os.ReadFile(configPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Config{}, err
|
return domain.Config{}, err
|
||||||
}
|
}
|
||||||
if err := yaml.Unmarshal(data, &config); err != nil {
|
if err := yaml.Unmarshal(data, &config); err != nil {
|
||||||
return Config{}, err
|
return domain.Config{}, err
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(config.JobsDir) == "" {
|
if strings.TrimSpace(config.JobsDir) == "" {
|
||||||
// Empty paths are treated as missing values rather than intentional root
|
// Empty paths are treated as missing values rather than intentional root
|
||||||
@@ -120,27 +121,27 @@ func loadOrCreateConfig(paths Paths) (Config, error) {
|
|||||||
return config, nil
|
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) {
|
if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
|
||||||
// The first run creates harmless sample jobs so a new user can immediately
|
// The first run creates harmless sample jobs so a new user can immediately
|
||||||
// see scheduled and manual execution without inventing a command.
|
// see scheduled and manual execution without inventing a command.
|
||||||
jobs := defaultJobs()
|
jobs := defaultJobs()
|
||||||
normalizeJobs(jobs)
|
normalizeJobs(jobs)
|
||||||
return jobs, writeYAML(path, JobsFile{Jobs: jobs})
|
return jobs, writeYAML(path, domain.JobsFile{Jobs: jobs})
|
||||||
}
|
}
|
||||||
|
|
||||||
data, err := os.ReadFile(path)
|
data, err := os.ReadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
var file JobsFile
|
var file domain.JobsFile
|
||||||
if err := yaml.Unmarshal(data, &file); err != nil {
|
if err := yaml.Unmarshal(data, &file); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return file.Jobs, nil
|
return file.Jobs, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func normalizeJobs(jobs []Job) {
|
func normalizeJobs(jobs []domain.Job) {
|
||||||
next := 1
|
next := 1
|
||||||
for index := range jobs {
|
for index := range jobs {
|
||||||
job := &jobs[index]
|
job := &jobs[index]
|
||||||
@@ -168,23 +169,9 @@ func normalizeJobs(jobs []Job) {
|
|||||||
if job.SuccessExitCodes == "" {
|
if job.SuccessExitCodes == "" {
|
||||||
job.SuccessExitCodes = "0"
|
job.SuccessExitCodes = "0"
|
||||||
}
|
}
|
||||||
if job.LastRun == "" {
|
// Runtime state (last run, next run, status, output, activity) is no longer
|
||||||
job.LastRun = "Never"
|
// part of Job. It is reconstructed each time the app starts via
|
||||||
}
|
// domain.NewRuntime, so normalizeJobs only touches durable configuration.
|
||||||
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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,8 +209,8 @@ func writeYAML(path string, value any) error {
|
|||||||
return os.WriteFile(path, data, 0o644)
|
return os.WriteFile(path, data, 0o644)
|
||||||
}
|
}
|
||||||
|
|
||||||
func defaultJobs() []Job {
|
func defaultJobs() []domain.Job {
|
||||||
return []Job{
|
return []domain.Job{
|
||||||
{
|
{
|
||||||
ID: 1,
|
ID: 1,
|
||||||
Name: "Hello scheduler",
|
Name: "Hello scheduler",
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user