diff --git a/README.md b/README.md index 07cdc7f..37636b4 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ Project notes: - [Changelog](docs/CHANGELOG.md) - [Roadmap](docs/ROADMAP.md) - [Architecture](docs/ARCHITECTURE.md) +- [Refactoring plan](docs/REFACTORING.md) ## Features @@ -351,14 +352,21 @@ Windows: ## Project Layout -- `cmd/gosentry` starts the desktop app. -- `src/gui` contains the GUI. -- `src/core` contains YAML storage, command execution, scheduling, and log cleanup. -- `assets` contains app icons that are embedded into the application binary. -- `scripts` contains build helpers. -- `docs` contains architecture notes, the changelog, and the roadmap. +- `cmd/gosentry` — entry point; starts the desktop app. +- `src/domain` — pure value types: `Job`, `Config`, `RunRecord`, `Schedule`, `JobRuntime`. +- `src/app` — `Service`: sole owner of job and runtime state; emits typed events to the UI. +- `src/scheduler` — pure timing loop; calls `Service.RunDue` on every tick. +- `src/runner` — shell command execution, log file writing, and log cleanup. +- `src/storage` — YAML persistence (`gosentry.yaml`, `jobs.yaml`). +- `src/platform/autostart` — `Manager` interface with Windows (shortcut) and Linux (XDG) implementations. +- `src/platform/desktop` — display-scale helper (Linux only). +- `src/platform/winproc` — hidden-window startup flags (Windows only). +- `src/ui` — Fyne windows, tabs, and dialogs; reads service state through events. +- `assets` — app icons embedded into the application binary. +- `scripts` — build helpers. +- `docs` — architecture notes, changelog, and roadmap. -Build outputs are written to `dist/`. The old local `bin/` directory is not used. +Build outputs are written to `dist/`. ## Dependencies diff --git a/cmd/gosentry/main.go b/cmd/gosentry/main.go index ab6b23d..d4cfee9 100644 --- a/cmd/gosentry/main.go +++ b/cmd/gosentry/main.go @@ -3,15 +3,15 @@ package main import ( "os" - "gitea.mixdep.ru/mix/gosentry/src/core" - "gitea.mixdep.ru/mix/gosentry/src/gui" + "gitea.mixdep.ru/mix/gosentry/src/domain" + "gitea.mixdep.ru/mix/gosentry/src/ui" ) func main() { // The executable entry point intentionally delegates all startup work to the - // GUI package. Keeping main small makes it easier to add platform-specific + // UI package. Keeping main small makes it easier to add platform-specific // packaging later without mixing window setup, storage, and scheduler logic. - gui.Run(hasArgument(core.StartInTrayArgument)) + ui.Run(hasArgument(domain.StartInTrayArgument)) } func hasArgument(argument string) bool { diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 7d757b8..67e6fa5 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,73 +1,104 @@ # GoSentry Architecture -This document shows the current component interaction model. GoSentry is still a -single desktop process: the GUI, scheduler, storage, and command runner live in -one application and communicate through Go function calls and shared in-memory -job state. +This document shows the current component interaction model. GoSentry is a +single desktop process: the GUI, application service, scheduler, storage, and +command runner live in one application. They communicate through typed events +and well-defined interfaces rather than shared mutable state. + +## Package Map + +``` +cmd/gosentry entry point — starts the UI +src/ + domain/ pure value types: Job, Config, RunRecord, Schedule, JobRuntime + app/ Service — sole owner of job/runtime state; emits typed Events + scheduler/ pure timing loop; calls app.Service.RunDue on every tick + runner/ shell command execution + log file writing + cleanup + storage/ YAML persistence (gosentry.yaml, jobs.yaml) + platform/ + autostart/ Manager interface + Windows (shortcut) and Linux (XDG) impls + desktop/ display-scale helper (Linux only) + winproc/ hidden-window startup flags (Windows only) + ui/ Fyne windows, tabs, and dialogs; reads service via Events +``` ## Component Diagram ```mermaid flowchart LR user["Desktop user"] - gui["src/gui - Fyne windows, tabs, dialogs"] - store["src/core Store - YAML config and jobs"] - scheduler["src/core Scheduler - @every and cron timing"] - runner["src/core Runner - shell command execution"] - autostart["src/core Autostart - Windows Startup shortcut / Linux desktop startup"] - config["gosentry.yaml - application settings"] - jobs["jobs.yaml - job definitions"] - logs["logs_dir - per-run command output logs"] - shell["Platform shell - cmd.exe /C or sh -c"] + ui["src/ui\nFyne windows, tabs, dialogs"] + svc["src/app Service\nsole owner of job + runtime state"] + store["src/storage Store\nYAML config and jobs"] + sched["src/scheduler Scheduler\npure timing loop"] + runner["src/runner\nshell command execution"] + autostart["src/platform/autostart Manager\nWindows shortcut / Linux XDG"] + config["gosentry.yaml\napplication settings"] + jobs["jobs.yaml\njob definitions"] + logs["logs_dir\nper-run command output logs"] + shell["Platform shell\ncmd.exe /C or sh -c"] - user -->|"edits jobs, settings, runs commands"| gui - gui -->|"OpenStore, SaveConfig, SaveJobs"| store + user -->|"edits jobs, settings, runs commands"| ui + ui -->|"CreateJob, UpdateJob, DeleteJob, RunNow, UpdateSettings, …"| svc + svc -->|"SaveJobs, SaveConfig, LoadJobs, LoadConfig"| store store -->|"read/write"| config store -->|"read/write"| jobs - gui -->|"Start, Pause, RunNow, RefreshSchedule"| scheduler - scheduler -->|"SaveJobs after state changes"| store - scheduler -->|"RunJob(trigger)"| runner + svc -->|"Start(RunDue)"| sched + sched -->|"RunDue(now)"| svc + svc -->|"RunJob"| runner runner -->|"execute command"| shell runner -->|"write stdout/stderr log"| logs - runner -->|"RunRecord with status, duration, log path"| scheduler - scheduler -->|"onChange RunRecord"| gui - gui -->|"display History, command output, job state"| user + runner -->|"RunRecord"| svc + svc -->|"emit JobChanged / RunRecorded / ErrorOccurred"| ui + ui -->|"display jobs, history, status"| user - gui -->|"SetAutostart, AutostartStatus"| autostart - autostart -->|"use executable path from resolved Paths"| config + ui -->|"SetAutostart, AutostartStatus"| autostart + svc -->|"Set / Status via Manager"| autostart ``` ## Main Flows 1. Startup: - The executable starts `cmd/gosentry`, which calls the GUI package. The GUI - opens the store, loads `gosentry.yaml` and `jobs.yaml`, creates the main tabs, - then starts the scheduler with the loaded job slice. + `cmd/gosentry` calls `ui.Run`, which creates an `app.Service`, opens the + store, loads `gosentry.yaml` and `jobs.yaml`, subscribes the UI to service + events, builds the main window, and calls `Service.Start` to begin the + scheduler loop. 2. Editing settings or jobs: - The GUI updates the in-memory job/config state and asks `Store` to write YAML - back to disk. Job definitions stay in one `jobs.yaml`; runtime command output - is not stored there. + The UI calls mutating methods on `app.Service` (e.g. `CreateJob`, + `UpdateJob`, `UpdateSettings`). The Service validates the request, updates + its in-memory state, persists through `storage.Store`, and emits a typed + `Event`. The UI's observer receives the event and refreshes the relevant + widget on the main thread via `fyne.Do`. 3. Scheduled run: - `Scheduler` checks due jobs on a one-second ticker. When a job is due, it marks - the job as running, saves state, and starts `Runner` asynchronously. + `scheduler.Scheduler` fires a tick every second. On each tick it calls + `Service.RunDue(now)`. The Service checks which enabled, non-paused jobs are + due, marks each as running, and launches `runner.RunJob` in a goroutine. 4. Manual run: - `Run now` calls the same scheduler path as scheduled execution, but the - resulting history record uses the `Manual` trigger. + `Run now` in the UI calls `Service.RunNow`. The Service checks that the job + exists, is not already running, and that the scheduler is not paused, then + executes `runner.RunJob` with the `Manual` trigger. 5. Command execution: - `Runner` executes the command through the platform shell, captures stdout and - stderr, writes one timestamped `.log` file, and returns a `RunRecord`. + `runner.RunJob` builds the platform-specific invocation, executes the + command through the platform shell, captures stdout and stderr, writes one + timestamped `.log` file, and returns a `domain.RunRecord`. 6. History update: - The scheduler receives the `RunRecord`, updates the matching job, saves YAML, - runs log cleanup, and calls the GUI callback so the `History` tab refreshes. + When a run goroutine completes, `Service` updates the job's runtime, saves + YAML, triggers log cleanup, and emits `RunRecorded`. The UI observer appends + the record to the History tab. 7. Autostart: - The Settings tab calls the platform autostart implementation. Windows uses a - shortcut in the current user's Startup folder. Linux uses a desktop-session - startup entry. Both autostart mechanisms pass `--start-in-tray`, so the - scheduler starts without opening the main window after sign-in. + `UpdateSettings` in the Service calls `autostart.Manager.Set`. The Manager + interface has two implementations: Windows writes a `.lnk` shortcut to the + user Startup folder; Linux writes an XDG Autostart `.desktop` file. Both + entries pass `--start-in-tray`. + +8. Error surfacing: + Background errors (failed YAML saves, cleanup errors) are emitted as + `ErrorOccurred` events and displayed in the UI status area, rather than + being silently discarded. diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index ffaa0d1..bca4444 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -2,6 +2,68 @@ All notable GoSentry changes are recorded in this file. +## 0.4.0 - 2026-06-22 + +**Architectural milestone: completed refactoring and reached target architecture.** + +- Completed Phase 5 refactoring: hardening, testing, and documentation. + - Surface all save/cleanup errors from service and storage; no more silently swallowed `_ = ...` on persistence. + - Introduced `platform/autostart.Manager` interface with per-platform implementations (Windows, Linux, other); inject into service instead of calling package functions. + - Filled test gaps: folder filtering, log cleanup (count and age), settings persistence and migration, concurrent run prevention. + - Verified `go test -race ./...` passes on Windows; no data races in the refactored codebase. + - Updated `docs/ARCHITECTURE.md`, `docs/TESTS.md`, and README with final package structure and build/test instructions. +- **Refactoring target reached:** Service layer owns all state and is the sole writer; UI is a thin view marshaling updates via `fyne.Do`; core engines are stateless and injectable; domain layer is pure with no test noise. +- Known follow-ups recorded in `ROADMAP.md`: + - Linux test build is currently broken (Windows-only test symbols need `//go:build windows`); will fix separately. + - File-size soft limits exceeded in a few places; revisit when next editing those files. +- No observable behavior changes. + +## 0.3.6 - 2026-06-22 + +- Completed Phase 4 refactoring: carved up the GUI into focused, testable components. + - Renamed `src/gui` → `src/ui` and split monolithic `app.go` into `run.go` (lifecycle) and `mainwindow.go` (view construction). + - Extracted view components into separate files: `jobs_view.go`, `job_dialog.go`, `history_view.go`, `settings_view.go`. + - Extracted platform wiring into separate files: `tray.go`, `singleinstance.go`, `layout.go`. + - Removed forbidden platform imports (autostart, desktop, paths) from `src/ui`; all platform concerns now flow through `app.Service`. + - Upgraded Fyne from v2.5.3 to v2.6.3 to enable `fyne.Do` for cross-thread widget marshaling (resolves concurrency issue #4). +- Added `docs/PERFORMANCE.md` with measured startup-time analysis: the ~290ms increase from Phase 4 is entirely the Fyne 2.6.3 upgrade's `w.Show()` cost, not the restructuring. +- Added `docs/PERFORMANCE.md` and wired post-Fyne-2.7.x re-check into `ROADMAP.md`. +- No observable behavior changes; continued internal refactoring toward separated concerns and testability. + +## 0.3.5 - 2026-06-19 + +- Completed Phase 3 refactoring: application service and state management. + - Added `app.Service` as the single owner of application state (job registry, settings, run history). + - Implemented event-driven observer dispatch: Services can emit events (JobAdded, JobChanged, etc.) to decouple state changes from UI updates. + - Added `app.Clock` interface for testable time-dependent behavior in scheduler and run tracking. + - Converted scheduler to drive app.Service instead of directly managing domain state. + - Created `app.Format` helpers for display rendering (job names, schedule summaries, run times). + - Added comprehensive unit tests for app.Service and supporting types. +- No observable behavior changes; continued internal refactoring toward separated concerns and testability. + +## 0.3.4 - 2026-06-19 + +- Completed Phase 2 refactoring: domain cleanup and value object extraction. + - Split durable job configuration (`domain.Job`) from transient execution state (`domain.JobRuntime`), keyed by job ID. + - Added `domain.Schedule` value object with `Parse`, `Validate`, and `Next(time.Time)` methods for cron/interval parsing. + - Migrated scheduler to parse schedules once at load/edit instead of per tick, removing duplicated parsing. + - Made `RunJob` pure: runner no longer mutates jobs, returning only `RunRecord` for the caller to fold into runtime state. + - Simplified `storage.normalizeJobs` to touch only durable configuration; runtime initialization moved to `domain.NewRuntime`. +- No observable behavior changes; continued internal refactoring toward separated concerns. + +## 0.3.3 - 2026-06-18 + +- Completed Phase 1 refactoring: split the flat `src/core` package into specialized, focused packages: + - `src/domain` for pure types (Job, RunRecord, Config) + - `src/storage` for persistence (Load/Save, Paths, YAML helpers) + - `src/runner` for job execution (RunJob orchestration, logging, exit codes) + - `src/scheduler` for timing loop + - `src/platform/winproc` for cross-platform hidden window configuration + - `src/platform/autostart` for system autostart integration + - `src/platform/desktop` for desktop environment integration + - `src/app` for application-level code (Version, future Service layer) +- No observable behavior changes; internal structure improvements only. + ## 0.3.1 - 2026-06-17 - Changed startup timing in History to measure until the main window is actually shown instead of stopping during UI construction. diff --git a/docs/PERFORMANCE.md b/docs/PERFORMANCE.md new file mode 100644 index 0000000..c6d1350 --- /dev/null +++ b/docs/PERFORMANCE.md @@ -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. diff --git a/docs/REFACTORING.md b/docs/REFACTORING.md new file mode 100644 index 0000000..5e21a79 --- /dev/null +++ b/docs/REFACTORING.md @@ -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. | diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index b376707..93b1eed 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -2,6 +2,32 @@ This file tracks planned GoSentry work that is larger than a single bug fix. +## Refactoring Follow-Ups + +Loose ends found while verifying the [refactoring plan](REFACTORING.md) against +its Definition of done. The architecture target is reached and verified on +Windows, but the items below remain. + +- **Linux test build is broken (correctness, not cosmetic).** `src/runner/runner_test.go` + is a shared (untagged) test file that references Windows-only symbols + (`SysProcAttr.HideWindow`, `SysProcAttr.CmdLine`, `windowsShellCommandLine`). + The `runtime.GOOS != "windows"` guards are runtime skips and cannot save a file + that does not *compile*, so `go test ./...` fails to build on Linux. This + contradicts T5.4 ("go test -race clean on both platforms") and the DoD's + "green on Windows and Linux." Fix: move the Windows-only tests + (`TestShellCommandHidesWindow`, `TestShellCommandUsesWindowsSafeQuoting`, and any + peers touching `SysProcAttr` / `windowsShellCommandLine`) into a new + `src/runner/runner_windows_test.go` guarded by `//go:build windows`. +- **File-size guidelines exceeded.** The DoD asks for no `src/ui` file over ~250 + lines and no single file over ~400: + - `src/ui/jobs_view.go` — 415 lines (over both the ~250 UI target and the ~400 cap). + - `src/app/operations.go` — 486 lines (over ~400). + - `src/app/operations_test.go` (536) and `src/runner/runner_test.go` (421) also + exceed 400 if the cap is read to include test files. + + These are soft ("~") limits; revisit when next touching those files rather than + splitting purely for line count. + ## Post-Field-Test Cleanup After real-world use confirms the main workflows, clean up temporary @@ -24,13 +50,25 @@ Cleanup checklist: ## Tray Interaction -Improve tray icon interaction after choosing a tray backend path. +Improve tray icon interaction: click the tray icon to show and focus the main +window. -- Add double-click on the tray icon to show and focus the main window. -- Current Fyne 2.5.3 desktop tray API exposes menu and icon setup, but does not - expose click or double-click callbacks for the tray icon itself. -- Revisit when Fyne exposes this callback, or evaluate a small platform-specific - tray integration if the behavior becomes important enough. +- Unblocked by Fyne 2.7.0, which added `desktop.App.SetSystemTrayWindow(window)`. + On Windows, macOS, and most Linux it shows the associated window on left-click; + any tray menu then moves to right-click. There is still no raw click / + double-click callback, so the behavior is single left-click (the conventional + tray gesture), not the double-click originally sketched here. +- The project is currently on Fyne 2.6.3, so this depends on a Fyne 2.6.3 -> 2.7.x + upgrade first (minor bump; re-verify the CGO build under MSYS2 UCRT64 and check + for 2.7 breaking changes). Track the upgrade as its own task. +- As part of that upgrade, **re-measure startup time** with the `GOSENTRY_TIMING` + method recorded in [PERFORMANCE.md](PERFORMANCE.md). The Fyne 2.5.3 -> 2.6.3 bump + added ~290 ms to startup, all inside `w.Show()`; check whether 2.7 recovers any + of it or holds steady, and append the result to PERFORMANCE.md. +- After upgrading, the change in `src/ui/run.go` (configureSystemTray) is small: + call `desk.SetSystemTrayWindow(w)` alongside `SetSystemTrayMenu(menu)`. Keep the + existing "Show" menu item, which the Fyne docs recommend for less-compliant + Linux systems. ## Delivery And Packaging diff --git a/docs/TESTS.md b/docs/TESTS.md index 80f5087..ab66815 100644 --- a/docs/TESTS.md +++ b/docs/TESTS.md @@ -1,146 +1,326 @@ # GoSentry Test Suite -All tests are located alongside source code in the `src/core/` package. Tests follow Go conventions with `*_test.go` filename patterns. +All tests are located alongside source code in their respective packages under `src/`. Tests follow Go conventions with `*_test.go` filename patterns. + +## Running Tests + +### Using the test scripts + +The repository provides convenience scripts to run all tests with static analysis: + +**Unix/Linux/macOS:** +```bash +./scripts/test.sh +``` + +**Windows:** +```bash +scripts\test.bat +``` + +Both scripts run: +1. `go vet ./...` — static analysis for common errors and suspicious code patterns +2. `go test -race ./...` — tests with race condition detection enabled + +### Manual test commands + +Run all tests: +```bash +go test ./... +``` + +Run all tests with race detection: +```bash +go test -race ./... +``` + +Run tests with verbose output: +```bash +go test -v ./... +``` + +Run a specific test by name: +```bash +go test -run TestRunJobWritesLogFile ./src/runner +``` + +Run tests with code coverage: +```bash +go test -cover ./src/runner +go test -coverprofile=coverage.out ./src/runner +go tool cover -html=coverage.out +``` + +--- ## Test Files Overview -### store_test.go -**Location:** `src/core/store_test.go` -**Package:** `core` +### src/domain/schedule_test.go -Tests YAML serialization and storage behavior. +**Package:** `domain` + +Tests schedule parsing and validation. | Test | Purpose | |------|---------| -| `TestJobsYAMLDoesNotPersistRuntimeNoise` | Verifies that `jobs.yaml` does not persist runtime state fields (LastRun, NextRun, LastState, Output, etc.). Only job definitions are stored; runtime data is kept in memory and log files. | +| `TestParseRejectsInvalidSchedules` | Verifies that invalid schedule strings return an error. | +| `TestParseEveryInterval` | Verifies `@every` duration syntax (e.g., `@every 10s`) is parsed and computes the correct next run time. | +| `TestParseEveryTrimsSurroundingWhitespace` | Verifies leading/trailing whitespace around the `@every` spec is ignored. | +| `TestParseCronExpression` | Verifies 5-field cron expressions (e.g., `*/5 * * * *`) are parsed and compute the correct next run time. | +| `TestParseCronDescriptor` | Verifies predefined cron descriptors such as `@hourly` are accepted. | +| `TestValidateAcceptsValidSchedules` | Verifies that `Schedule.Validate` returns nil for valid schedule strings. | +| `TestZeroScheduleNextIsZero` | Verifies that a zero-value Schedule returns a zero time from `Next`. | +| `TestStringReturnsTrimmedSpec` | Verifies that `Schedule.String` returns the trimmed schedule spec. | --- -### scheduler_test.go -**Location:** `src/core/scheduler_test.go` -**Package:** `core` +### src/app/service_test.go -Tests schedule parsing and job invocation output formatting. +**Package:** `app` + +Tests `Service` construction and the state-accessor contract. | Test | Purpose | |------|---------| -| `TestNextRunTimeSupportsEvery` | Verifies `@every` duration syntax (e.g., `@every 10s`) correctly calculates next run time. Tests with 10-second interval. | -| `TestNextRunTimeSupportsCron` | Verifies standard 5-field cron expressions (e.g., `*/5 * * * *`) correctly calculate next run time. Tests 5-minute interval. | -| `TestRunningOutputIncludesInvocation` | Verifies the running job output header includes all relevant invocation details: command, arguments, success exit codes, start time, and trigger type. | +| `TestNewServiceBuildsRuntimePerJob` | Verifies that `NewService` creates a `JobRuntime` entry for every loaded job. | +| `TestJobsReturnsCopy` | Verifies that `Service.Jobs` returns a defensive copy so callers cannot mutate internal state. | +| `TestStoreReturnsWiredStore` | Verifies that `Service.Store` returns the injected `storage.Store`. | --- -### runner_test.go -**Location:** `src/core/runner_test.go` -**Package:** `core` +### src/app/operations_test.go + +**Package:** `app` + +Tests all mutating operations on the Service, scheduler integration, and settings persistence. + +#### Job CRUD + +| Test | Purpose | +|------|---------| +| `TestCreateJobAssignsIDAndEmits` | Verifies that `CreateJob` assigns a unique ID, persists to YAML, and emits `JobChanged`. | +| `TestCreateJobValidates` | Verifies that `CreateJob` rejects jobs with an invalid schedule. | +| `TestUpdateJobKeepsRuntimeAndReflectsDisable` | Verifies that `UpdateJob` preserves existing runtime state and disables a job correctly. | +| `TestUpdateJobReenablesPausedJob` | Verifies that re-enabling a previously-disabled job clears the paused runtime state. | +| `TestRuntimeLazilyRecreated` | Verifies that `UpdateJob` recreates a missing runtime entry rather than panicking. | +| `TestUpdateJobNotFound` | Verifies that `UpdateJob` returns an error for an unknown job ID. | +| `TestDeleteJobRemovesEverything` | Verifies that `DeleteJob` removes the job from the slice, the runtime map, and the schedule cache. | +| `TestDeleteJobNotFound` | Verifies that `DeleteJob` returns an error for an unknown job ID. | +| `TestSetEnabledNotFound` | Verifies that `SetEnabled` returns an error for an unknown job ID. | +| `TestSetEnabledToggles` | Verifies that `SetEnabled` flips the enabled flag and persists the change. | + +#### Global pause / run-now / run-due + +| Test | Purpose | +|------|---------| +| `TestSetGlobalPauseUpdatesRuntimesAndEmits` | Verifies that `SetGlobalPause` updates all job runtimes, emits `SchedulerStateChanged`, and persists state. | +| `TestRunNowUsesRunnerAndRecords` | Verifies that `RunNow` invokes the runner, records a `RunRecord`, and emits `RunRecorded`. | +| `TestRunNowNotFound` | Verifies that `RunNow` returns an error for an unknown job ID. | +| `TestRunNowRefusedWhileAlreadyRunning` | Verifies that a second concurrent `RunNow` on the same job is rejected while the first is in progress. | +| `TestRunNowRefusedWhilePaused` | Verifies that `RunNow` is rejected when the global pause flag is set. | +| `TestRunDueStartsDueJob` | Verifies that `RunDue` launches a job whose next-run time has passed. | +| `TestRunDueSkipsJobNotYetDue` | Verifies that `RunDue` does not launch a job that is not yet due. | +| `TestRunDueSkipsJobInRunningState` | Verifies that `RunDue` does not start a second concurrent run for an already-running job. | +| `TestRunDueDoesNothingWhilePaused` | Verifies that `RunDue` launches nothing when the global pause flag is set. | +| `TestStartDrivesRunDueOnTick` | Verifies that `Service.Start` wires `RunDue` to the scheduler tick and that each tick advances state. | + +#### Settings + +| Test | Purpose | +|------|---------| +| `TestUpdateSettingsPersistsAndValidates` | Verifies that `UpdateSettings` persists a valid config and rewrites autostart if needed. | +| `TestUpdateSettingsRejectsInvalidConfigs` | Verifies that `UpdateSettings` returns validation errors without persisting. | +| `TestPrependLogCapsActivityList` | Verifies that the activity log never grows beyond its maximum cap. | + +--- + +### src/app/events_test.go + +**Package:** `app` + +Tests the event-emission and observer-subscription machinery. + +| Test | Purpose | +|------|---------| +| `TestEmitDeliversToAllObserversInOrder` | Verifies that all registered observers receive emitted events in registration order. | +| `TestEmitWithNoObserversIsNoop` | Verifies that emitting an event with no observers does not panic. | +| `TestObserverCanReadServiceState` | Verifies that an observer called by `emit` can safely read Service state (jobs, runtimes). | + +--- + +### src/app/format_test.go + +**Package:** `app` + +Tests display-formatting helpers used by the UI. + +| Test | Purpose | +|------|---------| +| `TestStatusText` | Verifies that job status codes map to the correct display strings. | +| `TestEventText` | Verifies trigger-type labels for scheduled, manual, and UI triggers. | +| `TestDisplayFolder` | Verifies that an empty folder string shows "No folder". | +| `TestDisplayArguments` | Verifies that an empty arguments string shows "None". | +| `TestDisplaySuccessExitCodes` | Verifies that an empty exit-codes string shows the default "0". | +| `TestDisplayRunMode` | Verifies run-mode labels for normal and start-only modes. | +| `TestDisplayInvocation` | Verifies that the full invocation display string includes command, arguments, and exit codes. | +| `TestDisplayIndex` | Verifies that the display index is one-based (job slice index + 1). | + +--- + +### src/storage/store_test.go + +**Package:** `storage` + +Tests YAML round-tripping, migration, and default generation. + +| Test | Purpose | +|------|---------| +| `TestJobsRoundTrip` | Verifies that jobs saved to YAML are reloaded with identical field values. | +| `TestConfigRoundTrip` | Verifies that settings saved to YAML are reloaded with identical field values. | +| `TestNormalizeJobsFillsDefaults` | Verifies that `normalizeJobs` assigns sequential IDs and sets default enabled state for jobs missing those fields. | +| `TestLoadOrCreateConfigMigratesFromLegacy` | Verifies that the old flat-config format is migrated to the current `gosentry.yaml` + `jobs_dir` layout on first load. | +| `TestLoadOrCreateConfigCreatesDefaultsOnFirstRun` | Verifies that a missing config file is created with sane defaults and a sample job. | +| `TestJobsYAMLDoesNotPersistRuntimeNoise` | Verifies that `jobs.yaml` does not persist runtime state (LastRun, NextRun, etc.). Only durable job fields are stored. | + +--- + +### src/scheduler/scheduler_test.go + +**Package:** `scheduler` + +Tests the timing-loop contract using a fake clock. + +| Test | Purpose | +|------|---------| +| `TestSchedulerCallsTickWithClockNow` | Verifies that the scheduler calls the injected tick function with the wall-clock time returned by the fake Clock. | +| `TestSchedulerStopReleasesClock` | Verifies that `Stop` terminates the tick loop and releases the Clock without hanging. | + +--- + +### src/runner/runner_test.go + +**Package:** `runner` Tests command execution, exit code handling, output capture, and Windows-specific process behavior. -#### Log File Tests +#### Log file tests | Test | Purpose | |------|---------| -| `TestRunJobWritesLogFile` | Verifies that each job execution creates a `.log` file in the configured logs directory with sanitized job name in filename and proper metadata (trigger type, job name, command output). | +| `TestRunJobLogFileAllHeaders` | Verifies that the log file contains all expected metadata headers: trigger type, job name, command, arguments, and start time. | +| `TestRunJobRecordFields` | Verifies that the returned `RunRecord` carries the correct status, trigger, and log-file path. | +| `TestRunJobWritesLogFile` | Verifies that each job execution creates a `.log` file in the configured logs directory with a sanitized job name in the filename. | -#### Command Execution Tests +#### Output formatting + +| Test | Purpose | +|------|---------| +| `TestFormatOutput` | Verifies that stdout and stderr are combined with section labels in the formatted output. | +| `TestFormatOutputEmptyStreams` | Verifies that empty stdout/stderr streams are omitted from the formatted output. | +| `TestLogArguments` | Verifies that arguments are included in the log header and absent when the arguments field is empty. | +| `TestSanitizeFileName` | Verifies that spaces and special characters in a job name are replaced to produce a safe filename segment. | + +#### Command execution | Test | Platform | Purpose | |------|----------|---------| -| `TestRunJobRunsQuotedWindowsExecutable` | Windows | Verifies that executable paths with quotes (e.g., `"C:\Program Files\..."`) are executed correctly via cmd.exe. | -| `TestRunJobRunsUnquotedWindowsProgramPathWithSpaces` | Windows | Verifies that unquoted executable paths with spaces (e.g., `C:\Program Files\App\app.exe`) are quoted and executed correctly. | -| `TestRunJobRunsWindowsCommandWithSeparateArguments` | Windows | Verifies that command and arguments separated in the Job struct are combined and executed correctly. | +| `TestRunJobRunsQuotedWindowsExecutable` | Windows | Verifies that executable paths with quotes are executed correctly via `cmd.exe`. | +| `TestRunJobRunsUnquotedWindowsProgramPathWithSpaces` | Windows | Verifies that unquoted executable paths with spaces are quoted and executed correctly. | +| `TestRunJobRunsWindowsCommandWithSeparateArguments` | Windows | Verifies that command and arguments from the Job struct are combined and executed correctly. | -#### Exit Code Handling Tests +#### Exit code handling | Test | Purpose | |------|---------| -| `TestRunJobAcceptsConfiguredExitCode` | Verifies that exit codes listed in `SuccessExitCodes` (e.g., `"0,1"`) result in "OK" status even if nonzero. Includes detail message about accepted exit code. | -| `TestRunJobRejectsUnconfiguredExitCode` | Verifies that exit codes not listed in `SuccessExitCodes` result in "Failed" status with exit code detail. | +| `TestRunJobAcceptsConfiguredExitCode` | Verifies that exit codes in `SuccessExitCodes` result in "OK" status even when nonzero. | +| `TestRunJobRejectsUnconfiguredExitCode` | Verifies that exit codes absent from `SuccessExitCodes` result in "Failed" status. | -#### Start-Only Mode Tests +#### Start-only mode | Test | Purpose | |------|---------| -| `TestRunJobStartOnlyDoesNotWaitForExitCode` | Verifies that jobs with `StartOnly: true` launch the process and return "OK" immediately without waiting for process exit or checking exit code. | -| `TestRunJobStartOnlyReportsStartFailure` | Verifies that jobs with `StartOnly: true` still report "Failed" if the process fails to start (e.g., executable not found). | +| `TestRunJobStartOnlyDoesNotWaitForExitCode` | Verifies that `StartOnly: true` jobs launch and return "OK" immediately without waiting for the process to exit. | +| `TestRunJobStartOnlyReportsStartFailure` | Verifies that `StartOnly: true` jobs still report "Failed" if the process cannot be started. | -#### Utility Function Tests +#### Utility / Windows invocation | Test | Platform | Purpose | |------|----------|---------| -| `TestParseExitCodes` | All | Verifies that exit code strings with mixed separators (comma, semicolon, newline) are correctly parsed into integer slice. | -| `TestDirectCommandDoesNotHideWindow` | Windows | Verifies that direct executable commands (with explicit path and arguments) do not request hidden window startup. | -| `TestShellCommandHidesWindow` | Windows | Verifies that shell commands (passed to cmd.exe) request hidden window startup to prevent console flash. | -| `TestShellCommandUsesWindowsSafeQuoting` | Windows | Verifies that shell commands use cmd.exe `/S /C` syntax with proper outer quoting to handle paths with spaces and special characters. | +| `TestParseExitCodes` | All | Verifies that mixed-separator exit-code strings (comma, semicolon, newline) are parsed correctly. | +| `TestDirectCommandDoesNotHideWindow` | Windows | Verifies that direct executable commands do not request hidden-window startup. | +| `TestShellCommandHidesWindow` | Windows | Verifies that shell commands request hidden-window startup to prevent console flash. | +| `TestShellCommandUsesWindowsSafeQuoting` | Windows | Verifies `cmd.exe /S /C` quoting for paths with spaces and special characters. | | `TestWindowsShellCommandLineQuotesUnquotedProgramPath` | Windows | Verifies that unquoted program paths in shell commands are quoted while preserving already-quoted arguments. | --- -### autostart_windows_test.go -**Location:** `src/core/autostart_windows_test.go` -**Package:** `core` -**Build Tags:** `//go:build windows` (Windows only) +### src/runner/cleanup_test.go -Tests Windows autostart entry creation via shortcuts in the Startup folder. +**Package:** `runner` + +Tests log-file cleanup by age and by count. | Test | Purpose | |------|---------| -| `TestParseRegistryRunValue` | Verifies that legacy Windows Registry `Run` entry values are correctly parsed from `reg query` output (for migration/cleanup). | -| `TestSameWindowsPathIgnoresCaseAndQuotes` | Verifies that Windows path comparison is case-insensitive and handles quote marks correctly (e.g., `"D:\..."` matches `d:\...`). | -| `TestSameWindowsPathHandlesSpaces` | Verifies that Windows path comparison correctly matches paths with spaces both with and without quotes. | -| `TestStartupShortcutPathUsesUserStartupFolder` | Verifies that the startup shortcut path resolves to the user's Startup folder using `%APPDATA%` environment variable. | -| `TestCreateStartupShortcutHandlesSpaces` | Verifies that `.lnk` shortcut files are created with correct `TargetPath` and `Arguments` (--start-in-tray) even when target path contains spaces. | +| `TestCleanupLogsMissingDirReturnsNil` | Verifies that cleanup returns nil (not an error) when the logs directory does not exist. | +| `TestCleanupLogsRemovesFilesPastMaxAge` | Verifies that `.log` files older than `MaxLogAgeDays` are deleted. | +| `TestCleanupLogsKeepsFilesWithinAgeLimit` | Verifies that `.log` files within the age limit are retained. | +| `TestCleanupLogsByCountDeletesOldest` | Verifies that when file count exceeds `MaxLogFiles`, the oldest files are removed first. | +| `TestCleanupLogsNonLogFilesNotDeleted` | Verifies that non-`.log` files in the logs directory are never deleted by cleanup. | +| `TestCleanupLogsSubdirsNotDeleted` | Verifies that subdirectories inside the logs directory are not deleted by cleanup. | +| `TestCleanupLogsZeroLimitsDisableBothPolicies` | Verifies that setting both limits to zero disables both the age and count cleanup policies. | --- -### autostart_linux_test.go -**Location:** `src/core/autostart_linux_test.go` -**Package:** `core` -**Build Tags:** `//go:build linux` (Linux only) +### src/platform/autostart/autostart_windows_test.go -Tests Linux autostart entry creation via XDG Desktop Entry files. +**Location:** `src/platform/autostart/autostart_windows_test.go` +**Build Tags:** `//go:build windows` + +Tests Windows autostart via shortcuts in the Startup folder. | Test | Purpose | |------|---------| -| `TestLinuxAutostartStartsInTray` | Verifies that the XDG Desktop Entry is created with the `--start-in-tray` argument in the `Exec=` field, so scheduled jobs run immediately after login without displaying the window. | -| `TestLinuxAutostartRemovesLegacyDesktopEntry` | Verifies that legacy autostart entries (from old PySentry implementation) are cleaned up when enabling autostart through the new system. | +| `TestParseRegistryRunValue` | Verifies that legacy `HKCU\...\Run` entry values are parsed correctly from `reg query` output (for migration/cleanup). | +| `TestSameWindowsPathIgnoresCaseAndQuotes` | Verifies that Windows path comparison is case-insensitive and handles quote marks correctly. | +| `TestSameWindowsPathHandlesSpaces` | Verifies that Windows path comparison matches paths with and without surrounding quotes. | +| `TestSameWindowsPathStripsExtendedLengthPrefix` | Verifies that `\\?\`-prefixed paths are compared correctly after stripping the prefix. | +| `TestSameWindowsPathMatchesShortNameViaFilesystem` | Verifies that 8.3 short names are resolved to long names for comparison. | +| `TestStartupShortcutPathUsesUserStartupFolder` | Verifies that the shortcut path resolves into `%APPDATA%\Microsoft\Windows\Start Menu\Programs\Startup`. | +| `TestCreateStartupShortcutHandlesCyrillicPath` | Verifies that `.lnk` files are created correctly when the executable path contains Cyrillic characters. | +| `TestCreateStartupShortcutHandlesSpaces` | Verifies that `.lnk` files are created with correct `TargetPath` and `--start-in-tray` arguments when the path contains spaces. | --- -## Running Tests +### src/platform/autostart/autostart_linux_test.go -### Run all tests in the package -```bash -cd D:\Local\Git\gosentry -go test ./src/core -``` +**Location:** `src/platform/autostart/autostart_linux_test.go` +**Build Tags:** `//go:build linux` -### Run tests with verbose output -```bash -go test -v ./src/core -``` +Tests Linux autostart via XDG Desktop Entry files. -### Run specific test by name -```bash -go test -run TestRunJobWritesLogFile ./src/core -``` +| Test | Purpose | +|------|---------| +| `TestLinuxAutostartStartsInTray` | Verifies that the XDG Desktop Entry is created with `--start-in-tray` in the `Exec=` field. | +| `TestLinuxAutostartRemovesLegacyDesktopEntry` | Verifies that enabling autostart also removes legacy PySentry service files left by earlier builds. | -### Run Windows-only tests (on Windows) -```bash -go test -v ./src/core # Windows build tags are active -``` +--- -### Run Linux-only tests (on Linux) -```bash -go test -v ./src/core # Linux build tags are active -``` +### src/ui/jobs_view_test.go -### Run with code coverage -```bash -go test -cover ./src/core -go test -coverprofile=coverage.out ./src/core -go tool cover -html=coverage.out -``` +**Package:** `ui` + +Tests pure helper functions in the jobs view (no Fyne widget construction). + +| Test | Purpose | +|------|---------| +| `TestFilterValue` | Verifies that `filterValue` returns the correct display string for the current folder filter. | +| `TestFolderOptionsAlwaysIncludesSentinels` | Verifies that the folder filter list always starts with "All" and "No folder" sentinel entries. | +| `TestFolderOptionsAppendsUniqueFolders` | Verifies that folder names from the job list are appended once each, in order, without duplicates. | +| `TestFilteredJobIndexesAll` | Verifies that the "All" filter returns indexes for every job. | +| `TestFilteredJobIndexesByNamedFolder` | Verifies that filtering by a named folder returns only jobs in that folder. | +| `TestFilteredJobIndexesNoFolder` | Verifies that the "No folder" filter returns only jobs with an empty folder field. | +| `TestFilteredJobIndexesEmptySlice` | Verifies that filtering an empty job slice returns an empty index list. | --- @@ -150,20 +330,19 @@ go tool cover -html=coverage.out 2. **Cross-platform** — Platform-specific tests use `//go:build` tags and `runtime.GOOS` checks to skip when not applicable. -3. **Exit Code Flexibility** — The `SuccessExitCodes` field allows jobs to treat nonzero exit codes as success, tested explicitly. +3. **Fake clocks and runners** — The scheduler is exercised with an injected fake `Clock`; the service operations tests inject a fake `runJob` function to avoid spawning real processes. -4. **Path Handling** — Extensive tests cover Windows path quoting, spaces in paths, and case-insensitive matching to avoid subtle shell escaping bugs. +4. **Event-driven correctness** — `app` tests subscribe to the event bus and assert that the expected events are emitted, rather than inspecting internal fields directly. -5. **Start-Only Mode** — Special handling for long-running processes that should be launched but not waited on, tested separately from normal execution flow. +5. **Exit Code Flexibility** — The `SuccessExitCodes` field allows jobs to treat nonzero exit codes as success, tested explicitly. + +6. **Path Handling** — Extensive tests cover Windows path quoting, spaces in paths, and case-insensitive matching to avoid subtle shell escaping bugs. + +7. **Start-Only Mode** — Special handling for long-running processes that should be launched but not waited on, tested separately from normal execution flow. --- -## Future Test Coverage Gaps +## Remaining Test Coverage Gaps -Potential areas for additional tests: -- Job group/folder filtering and persistence -- Log cleanup (max file count and max age) -- Settings persistence and migration -- GUI integration tests (currently untested) -- Concurrent job execution -- Job history and run record storage +- GUI integration tests — Fyne widget interaction is not yet tested end-to-end +- Job history on-disk retrieval — RunRecord file reading is not covered diff --git a/go.mod b/go.mod index 4f55de7..c8d8a0a 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module gitea.mixdep.ru/mix/gosentry go 1.22 require ( - fyne.io/fyne/v2 v2.5.3 + fyne.io/fyne/v2 v2.6.3 github.com/robfig/cron/v3 v3.0.1 go.yaml.in/yaml/v4 v4.0.0-rc.5 ) @@ -13,29 +13,32 @@ require ( github.com/BurntSushi/toml v1.4.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/fredbi/uri v1.1.0 // indirect - github.com/fsnotify/fsnotify v1.7.0 // indirect - github.com/fyne-io/gl-js v0.0.0-20220119005834-d2da28d9ccfe // indirect - github.com/fyne-io/glfw-js v0.0.0-20241126112943-313d8a0fe1d0 // indirect - github.com/fyne-io/image v0.0.0-20220602074514-4956b0afb3d2 // indirect - github.com/go-gl/gl v0.0.0-20211210172815-726fda9656d6 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/fyne-io/gl-js v0.2.0 // indirect + github.com/fyne-io/glfw-js v0.3.0 // indirect + github.com/fyne-io/image v0.1.1 // indirect + github.com/fyne-io/oksvg v0.1.0 // indirect + github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71 // indirect github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a // indirect github.com/go-text/render v0.2.0 // indirect - github.com/go-text/typesetting v0.2.0 // indirect + github.com/go-text/typesetting v0.2.1 // indirect github.com/godbus/dbus/v5 v5.1.0 // indirect - github.com/gopherjs/gopherjs v1.17.2 // indirect - github.com/jeandeaual/go-locale v0.0.0-20240223122105-ce5225dcaa49 // indirect - github.com/jsummers/gobmp v0.0.0-20151104160322-e2ba15ffa76e // indirect - github.com/nicksnyder/go-i18n/v2 v2.4.0 // indirect + github.com/hack-pad/go-indexeddb v0.3.2 // indirect + github.com/hack-pad/safejs v0.1.0 // indirect + github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade // indirect + github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 // indirect + github.com/kr/text v0.2.0 // indirect + github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect + github.com/nicksnyder/go-i18n/v2 v2.5.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/rymdport/portal v0.3.0 // indirect + github.com/rymdport/portal v0.4.1 // indirect github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c // indirect github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef // indirect - github.com/stretchr/testify v1.8.4 // indirect - github.com/yuin/goldmark v1.7.1 // indirect - golang.org/x/image v0.18.0 // indirect - golang.org/x/mobile v0.0.0-20231127183840-76ac6878050a // indirect - golang.org/x/net v0.25.0 // indirect - golang.org/x/sys v0.20.0 // indirect - golang.org/x/text v0.16.0 // indirect + github.com/stretchr/testify v1.10.0 // indirect + github.com/yuin/goldmark v1.7.8 // indirect + golang.org/x/image v0.24.0 // indirect + golang.org/x/net v0.35.0 // indirect + golang.org/x/sys v0.30.0 // indirect + golang.org/x/text v0.22.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index d487803..e2f2a00 100644 --- a/go.sum +++ b/go.sum @@ -1,660 +1,84 @@ -cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= -cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= -cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= -cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU= -cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY= -cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc= -cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0= -cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To= -cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4= -cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M= -cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc= -cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk= -cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs= -cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc= -cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY= -cloud.google.com/go v0.72.0/go.mod h1:M+5Vjvlc2wnp6tjzE102Dw08nGShTscUx2nZMufOKPI= -cloud.google.com/go v0.74.0/go.mod h1:VV1xSbzvo+9QJOxLDaJfTjx5e+MePCpCWwvftOeQmWk= -cloud.google.com/go v0.78.0/go.mod h1:QjdrLG0uq+YwhjoVOLsS1t7TW8fs36kLs4XO5R5ECHg= -cloud.google.com/go v0.79.0/go.mod h1:3bzgcEeQlzbuEAYu4mrWhKqWjmpprinYgKJLgKHnbb8= -cloud.google.com/go v0.81.0/go.mod h1:mk/AM35KwGk/Nm2YSeZbxXdrNK3KZOYHmLkOqC2V6E0= -cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o= -cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE= -cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc= -cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg= -cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc= -cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ= -cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= -cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk= -cloud.google.com/go/firestore v1.1.0/go.mod h1:ulACoGHTpvq5r8rxGJ4ddJZBZqakUQqClKRT5SZwBmk= -cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= -cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw= -cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA= -cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU= -cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw= -cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos= -cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk= -cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs= -cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0= -dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= -fyne.io/fyne/v2 v2.5.3 h1:k6LjZx6EzRZhClsuzy6vucLZBstdH2USDGHSGWq8ly8= -fyne.io/fyne/v2 v2.5.3/go.mod h1:0GOXKqyvNwk3DLmsFu9v0oYM0ZcD1ysGnlHCerKoAmo= +fyne.io/fyne/v2 v2.6.3 h1:cvtM2KHeRuH+WhtHiA63z5wJVBkQ9+Ay0UMl9PxFHyA= +fyne.io/fyne/v2 v2.6.3/go.mod h1:NGSurpRElVoI1G3h+ab2df3O5KLGh1CGbsMMcX0bPIs= fyne.io/systray v1.11.0 h1:D9HISlxSkx+jHSniMBR6fCFOUjk1x/OOOJLa9lJYAKg= fyne.io/systray v1.11.0/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs= -github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0= github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= -github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= -github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= -github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o= -github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY= -github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= -github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= -github.com/bketelsen/crypt v0.0.4/go.mod h1:aI6NrJ0pMGgvZKL1iVgXLnfIFJtfV+bKCoqOes/6LfM= -github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= -github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= -github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= -github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= -github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= -github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= -github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= -github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= -github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= -github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= -github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= -github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= -github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= -github.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5ynNVH9qI8YYLbd1fK2po= -github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= -github.com/envoyproxy/go-control-plane v0.9.9-0.20210217033140-668b12f5399d/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= -github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= -github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= github.com/felixge/fgprof v0.9.3 h1:VvyZxILNuCiUCSXtPtYmmtGvb65nqXh2QFWc0Wpf2/g= github.com/felixge/fgprof v0.9.3/go.mod h1:RdbpDgzqYVh/T9fPELJyV7EYJuHB55UTEULNun8eiPw= github.com/fredbi/uri v1.1.0 h1:OqLpTXtyRg9ABReqvDGdJPqZUxs8cyBDOMXBbskCaB8= github.com/fredbi/uri v1.1.0/go.mod h1:aYTUoAXBOq7BLfVJ8GnKmfcuURosB1xyHDIfWeC/iW4= -github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= -github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= -github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= -github.com/fyne-io/gl-js v0.0.0-20220119005834-d2da28d9ccfe h1:A/wiwvQ0CAjPkuJytaD+SsXkPU0asQ+guQEIg1BJGX4= -github.com/fyne-io/gl-js v0.0.0-20220119005834-d2da28d9ccfe/go.mod h1:d4clgH0/GrRwWjRzJJQXxT/h1TyuNSfF/X64zb/3Ggg= -github.com/fyne-io/glfw-js v0.0.0-20241126112943-313d8a0fe1d0 h1:/1YRWFv9bAWkoo3SuxpFfzpXH0D/bQnTjNXyF4ih7Os= -github.com/fyne-io/glfw-js v0.0.0-20241126112943-313d8a0fe1d0/go.mod h1:gsGA2dotD4v0SR6PmPCYvS9JuOeMwAtmfvDE7mbYXMY= -github.com/fyne-io/image v0.0.0-20220602074514-4956b0afb3d2 h1:hnLq+55b7Zh7/2IRzWCpiTcAvjv/P8ERF+N7+xXbZhk= -github.com/fyne-io/image v0.0.0-20220602074514-4956b0afb3d2/go.mod h1:eO7W361vmlPOrykIg+Rsh1SZ3tQBaOsfzZhsIOb/Lm0= -github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= -github.com/go-gl/gl v0.0.0-20211210172815-726fda9656d6 h1:zDw5v7qm4yH7N8C8uWd+8Ii9rROdgWxQuGoJ9WDXxfk= -github.com/go-gl/gl v0.0.0-20211210172815-726fda9656d6/go.mod h1:9YTyiznxEY1fVinfM7RvRcjRHbw2xLBJ3AAGIT0I4Nw= -github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= -github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= -github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/fyne-io/gl-js v0.2.0 h1:+EXMLVEa18EfkXBVKhifYB6OGs3HwKO3lUElA0LlAjs= +github.com/fyne-io/gl-js v0.2.0/go.mod h1:ZcepK8vmOYLu96JoxbCKJy2ybr+g1pTnaBDdl7c3ajI= +github.com/fyne-io/glfw-js v0.3.0 h1:d8k2+Y7l+zy2pc7wlGRyPfTgZoqDf3AI4G+2zOWhWUk= +github.com/fyne-io/glfw-js v0.3.0/go.mod h1:Ri6te7rdZtBgBpxLW19uBpp3Dl6K9K/bRaYdJ22G8Jk= +github.com/fyne-io/image v0.1.1 h1:WH0z4H7qfvNUw5l4p3bC1q70sa5+YWVt6HCj7y4VNyA= +github.com/fyne-io/image v0.1.1/go.mod h1:xrfYBh6yspc+KjkgdZU/ifUC9sPA5Iv7WYUBzQKK7JM= +github.com/fyne-io/oksvg v0.1.0 h1:7EUKk3HV3Y2E+qypp3nWqMXD7mum0hCw2KEGhI1fnBw= +github.com/fyne-io/oksvg v0.1.0/go.mod h1:dJ9oEkPiWhnTFNCmRgEze+YNprJF7YRbpjgpWS4kzoI= +github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71 h1:5BVwOaUSBTlVZowGO6VZGw2H/zl9nrd3eCZfYV+NfQA= +github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71/go.mod h1:9YTyiznxEY1fVinfM7RvRcjRHbw2xLBJ3AAGIT0I4Nw= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a h1:vxnBhFDDT+xzxf1jTJKMKZw3H0swfWk9RpWbBbDK5+0= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-text/render v0.2.0 h1:LBYoTmp5jYiJ4NPqDc2pz17MLmA3wHw1dZSVGcOdeAc= github.com/go-text/render v0.2.0/go.mod h1:CkiqfukRGKJA5vZZISkjSYrcdtgKQWRa2HIzvwNN5SU= -github.com/go-text/typesetting v0.2.0 h1:fbzsgbmk04KiWtE+c3ZD4W2nmCRzBqrqQOvYlwAOdho= -github.com/go-text/typesetting v0.2.0/go.mod h1:2+owI/sxa73XA581LAzVuEBZ3WEEV2pXeDswCH/3i1I= -github.com/go-text/typesetting-utils v0.0.0-20240317173224-1986cbe96c66 h1:GUrm65PQPlhFSKjLPGOZNPNxLCybjzjYBzjfoBGaDUY= -github.com/go-text/typesetting-utils v0.0.0-20240317173224-1986cbe96c66/go.mod h1:DDxDdQEnB70R8owOx3LVpEFvpMK9eeH1o2r0yZhFI9o= -github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/go-text/typesetting v0.2.1 h1:x0jMOGyO3d1qFAPI0j4GSsh7M0Q3Ypjzr4+CEVg82V8= +github.com/go-text/typesetting v0.2.1/go.mod h1:mTOxEwasOFpAMBjEQDhdWRckoLLeI/+qrQeBCTGEt6M= +github.com/go-text/typesetting-utils v0.0.0-20241103174707-87a29e9e6066 h1:qCuYC+94v2xrb1PoS4NIDe7DGYtLnU2wWiQe9a1B1c0= +github.com/go-text/typesetting-utils v0.0.0-20241103174707-87a29e9e6066/go.mod h1:DDxDdQEnB70R8owOx3LVpEFvpMK9eeH1o2r0yZhFI9o= github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= -github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= -github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= -github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= -github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= -github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y= -github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= -github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= -github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= -github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4= -github.com/golang/mock v1.5.0/go.mod h1:CWnOUgYIOo4TcNZ0wHX3YZCqsaM1I1Jvs6v3mP3KVu8= -github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= -github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= -github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk= -github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= -github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= -github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= -github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= -github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= -github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= -github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= -github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= -github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= -github.com/golang/protobuf v1.5.1/go.mod h1:DopwsBzvsk0Fs44TXzsVbJyPhcCPeIwnvohx4u74HPM= -github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= -github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= -github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= -github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= -github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= -github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= -github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= -github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= -github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= -github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= -github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= -github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20201023163331-3e6fc7fc9c4c/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= -github.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= -github.com/google/pprof v0.0.0-20210122040257-d980be63207e/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= -github.com/google/pprof v0.0.0-20210226084205-cbba55b83ad5/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/pprof v0.0.0-20211214055906-6f57359322fd h1:1FjCyPC+syAzJ5/2S8fqdZK1R22vvA0J7JZKcuOIQ7Y= github.com/google/pprof v0.0.0-20211214055906-6f57359322fd/go.mod h1:KgnwoLYCZ8IQu3XUZ8Nc/bM9CCZFOyjUNOSygVozoDg= -github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= -github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= -github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= -github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= -github.com/gopherjs/gopherjs v0.0.0-20211219123610-ec9572f70e60/go.mod h1:cz9oNYuRUWGdHmLF2IodMLkAhcPtXeULvcBNagUrxTI= -github.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g= -github.com/gopherjs/gopherjs v1.17.2/go.mod h1:pRRIvn/QzFLrKfvEz3qUuEhtE/zLCWfreZ6J5gM2i+k= -github.com/goxjs/gl v0.0.0-20210104184919-e3fafc6f8f2a/go.mod h1:dy/f2gjY09hwVfIyATps4G2ai7/hLwLkc5TrPqONuXY= -github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw= -github.com/hashicorp/consul/api v1.1.0/go.mod h1:VmuI/Lkw1nC05EYQWNKwWGbkg+FbDBtguAZLlVdkD9Q= -github.com/hashicorp/consul/sdk v0.1.1/go.mod h1:VKf9jXwCTEY1QZP2MOLRhb5i/I/ssyNV1vwHyQBF0x8= -github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= -github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= -github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= -github.com/hashicorp/go-msgpack v0.5.3/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM= -github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= -github.com/hashicorp/go-rootcerts v1.0.0/go.mod h1:K6zTfqpRlCUIjkwsN4Z+hiSfzSTQa6eBIzfwKfwNnHU= -github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU= -github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4= -github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= -github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= -github.com/hashicorp/go.net v0.0.1/go.mod h1:hjKkEWcCURg++eb33jQU7oqQcI9XDCnUzHA0oac0k90= -github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= -github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= -github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= -github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64= -github.com/hashicorp/mdns v1.0.0/go.mod h1:tL+uN++7HEJ6SQLQ2/p+z2pH24WQKWjBPkE0mNTz8vQ= -github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2pPBoIllUwCN7I= -github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc= -github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= -github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= -github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= -github.com/jeandeaual/go-locale v0.0.0-20240223122105-ce5225dcaa49 h1:Po+wkNdMmN+Zj1tDsJQy7mJlPlwGNQd9JZoPjObagf8= -github.com/jeandeaual/go-locale v0.0.0-20240223122105-ce5225dcaa49/go.mod h1:YiutDnxPRLk5DLUFj6Rw4pRBBURZY07GFr54NdV9mQg= -github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= -github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= -github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= -github.com/jsummers/gobmp v0.0.0-20151104160322-e2ba15ffa76e h1:LvL4XsI70QxOGHed6yhQtAU34Kx3Qq2wwBzGFKY8zKk= -github.com/jsummers/gobmp v0.0.0-20151104160322-e2ba15ffa76e/go.mod h1:kLgvv7o6UM+0QSf0QjAse3wReFDsb9qbZJdfexWlrQw= -github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= -github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= -github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= -github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= -github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= -github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= -github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= -github.com/magiconair/properties v1.8.5/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60= -github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= -github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= -github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= -github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc= -github.com/mitchellh/go-homedir v1.0.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= -github.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI= -github.com/mitchellh/gox v0.4.0/go.mod h1:Sd9lOJ0+aimLBi73mGofS1ycjY8lL3uZM3JPS42BGNg= -github.com/mitchellh/iochan v1.0.0/go.mod h1:JwYml1nuB7xOzsp52dPpHFffvOCDupsG0QubkSMEySY= -github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= -github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= -github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= -github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= -github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= -github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= -github.com/neelance/astrewrite v0.0.0-20160511093645-99348263ae86/go.mod h1:kHJEU3ofeGjhHklVoIGuVj85JJwZ6kWPaJwCIxgnFmo= -github.com/neelance/sourcemap v0.0.0-20200213170602-2833bce08e4c/go.mod h1:Qr6/a/Q4r9LP1IltGz7tA7iOK1WonHEYhu1HRBA7ZiM= -github.com/nicksnyder/go-i18n/v2 v2.4.0 h1:3IcvPOAvnCKwNm0TB0dLDTuawWEj+ax/RERNC+diLMM= -github.com/nicksnyder/go-i18n/v2 v2.4.0/go.mod h1:nxYSZE9M0bf3Y70gPQjN9ha7XNHX7gMc814+6wVyEI4= +github.com/hack-pad/go-indexeddb v0.3.2 h1:DTqeJJYc1usa45Q5r52t01KhvlSN02+Oq+tQbSBI91A= +github.com/hack-pad/go-indexeddb v0.3.2/go.mod h1:QvfTevpDVlkfomY498LhstjwbPW6QC4VC/lxYb0Kom0= +github.com/hack-pad/safejs v0.1.0 h1:qPS6vjreAqh2amUqj4WNG1zIw7qlRQJ9K10eDKMCnE8= +github.com/hack-pad/safejs v0.1.0/go.mod h1:HdS+bKF1NrE72VoXZeWzxFOVQVUSqZJAG0xNCnb+Tio= +github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade h1:FmusiCI1wHw+XQbvL9M+1r/C3SPqKrmBaIOYwVfQoDE= +github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade/go.mod h1:ZDXo8KHryOWSIqnsb/CiDq7hQUYryCgdVnxbj8tDG7o= +github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 h1:YLvr1eE6cdCqjOe972w/cYF+FjW34v27+9Vo5106B4M= +github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25/go.mod h1:kLgvv7o6UM+0QSf0QjAse3wReFDsb9qbZJdfexWlrQw= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= +github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= +github.com/nicksnyder/go-i18n/v2 v2.5.1 h1:IxtPxYsR9Gp60cGXjfuR/llTqV8aYMsC472zD0D1vHk= +github.com/nicksnyder/go-i18n/v2 v2.5.1/go.mod h1:DrhgsSDZxoAfvVrBVLXoxZn/pN5TXqaDbq7ju94viiQ= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= -github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= -github.com/pelletier/go-toml v1.9.3/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= -github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/profile v1.7.0 h1:hnbDkaNWPCLMO9wGLdBFTIZvzDrDfBM2072E1S9gJkA= github.com/pkg/profile v1.7.0/go.mod h1:8Uer0jas47ZQMJ7VD+OHknK4YDY07LPUC6dEvqDjvNo= -github.com/pkg/sftp v1.10.1/go.mod h1:lYOWFsE0bwd1+KfKJaKeuokY15vzFx25BLbzYYoAxZI= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= -github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= -github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= -github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= -github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= -github.com/rymdport/portal v0.3.0 h1:QRHcwKwx3kY5JTQcsVhmhC3TGqGQb9LFghVNUy8AdB8= -github.com/rymdport/portal v0.3.0/go.mod h1:kFF4jslnJ8pD5uCi17brj/ODlfIidOxlgUDTO5ncnC4= -github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= -github.com/shurcooL/go v0.0.0-20200502201357-93f07166e636/go.mod h1:TDJrrUr11Vxrven61rcy3hJMUqaf/CLWYhHNPmT14Lk= -github.com/shurcooL/httpfs v0.0.0-20190707220628-8d4bc4ba7749/go.mod h1:ZY1cvUeJuFPAdZ/B6v7RHavJWZn2YPVFQ1OSXhCGOkg= -github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= -github.com/shurcooL/vfsgen v0.0.0-20200824052919-0d455de96546/go.mod h1:TrYk7fJVaAttu97ZZKrO9UbRa8izdowaMIZcxYMbVaw= -github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= -github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= -github.com/spf13/afero v1.6.0/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z93I= -github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= -github.com/spf13/cobra v1.2.1/go.mod h1:ExllRjgxM/piMAM+3tAZvg8fsklGAf3tPfi+i8t68Nk= -github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo= -github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/spf13/viper v1.8.1/go.mod h1:o0Pch8wJ9BVSWGQMbra6iw0oQ5oktSIBaujf1rJH9Ns= +github.com/rymdport/portal v0.4.1 h1:2dnZhjf5uEaeDjeF/yBIeeRo6pNI2QAKm7kq1w/kbnA= +github.com/rymdport/portal v0.4.1/go.mod h1:kFF4jslnJ8pD5uCi17brj/ODlfIidOxlgUDTO5ncnC4= github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiYWEedbTT0qnSxrCjsVbb7yKY1KE= github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c/go.mod h1:cNQ3dwVJtS5Hmnjxy6AgTPd0Inb3pW05ftPSX7NZO7Q= github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef h1:Ch6Q+AZUxDBCVqdkI8FSpFyZDtCVBc2VmejdNrm5rRQ= github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef/go.mod h1:nXTWP6+gD5+LUJ8krVhhoeHjvHTutPxMYl5SvkcnJNE= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= -github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= -github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= -github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= -github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw= -github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= -github.com/yuin/goldmark v1.4.0/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= -github.com/yuin/goldmark v1.7.1 h1:3bajkSilaCbjdKVsKdZjZCLBNPL9pYzrCakKaf4U49U= -github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= -go.etcd.io/etcd/api/v3 v3.5.0/go.mod h1:cbVKeC6lCfl7j/8jBhAK6aIYO9XOjdptoxU/nLQcPvs= -go.etcd.io/etcd/client/pkg/v3 v3.5.0/go.mod h1:IJHfcCEKxYu1Os13ZdwCwIUTUVGYTSAM3YSwc9/Ac1g= -go.etcd.io/etcd/client/v2 v2.305.0/go.mod h1:h9puh54ZTgAKtEbut2oe9P4L/oqKCVB6xsXlzd7alYQ= -go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= -go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= -go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= -go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= -go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= -go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk= -go.opencensus.io v0.23.0/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E= -go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= -go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= -go.uber.org/zap v1.17.0/go.mod h1:MXVU+bhUf/A7Xi2HNOnopQOrmycQ5Ih87HtOu4q5SSo= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic= +github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= go.yaml.in/yaml/v4 v4.0.0-rc.5 h1:JVliQq9EGOYaTgMi+k8BhUJyqcGk4ZqeuiN1Cirba9c= go.yaml.in/yaml/v4 v4.0.0-rc.5/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0= -golang.org/x/crypto v0.0.0-20181029021203-45a5f77698d3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= -golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= -golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= -golang.org/x/exp v0.0.0-20190731235908-ec7cb31e5a56/go.mod h1:JhuoJpWY28nO4Vef9tZUw9qufEGTyX1+7lmHxV5q5G4= -golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek= -golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY= -golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= -golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= -golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= -golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= -golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= -golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= -golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= -golang.org/x/image v0.18.0 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ= -golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E= -golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= -golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= -golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= -golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs= -golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= -golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= -golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= -golang.org/x/lint v0.0.0-20210508222113-6edffad5e616/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= -golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= -golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= -golang.org/x/mobile v0.0.0-20211207041440-4e6c2922fdee/go.mod h1:pe2sM7Uk+2Su1y7u/6Z8KJ24D7lepUjFZbhFOrmDfuQ= -golang.org/x/mobile v0.0.0-20231127183840-76ac6878050a h1:sYbmY3FwUWCBTodZL1S3JUuOvaW6kM2o+clDzzDNBWg= -golang.org/x/mobile v0.0.0-20231127183840-76ac6878050a/go.mod h1:Ede7gF0KGoHlj822RtphAHK1jLdrcuRBZg0sF1Q+SPc= -golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= -golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= -golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= -golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= -golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20181023162649-9b4f9f5ad519/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20181201002055-351d144fa1fc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= -golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= -golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= -golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= -golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20210316092652-d523dce5a7f4/go.mod h1:RBQZq4jEuRlivfhVLdyRGr576XBO4/greRjx4P4O3yc= -golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= -golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac= -golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= -golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= -golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20201109201403-9fd604954f58/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20210220000619-9bb904979d93/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20210313182246-cd4f82c27b84/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20210402161424-2e8d93401602/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20181026203630-95b1ffbd15a5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210220050731-9a76102bfb43/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210305230114-8fe3ee5dd75b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210315160823-c6e025ad8005/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210403161142-5e06dd20ab57/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= -golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= -golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= -golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= -golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= -golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= -golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= -golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= -golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= -golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= -golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= -golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191112195655-aa38f8e97acc/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= -golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= -golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= -golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8= -golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= -golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= -golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= -golang.org/x/tools v0.0.0-20200904185747-39188db58858/go.mod h1:Cj7w3i3Rnn0Xh82ur9kSqwfTHTeVxaDqrfMjpcNT6bE= -golang.org/x/tools v0.0.0-20201110124207-079ba7bd75cd/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.0.0-20201201161351-ac6f37ff4c2a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.0.0-20201208233053-a543418bbed2/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.0.0-20210105154028-b0ab187a4818/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0= -golang.org/x/tools v0.1.2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= -golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= -golang.org/x/tools v0.1.8-0.20211022200916-316ba0b74098/go.mod h1:LGqMHiF4EqQNHR1JncWGqT5BVaXmza+X+BDGol+dOxo= -golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= -google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= -google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= -google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= -google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= -google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= -google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= -google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= -google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= -google.golang.org/api v0.19.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= -google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= -google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= -google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= -google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= -google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM= -google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc= -google.golang.org/api v0.35.0/go.mod h1:/XrVsuzM0rZmrsbjJutiuftIzeuTQcEeaYcSk/mQ1dg= -google.golang.org/api v0.36.0/go.mod h1:+z5ficQTmoYpPn8LCUNVpK5I7hwkpjbcgqA7I34qYtE= -google.golang.org/api v0.40.0/go.mod h1:fYKFpnQN0DsDSKRVRcQSDQNtqWPfM9i+zNPxepjRCQ8= -google.golang.org/api v0.41.0/go.mod h1:RkxM5lITDfTzmyKFPt+wGrCJbVfniCr2ool8kTBzRTU= -google.golang.org/api v0.43.0/go.mod h1:nQsDGjRXMo4lvh5hP0TKqF244gqhGcr/YSIykhUk/94= -google.golang.org/api v0.44.0/go.mod h1:EBOGZqzyhtvMDoxwS97ctnh0zUmYY6CxqXsc1AvkYD8= -google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= -google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= -google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= -google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= -google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= -google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= -google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= -google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= -google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= -google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= -google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8= -google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= -google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA= -google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200513103714-09dca8ec2884/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U= -google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= -google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA= -google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20200904004341-0bd0a958aa1d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20201109203340-2640f1f9cdfb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20201201144952-b05cb90ed32e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20201210142538-e3217bee35cc/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20201214200347-8c77b98c765d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20210222152913-aa3ee6e6a81c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20210303154014-9728d6b83eeb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20210310155132-4ce2db91004e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20210319143718-93e7006c17a6/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20210402141018-6c239bbf2bb1/go.mod h1:9lPAdzaEmUacj36I+k7YKbEc5CXzPIeORRgDAUOu28A= -google.golang.org/genproto v0.0.0-20210602131652-f16073e35f0c/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0= -google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= -google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= -google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= -google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= -google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= -google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= -google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= -google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= -google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60= -google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk= -google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= -google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= -google.golang.org/grpc v1.31.1/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= -google.golang.org/grpc v1.33.1/go.mod h1:fr5YgcSWrqhRRxogOsw7RzIpsmvOZ6IcH4kBYTpR3n0= -google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= -google.golang.org/grpc v1.34.0/go.mod h1:WotjhfgOW/POjDeRt8vscBtXq+2VjORFy659qA51WJ8= -google.golang.org/grpc v1.35.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= -google.golang.org/grpc v1.36.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= -google.golang.org/grpc v1.36.1/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= -google.golang.org/grpc v1.38.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM= -google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= -google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= -google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= -google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= -google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= -google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4= -google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= -google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= -google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +golang.org/x/image v0.24.0 h1:AN7zRgVsbvmTfNyqIbbOraYL8mSwcKncEj8ofjgzcMQ= +golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8= +golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= +golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= +golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= +golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= +golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU= gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= -gopkg.in/ini.v1 v1.62.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= -gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= -gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= -honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= -honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= -rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= -rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= -rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= diff --git a/scripts/build-linux-docker.sh b/scripts/build-linux-docker.sh index 27aa460..9f267db 100644 --- a/scripts/build-linux-docker.sh +++ b/scripts/build-linux-docker.sh @@ -4,7 +4,7 @@ set -euo pipefail # Optional first argument mirrors build-linux.sh. The Docker build still writes # the final artifact into the local dist/ tree, not into the container. The # default includes the application version and target platform. -version="$(sed -n 's/^var Version = "\(.*\)"/\1/p' src/core/version.go)" +version="$(sed -n 's/^var Version = "\(.*\)"/\1/p' src/app/version.go)" version="${version:-0.0.0-dev}" tag="gitea.mixdep.ru/mix/gosentry-builder:${version}" output="${1:-dist/linux/gosentry-${version}-linux-amd64}" @@ -26,7 +26,7 @@ docker run --rm \ -v "$(pwd):/src" \ -w /src \ "$tag" \ - bash -c 'CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build -buildvcs=false -trimpath -ldflags "-s -w -X gitea.mixdep.ru/mix/gosentry/src/core.Version=${VERSION}" -o "${OUTPUT}" ./cmd/gosentry' + bash -c 'CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build -buildvcs=false -trimpath -ldflags "-s -w -X gitea.mixdep.ru/mix/gosentry/src/app.Version=${VERSION}" -o "${OUTPUT}" ./cmd/gosentry' # Icons are embedded in the Go binary, so there is no assets directory to copy # after extracting the Linux executable. diff --git a/scripts/build-linux.sh b/scripts/build-linux.sh index 375d67f..23b16d3 100644 --- a/scripts/build-linux.sh +++ b/scripts/build-linux.sh @@ -3,7 +3,7 @@ set -euo pipefail # Optional first argument lets a developer or CI job choose the output path. The # default includes the application version and target platform. -version="$(sed -n 's/^var Version = "\(.*\)"/\1/p' src/core/version.go)" +version="$(sed -n 's/^var Version = "\(.*\)"/\1/p' src/app/version.go)" version="${version:-0.0.0-dev}" output="${1:-dist/linux/gosentry-${version}-linux-amd64}" mkdir -p "$(dirname "$output")" @@ -17,7 +17,7 @@ export GOARCH=amd64 # -trimpath removes local machine paths from debug/build metadata. -s -w strips # symbol/debug tables to keep the desktop binary smaller. -go build -trimpath -ldflags "-s -w -X gitea.mixdep.ru/mix/gosentry/src/core.Version=${version}" -o "$output" ./cmd/gosentry +go build -trimpath -ldflags "-s -w -X gitea.mixdep.ru/mix/gosentry/src/app.Version=${version}" -o "$output" ./cmd/gosentry # The application icon is embedded by Go, so the Linux build does not need a # sidecar assets directory beside the executable. diff --git a/scripts/build-release-linux.sh b/scripts/build-release-linux.sh index 4c1aca5..b3f8dea 100644 --- a/scripts/build-release-linux.sh +++ b/scripts/build-release-linux.sh @@ -9,7 +9,7 @@ script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" repo_root="$(cd "${script_dir}/.." && pwd)" cd "$repo_root" -version="$(sed -n 's/^var Version = "\(.*\)"/\1/p' src/core/version.go)" +version="$(sed -n 's/^var Version = "\(.*\)"/\1/p' src/app/version.go)" version="${version:-0.0.0-dev}" tag="gitea.mixdep.ru/mix/gosentry-builder:${version}" @@ -99,15 +99,15 @@ run_in_builder() { } build_linux_amd64() { - run_in_builder 'mkdir -p dist/linux && CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build -buildvcs=false -trimpath -ldflags "-s -w -X gitea.mixdep.ru/mix/gosentry/src/core.Version=${VERSION}" -o "dist/linux/gosentry-${VERSION}-linux-amd64" ./cmd/gosentry' + run_in_builder 'mkdir -p dist/linux && CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build -buildvcs=false -trimpath -ldflags "-s -w -X gitea.mixdep.ru/mix/gosentry/src/app.Version=${VERSION}" -o "dist/linux/gosentry-${VERSION}-linux-amd64" ./cmd/gosentry' } build_linux_arm64() { - run_in_builder 'mkdir -p dist/linux && CC=aarch64-linux-gnu-gcc CGO_ENABLED=1 GOOS=linux GOARCH=arm64 CGO_CFLAGS="--sysroot=/ -I/usr/include/aarch64-linux-gnu" CGO_LDFLAGS="--sysroot=/ -L/usr/lib/aarch64-linux-gnu" PKG_CONFIG_LIBDIR=/usr/lib/aarch64-linux-gnu/pkgconfig go build -buildvcs=false -trimpath -ldflags "-s -w -X gitea.mixdep.ru/mix/gosentry/src/core.Version=${VERSION}" -o "dist/linux/gosentry-${VERSION}-linux-arm64" ./cmd/gosentry' + run_in_builder 'mkdir -p dist/linux && CC=aarch64-linux-gnu-gcc CGO_ENABLED=1 GOOS=linux GOARCH=arm64 CGO_CFLAGS="--sysroot=/ -I/usr/include/aarch64-linux-gnu" CGO_LDFLAGS="--sysroot=/ -L/usr/lib/aarch64-linux-gnu" PKG_CONFIG_LIBDIR=/usr/lib/aarch64-linux-gnu/pkgconfig go build -buildvcs=false -trimpath -ldflags "-s -w -X gitea.mixdep.ru/mix/gosentry/src/app.Version=${VERSION}" -o "dist/linux/gosentry-${VERSION}-linux-arm64" ./cmd/gosentry' } build_windows_amd64() { - run_in_builder 'mkdir -p dist/windows && x86_64-w64-mingw32-windres -O coff -o cmd/gosentry/rsrc_windows_amd64.syso packaging/windows/gosentry.rc && CC=x86_64-w64-mingw32-gcc CGO_ENABLED=1 GOOS=windows GOARCH=amd64 go build -buildvcs=false -trimpath -ldflags "-s -w -H=windowsgui -X gitea.mixdep.ru/mix/gosentry/src/core.Version=${VERSION}" -o "dist/windows/gosentry-${VERSION}-windows-amd64.exe" ./cmd/gosentry' + run_in_builder 'mkdir -p dist/windows && x86_64-w64-mingw32-windres -O coff -o cmd/gosentry/rsrc_windows_amd64.syso packaging/windows/gosentry.rc && CC=x86_64-w64-mingw32-gcc CGO_ENABLED=1 GOOS=windows GOARCH=amd64 go build -buildvcs=false -trimpath -ldflags "-s -w -H=windowsgui -X gitea.mixdep.ru/mix/gosentry/src/app.Version=${VERSION}" -o "dist/windows/gosentry-${VERSION}-windows-amd64.exe" ./cmd/gosentry' } mapfile -t targets < <(choose_targets "$@" | normalize_targets | awk '!seen[$0]++') diff --git a/scripts/build-windows.bat b/scripts/build-windows.bat index 1bc9483..d34870c 100644 --- a/scripts/build-windows.bat +++ b/scripts/build-windows.bat @@ -6,7 +6,7 @@ REM directory. Move to the repository root (the parent of scripts\) before using REM relative paths such as .\cmd\gosentry and packaging\windows\gosentry.rc. cd /d "%~dp0\.." -for /f "tokens=4" %%V in ('findstr /C:"var Version" src\core\version.go') do set "VERSION=%%~V" +for /f "tokens=4" %%V in ('findstr /C:"var Version" src\app\version.go') do set "VERSION=%%~V" if "%VERSION%"=="" set "VERSION=0.0.0-dev" set "VERSION=%VERSION:"=%" @@ -47,7 +47,7 @@ if %ERRORLEVEL%==0 ( REM -trimpath removes local machine paths from the binary, -s -w reduce binary REM size, and -H=windowsgui prevents a separate console window from opening when REM the GUI app starts from Explorer or a shortcut. -"%GOEXE%" build -trimpath -ldflags "-s -w -H=windowsgui -X gitea.mixdep.ru/mix/gosentry/src/core.Version=%VERSION%" -o "%OUTPUT%" .\cmd\gosentry +"%GOEXE%" build -trimpath -ldflags "-s -w -H=windowsgui -X gitea.mixdep.ru/mix/gosentry/src/app.Version=%VERSION%" -o "%OUTPUT%" .\cmd\gosentry if errorlevel 1 exit /b 1 REM Icons are embedded into the executable, so no assets directory is copied next diff --git a/scripts/test.bat b/scripts/test.bat new file mode 100644 index 0000000..e2a724d --- /dev/null +++ b/scripts/test.bat @@ -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 diff --git a/scripts/test.sh b/scripts/test.sh new file mode 100644 index 0000000..33b381d --- /dev/null +++ b/scripts/test.sh @@ -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" diff --git a/src/app/events.go b/src/app/events.go new file mode 100644 index 0000000..5fe3ac9 --- /dev/null +++ b/src/app/events.go @@ -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) + } +} diff --git a/src/app/events_test.go b/src/app/events_test.go new file mode 100644 index 0000000..1c84abf --- /dev/null +++ b/src/app/events_test.go @@ -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") + } +} diff --git a/src/app/format.go b/src/app/format.go new file mode 100644 index 0000000..b44989e --- /dev/null +++ b/src/app/format.go @@ -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 +} diff --git a/src/app/format_test.go b/src/app/format_test.go new file mode 100644 index 0000000..016bd7b --- /dev/null +++ b/src/app/format_test.go @@ -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) + } +} diff --git a/src/app/operations.go b/src/app/operations.go new file mode 100644 index 0000000..a932e7e --- /dev/null +++ b/src/app/operations.go @@ -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 +} diff --git a/src/app/operations_test.go b/src/app/operations_test.go new file mode 100644 index 0000000..e67e178 --- /dev/null +++ b/src/app/operations_test.go @@ -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) + } +} diff --git a/src/app/platform.go b/src/app/platform.go new file mode 100644 index 0000000..deedf6f --- /dev/null +++ b/src/app/platform.go @@ -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) +} diff --git a/src/app/service.go b/src/app/service.go new file mode 100644 index 0000000..5ebf990 --- /dev/null +++ b/src/app/service.go @@ -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] +} diff --git a/src/app/service_test.go b/src/app/service_test.go new file mode 100644 index 0000000..b43bc55 --- /dev/null +++ b/src/app/service_test.go @@ -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") + } +} diff --git a/src/core/version.go b/src/app/version.go similarity index 85% rename from src/core/version.go rename to src/app/version.go index 50c98b9..f8402bb 100644 --- a/src/core/version.go +++ b/src/app/version.go @@ -1,6 +1,6 @@ -package core +package app // Version is the application version shown in the GUI and used by build // scripts in artifact names. It is a var rather than a const so release builds // can override it with Go ldflags when CI tags a build. -var Version = "0.3.2" +var Version = "0.4.0" diff --git a/src/core/autostart_other.go b/src/core/autostart_other.go deleted file mode 100644 index b9ac296..0000000 --- a/src/core/autostart_other.go +++ /dev/null @@ -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" -} diff --git a/src/core/model.go b/src/core/model.go deleted file mode 100644 index 2c023de..0000000 --- a/src/core/model.go +++ /dev/null @@ -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"` -} diff --git a/src/core/runner.go b/src/core/runner.go deleted file mode 100644 index e70fd4a..0000000 --- a/src/core/runner.go +++ /dev/null @@ -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 "" - } - 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 = "" - } - if stderr == "" { - stderr = "" - } - return "stdout:\n" + stdout + "\n\nstderr:\n" + stderr -} diff --git a/src/core/scheduler.go b/src/core/scheduler.go deleted file mode 100644 index 004bec4..0000000 --- a/src/core/scheduler.go +++ /dev/null @@ -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 -} diff --git a/src/core/scheduler_test.go b/src/core/scheduler_test.go deleted file mode 100644 index ed0267d..0000000 --- a/src/core/scheduler_test.go +++ /dev/null @@ -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) - } - } -} diff --git a/src/core/store_test.go b/src/core/store_test.go deleted file mode 100644 index 5fc84f2..0000000 --- a/src/core/store_test.go +++ /dev/null @@ -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) - } - } -} diff --git a/src/domain/config.go b/src/domain/config.go new file mode 100644 index 0000000..3bfb3b1 --- /dev/null +++ b/src/domain/config.go @@ -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"` +} diff --git a/src/domain/job.go b/src/domain/job.go new file mode 100644 index 0000000..0731713 --- /dev/null +++ b/src/domain/job.go @@ -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"` +} diff --git a/src/domain/record.go b/src/domain/record.go new file mode 100644 index 0000000..4ec8b03 --- /dev/null +++ b/src/domain/record.go @@ -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"` +} diff --git a/src/domain/runtime.go b/src/domain/runtime.go new file mode 100644 index 0000000..35934be --- /dev/null +++ b/src/domain/runtime.go @@ -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 +} diff --git a/src/domain/schedule.go b/src/domain/schedule.go new file mode 100644 index 0000000..bc06f58 --- /dev/null +++ b/src/domain/schedule.go @@ -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 " 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 " 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 +} diff --git a/src/domain/schedule_test.go b/src/domain/schedule_test.go new file mode 100644 index 0000000..866a2fa --- /dev/null +++ b/src/domain/schedule_test.go @@ -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) + } +} diff --git a/src/gui/app.go b/src/gui/app.go deleted file mode 100644 index c86ccf9..0000000 --- a/src/gui/app.go +++ /dev/null @@ -1,1057 +0,0 @@ -package gui - -import ( - "fmt" - "io" - "net" - "net/url" - "runtime" - "runtime/debug" - "sort" - "strconv" - "strings" - "time" - - "gitea.mixdep.ru/mix/gosentry/assets" - "gitea.mixdep.ru/mix/gosentry/src/core" - - "fyne.io/fyne/v2" - "fyne.io/fyne/v2/app" - "fyne.io/fyne/v2/container" - "fyne.io/fyne/v2/dialog" - "fyne.io/fyne/v2/driver/desktop" - "fyne.io/fyne/v2/layout" - "fyne.io/fyne/v2/theme" - "fyne.io/fyne/v2/widget" -) - -const appID = "ru.mixdep.gosentry.desktop" -const allFolders = "All" -const noFolder = "No folder" -const minJobsSidebarWidth float32 = 480 -const settingsLabelWidth float32 = 140 -const settingsControlWidth float32 = 330 -const settingsStatusWidth float32 = 280 -const projectRepositoryURL = "https://gitea.mixdep.ru/mix/gosentry" -const singleInstanceAddress = "127.0.0.1:37653" -const singleInstanceShowCommand = "show" - -// The GUI package aliases core types to keep widget callbacks short. The actual -// durable model still lives in src/core, so GUI code does not define a second -// copy of the scheduler data. -type job = core.Job -type event = core.RunRecord - -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 := app.NewWithID(appID) - a.SetIcon(loadAppIcon()) - - w := a.NewWindow("GoSentry " + core.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() -} - -func configureSystemTray(a fyne.App, w fyne.Window) { - desk, ok := a.(desktop.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 - } - - menu := fyne.NewMenu("GoSentry", - fyne.NewMenuItem("Show", func() { - w.Show() - w.RequestFocus() - }), - fyne.NewMenuItemSeparator(), - fyne.NewMenuItem("Quit", func() { - a.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() - }) -} - -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 - } - w.Show() - w.RequestFocus() - } - }() -} - -func newMainView(w fyne.Window) (fyne.CanvasObject, func(time.Duration, bool)) { - store, jobs, err := core.OpenStore() - if err != nil { - return container.NewPadded(widget.NewLabel("Failed to load GoSentry configuration: " + err.Error())), func(time.Duration, bool) {} - } - if iconPath, err := core.InstallDesktopIntegration(appID, store.Paths.ExecutablePath, assets.IconBytes()); err == nil { - store.Paths.DesktopIcon = iconPath - } - events := collectActivity(jobs) - - // The GUI keeps the loaded jobs slice in memory and persists changes after - // each edit/run. This keeps the first version responsive and easy to reason - // about; a database would be unnecessary overhead for one YAML file. - nextJobID := nextID(jobs) - 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 - folder := newJobDetailLabel(jobs[selected].Folder) - schedule := newJobDetailLabel(jobs[selected].Schedule) - command := newJobDetailLabel(jobs[selected].Command) - arguments := newJobDetailLabel(jobs[selected].Arguments) - successExitCodes := newJobDetailLabel(displaySuccessExitCodes(jobs[selected].SuccessExitCodes)) - runMode := newJobDetailLabel(displayRunMode(jobs[selected])) - lastRun := newJobDetailLabel(jobs[selected].LastRun) - nextRun := newJobDetailLabel(jobs[selected].NextRun) - state := newJobDetailLabel(jobs[selected].LastState) - schedulerState := widget.NewLabel("Scheduler running") - commandOutput := widget.NewTextGrid() - commandOutput.SetText(jobs[selected].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)) - 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() - } - selectedLogs := append([]event(nil), jobs[selected].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(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") - folder.SetText("") - schedule.SetText("") - command.SetText("") - arguments.SetText("") - successExitCodes.SetText("") - runMode.SetText("") - lastRun.SetText("") - nextRun.SetText("") - state.SetText("") - commandOutput.SetText("") - selectedLogs = nil - return - } - selected = index - current := jobs[selected] - title.SetText(current.Name) - folder.SetText(displayFolder(current.Folder)) - schedule.SetText(current.Schedule) - command.SetText(current.Command) - arguments.SetText(displayArguments(current.Arguments)) - successExitCodes.SetText(displaySuccessExitCodes(current.SuccessExitCodes)) - runMode.SetText(displayRunMode(current)) - lastRun.SetText(current.LastRun) - nextRun.SetText(current.NextRun) - state.SetText(current.LastState) - commandOutput.SetText(current.Output) - selectedLogs = append(selectedLogs[:0], current.Logs...) - } - refresh := func() { - // Several callbacks mutate jobs, filters, and event history. A single - // refresh closure keeps the different widgets synchronized after each - // mutation without introducing a heavier state-management layer. - filteredJobs = filteredJobIndexes(jobs, selectedFolder) - updateDetails(selected) - jobLogs.Refresh() - history.Refresh() - } - var scheduler *core.Scheduler - - 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(displayFolder(current.Folder) + " " + current.Schedule + " " + displayInvocation(current)) - status.SetText(statusText(current)) - }, - ) - 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) - refresh() - }) - 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, LastRun: "Never", NextRun: "After save", LastState: "Ready"}, func(saved job) { - saved.ID = nextJobID - nextJobID++ - jobs = append(jobs, saved) - selected = len(jobs) - 1 - created := newEvent(saved.ID, saved.Name, "Created", "Job was added") - // UI events are kept in memory for the current session. They explain - // user actions in History, while command output remains in log files. - jobs[selected].Logs = append([]event{created}, jobs[selected].Logs...) - events = append(events, created) - _ = store.SaveJobs(jobs) - folderSelect.Options = folderOptions(jobs) - folderSelect.Refresh() - targetFolder := filterValue(saved.Folder) - if selectedFolder != allFolders && selectedFolder != targetFolder { - selectedFolder = targetFolder - folderSelect.SetSelected(targetFolder) - } - filteredJobs = filteredJobIndexes(jobs, selectedFolder) - list.Refresh() - list.Select(displayIndex(filteredJobs, selected)) - refresh() - }) - }) - editButton := widget.NewButtonWithIcon("Edit", theme.DocumentCreateIcon(), func() { - if selected < 0 || selected >= len(jobs) { - return - } - showJobDialog(w, "Edit job", jobs[selected], func(saved job) { - saved.ID = jobs[selected].ID - saved.Logs = jobs[selected].Logs - saved.Output = jobs[selected].Output - jobs[selected] = saved - updated := newEvent(saved.ID, saved.Name, "Updated", "Job settings changed") - jobs[selected].Logs = append([]event{updated}, jobs[selected].Logs...) - events = append(events, updated) - if scheduler != nil { - scheduler.RefreshSchedule(selected) - } - _ = store.SaveJobs(jobs) - folderSelect.Options = folderOptions(jobs) - folderSelect.Refresh() - list.Refresh() - refresh() - }) - }) - 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 - } - if !scheduler.RunNow(selected) { - return - } - list.Refresh() - refresh() - }) - stopAllButton := widget.NewButtonWithIcon("Pause all", theme.MediaStopIcon(), nil) - stopAllButton.OnTapped = func() { - schedulerPaused = !schedulerPaused - if schedulerPaused { - schedulerState.SetText("Scheduler paused") - stopAllButton.SetText("Resume all") - stopAllButton.SetIcon(theme.MediaPlayIcon()) - for index := range jobs { - if jobs[index].Enabled { - jobs[index].NextRun = "Scheduler paused" - } - } - if scheduler != nil { - scheduler.SetPaused(true) - } - events = append(events, newEvent(0, "Scheduler", "Paused", "All job execution paused")) - } else { - schedulerState.SetText("Scheduler running") - stopAllButton.SetText("Pause all") - stopAllButton.SetIcon(theme.MediaStopIcon()) - for index := range jobs { - if jobs[index].Enabled && jobs[index].NextRun == "Scheduler paused" { - // The scheduler will calculate the exact next run when it is - // resumed; this interim text prevents a stale paused timestamp. - jobs[index].NextRun = "Waiting for scheduler" - } - } - if scheduler != nil { - scheduler.SetPaused(false) - } - events = append(events, newEvent(0, "Scheduler", "Resumed", "All job execution resumed")) - } - list.Refresh() - refresh() - } - pauseButton := widget.NewButtonWithIcon("Pause", theme.MediaPauseIcon(), func() { - if selected < 0 || selected >= len(jobs) { - return - } - current := &jobs[selected] - current.Enabled = !current.Enabled - if current.Enabled { - current.LastState = "Ready" - current.NextRun = "Waiting for scheduler" - resumed := newEvent(current.ID, current.Name, "Resumed", "Job was enabled") - current.Logs = append([]event{resumed}, current.Logs...) - events = append(events, resumed) - if scheduler != nil { - scheduler.RefreshSchedule(selected) - } - } else { - current.LastState = "Paused" - current.NextRun = "Paused" - paused := newEvent(current.ID, current.Name, "Paused", "Job was disabled") - current.Logs = append([]event{paused}, current.Logs...) - events = append(events, paused) - if scheduler != nil { - scheduler.RefreshSchedule(selected) - } - } - _ = store.SaveJobs(jobs) - list.Refresh() - refresh() - }) - 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 - } - jobs = append(jobs[:selected], jobs[selected+1:]...) - 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] - } - events = append(events, newEvent(deleted.ID, deleted.Name, "Deleted", "Job was removed")) - _ = store.SaveJobs(jobs) - list.Refresh() - if selected >= 0 { - list.Select(displayIndex(filteredJobs, selected)) - } - refresh() - }, 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", folder), - detailRow("Schedule", schedule), - detailRow("Command", command), - detailRow("Arguments", arguments), - detailRow("Success exit codes", successExitCodes), - detailRow("Run mode", runMode), - detailRow("Last run", lastRun), - detailRow("Next run", nextRun), - detailRow("State", state), - 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, - ) - - scheduler = core.NewScheduler(store, &jobs, func(record core.RunRecord) { - // Scheduled runs happen on the scheduler goroutine. The callback updates - // the shared in-memory event list so History reflects background activity. - events = append(events, record) - refresh() - }) - scheduler.Start() - - fixedSidebar := container.New(minWidthLayout{width: minJobsSidebarWidth}, sidebar) - jobsView := container.NewBorder(nil, nil, fixedSidebar, nil, container.NewPadded(details)) - tabs := container.NewAppTabs( - container.NewTabItemWithIcon("Jobs", theme.ListIcon(), jobsView), - container.NewTabItemWithIcon("History", theme.HistoryIcon(), history), - container.NewTabItemWithIcon("Settings", theme.SettingsIcon(), settingsView(w, store, &jobs)), - ) - tabs.SetTabLocation(container.TabLocationTop) - - return tabs, recordStartup -} - -type minWidthLayout struct { - width float32 -} - -func (layout minWidthLayout) MinSize(objects []fyne.CanvasObject) fyne.Size { - width := layout.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 (layout 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) - } -} - -func statusText(j job) string { - if !j.Enabled { - return "Paused" - } - return j.LastState -} - -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 eventText(e event) 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) -} - -func collectActivity(jobs []job) []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. - events = append(events, current.Logs...) - } - sort.SliceStable(events, func(left int, right int) bool { - return events[left].Time < events[right].Time - }) - return events -} - -func nextID(jobs []job) int { - next := 1 - for _, current := range jobs { - if current.ID >= next { - next = current.ID + 1 - } - } - return next -} - -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 -} - -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)) -} - -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 displayFolder(folder string) string { - if strings.TrimSpace(folder) == "" { - return "(" + noFolder + ")" - } - return strings.TrimSpace(folder) -} - -func displayArguments(arguments string) string { - if strings.TrimSpace(arguments) == "" { - return "(none)" - } - return strings.TrimSpace(arguments) -} - -func displaySuccessExitCodes(codes string) string { - if strings.TrimSpace(codes) == "" { - return "0" - } - return strings.TrimSpace(codes) -} - -func displayRunMode(current job) string { - if current.StartOnly { - return "Start only" - } - return "Wait for completion" -} - -func displayInvocation(current job) string { - if strings.TrimSpace(current.Arguments) == "" { - return current.Command - } - return current.Command + " " + strings.ReplaceAll(strings.TrimSpace(current.Arguments), "\n", " ") -} - -func displayIndex(indexes []int, jobIndex int) int { - for display, index := range indexes { - if index == jobIndex { - return display - } - } - return 0 -} - -func showJobDialog(w fyne.Window, title string, current job, onSave func(job)) { - name := widget.NewEntry() - name.SetPlaceHolder("Nightly backup") - name.SetText(current.Name) - folder := widget.NewEntry() - folder.SetPlaceHolder("Maintenance") - folder.SetText(current.Folder) - schedule := widget.NewEntry() - schedule.SetPlaceHolder("@every 1m") - schedule.SetText(current.Schedule) - command := widget.NewEntry() - command.SetPlaceHolder(`C:\Program Files\App\App.exe`) - command.SetText(current.Command) - arguments := widget.NewMultiLineEntry() - arguments.SetPlaceHolder(`D:\Local\Jobs\Auto.ffs_batch`) - arguments.SetText(current.Arguments) - successExitCodes := widget.NewEntry() - successExitCodes.SetPlaceHolder("0") - successExitCodes.SetText(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", folder), - widget.NewFormItem("Schedule", schedule), - widget.NewFormItem("Command", command), - widget.NewFormItem("Arguments", arguments), - widget.NewFormItem("Success exit codes", successExitCodes), - widget.NewFormItem("", startOnly), - widget.NewFormItem("", enabled), - }, - func(saved bool) { - if !saved { - return - } - if strings.TrimSpace(name.Text) == "" || strings.TrimSpace(schedule.Text) == "" || strings.TrimSpace(command.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 - } - current.Name = strings.TrimSpace(name.Text) - current.Folder = strings.TrimSpace(folder.Text) - current.Schedule = strings.TrimSpace(schedule.Text) - current.Command = strings.TrimSpace(command.Text) - current.Arguments = strings.TrimSpace(arguments.Text) - current.SuccessExitCodes = strings.TrimSpace(successExitCodes.Text) - if current.SuccessExitCodes == "" { - current.SuccessExitCodes = "0" - } - current.StartOnly = startOnly.Checked - current.Enabled = enabled.Checked - if current.LastRun == "" { - current.LastRun = "Never" - } - if current.Enabled { - current.NextRun = "Waiting for scheduler" - if current.LastState == "" || current.LastState == "Paused" { - current.LastState = "Ready" - } - } else { - current.NextRun = "Paused" - current.LastState = "Paused" - } - onSave(current) - }, - w, - ) - form.Resize(fyne.NewSize(640, 460)) - form.Show() -} - -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 -} - -func settingsView(w fyne.Window, store *core.Store, jobs *[]job) fyne.CanvasObject { - startOnLogin := widget.NewCheck("Start on login", nil) - startOnLogin.SetChecked(store.Config.StartOnLogin) - autostartStatus := widget.NewLabel("") - refreshAutostartStatus := func() { - ok, message := core.AutostartStatus(store.Config.StartOnLogin, store.Paths.ExecutablePath) - 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 - } - store.Config.LogsDir = strings.TrimSpace(logsDir.Text) - if strings.TrimSpace(jobsDir.Text) == "" { - settingsStatus.SetText("Jobs directory is required") - return - } - if strings.TrimSpace(logsDir.Text) == "" { - settingsStatus.SetText("Logs directory is required") - return - } - store.Config.JobsDir = strings.TrimSpace(jobsDir.Text) - store.Config.MaxLogFiles = files - store.Config.MaxLogAgeDays = days - store.Config.StartOnLogin = startOnLogin.Checked - store.Config.KeepRunningInTray = minimizeToTray.Checked - store.Config.NotifyOnFailure = notifications.Checked - if err := store.SaveConfig(); err != nil { - settingsStatus.SetText("Save failed: " + err.Error()) - return - } - if err := core.SetAutostart(store.Config.StartOnLogin, store.Paths.ExecutablePath, store.Paths.DesktopIcon); err != nil { - refreshAutostartStatus() - settingsStatus.SetText("Saved, autostart failed: " + err.Error()) - return - } - refreshAutostartStatus() - // When the jobs directory changes, save the currently loaded jobs to the - // newly resolved path immediately. That makes the setting visible on disk - // without requiring a restart or a separate migration command. - if err := store.SaveJobs(*jobs); err != nil { - settingsStatus.SetText("Jobs save failed: " + err.Error()) - return - } - // Cleanup runs on settings save so a user who tightens retention limits - // sees the new policy take effect right away. - if err := core.CleanupLogs(store.Paths.LogsDir, store.Config.MaxLogFiles, store.Config.MaxLogAgeDays); err != nil { - settingsStatus.SetText("Saved, cleanup failed: " + err.Error()) - return - } - 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(core.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() -} diff --git a/src/platform/autostart/autostart.go b/src/platform/autostart/autostart.go new file mode 100644 index 0000000..5139f16 --- /dev/null +++ b/src/platform/autostart/autostart.go @@ -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) +} diff --git a/src/core/autostart_linux.go b/src/platform/autostart/autostart_linux.go similarity index 89% rename from src/core/autostart_linux.go rename to src/platform/autostart/autostart_linux.go index a657b7e..5d4dd6d 100644 --- a/src/core/autostart_linux.go +++ b/src/platform/autostart/autostart_linux.go @@ -1,6 +1,6 @@ //go:build linux -package core +package autostart import ( "fmt" @@ -9,8 +9,23 @@ import ( "path/filepath" "strconv" "strings" + + "gitea.mixdep.ru/mix/gosentry/src/domain" ) +type linuxManager struct{} + +// New returns the Linux autostart Manager. +func New() Manager { return linuxManager{} } + +func (linuxManager) Set(enabled bool, executablePath, iconPath string) error { + return SetAutostart(enabled, executablePath, iconPath) +} + +func (linuxManager) Status(expectedEnabled bool, executablePath string) (bool, string) { + return AutostartStatus(expectedEnabled, executablePath) +} + const autostartDesktopFileName = "gosentry.desktop" const legacyAutostartDesktopFileName = "pysentry.desktop" @@ -43,7 +58,7 @@ Exec=%s %s %s Terminal=false X-GNOME-Autostart-enabled=true -`, quoteDesktopExec(executablePath), StartInTrayArgument, desktopIconLine(iconPath)) +`, quoteDesktopExec(executablePath), domain.StartInTrayArgument, desktopIconLine(iconPath)) return os.WriteFile(desktopPath, []byte(desktopFile), 0o644) } @@ -75,7 +90,7 @@ func AutostartStatus(expectedEnabled bool, executablePath string) (bool, string) if readErr != nil { return false, "Autostart desktop entry is missing" } - expectedExec := "Exec=" + quoteDesktopExec(executablePath) + " " + StartInTrayArgument + expectedExec := "Exec=" + quoteDesktopExec(executablePath) + " " + domain.StartInTrayArgument if !strings.Contains(string(data), expectedExec) { return false, "Autostart desktop entry points to another executable" } diff --git a/src/core/autostart_linux_test.go b/src/platform/autostart/autostart_linux_test.go similarity index 94% rename from src/core/autostart_linux_test.go rename to src/platform/autostart/autostart_linux_test.go index a0c3234..22ebc1f 100644 --- a/src/core/autostart_linux_test.go +++ b/src/platform/autostart/autostart_linux_test.go @@ -1,12 +1,14 @@ //go:build linux -package core +package autostart import ( "os" "path/filepath" "strings" "testing" + + "gitea.mixdep.ru/mix/gosentry/src/domain" ) func TestLinuxAutostartStartsInTray(t *testing.T) { @@ -26,7 +28,7 @@ func TestLinuxAutostartStartsInTray(t *testing.T) { t.Fatalf("read desktop entry: %v", err) } - expectedExec := "Exec=" + quoteDesktopExec(executablePath) + " " + StartInTrayArgument + expectedExec := "Exec=" + quoteDesktopExec(executablePath) + " " + domain.StartInTrayArgument if !strings.Contains(string(data), expectedExec) { t.Fatalf("desktop entry does not start in tray: %s", data) } diff --git a/src/platform/autostart/autostart_other.go b/src/platform/autostart/autostart_other.go new file mode 100644 index 0000000..96a1010 --- /dev/null +++ b/src/platform/autostart/autostart_other.go @@ -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" +} diff --git a/src/core/autostart_windows.go b/src/platform/autostart/autostart_windows.go similarity index 90% rename from src/core/autostart_windows.go rename to src/platform/autostart/autostart_windows.go index dbf3633..9493b96 100644 --- a/src/core/autostart_windows.go +++ b/src/platform/autostart/autostart_windows.go @@ -1,4 +1,4 @@ -package core +package autostart import ( "fmt" @@ -6,8 +6,24 @@ import ( "os/exec" "path/filepath" "strings" + + "gitea.mixdep.ru/mix/gosentry/src/domain" + "gitea.mixdep.ru/mix/gosentry/src/platform/winproc" ) +type windowsManager struct{} + +// New returns the Windows autostart Manager. +func New() Manager { return windowsManager{} } + +func (windowsManager) Set(enabled bool, executablePath, iconPath string) error { + return SetAutostart(enabled, executablePath, iconPath) +} + +func (windowsManager) Status(expectedEnabled bool, executablePath string) (bool, string) { + return AutostartStatus(expectedEnabled, executablePath) +} + const autostartName = "GoSentry" const legacyAutostartName = "PySentry" const startupShortcutFile = autostartName + ".lnk" @@ -69,7 +85,7 @@ func AutostartStatus(expectedEnabled bool, executablePath string) (bool, string) if !sameWindowsPath(actual, executablePath) { return false, "Autostart shortcut points to another executable" } - if strings.TrimSpace(arguments) != StartInTrayArgument { + if strings.TrimSpace(arguments) != domain.StartInTrayArgument { return false, "Autostart shortcut does not start in tray" } return true, "Autostart is configured" @@ -101,11 +117,11 @@ func createStartupShortcut(shortcutPath string, executablePath string, iconPath command.Env = append(os.Environ(), "GOSENTRY_SHORTCUT_PATH="+shortcutPath, "GOSENTRY_TARGET_PATH="+executablePath, - "GOSENTRY_ARGUMENTS="+StartInTrayArgument, + "GOSENTRY_ARGUMENTS="+domain.StartInTrayArgument, "GOSENTRY_WORKING_DIRECTORY="+workingDirectory, "GOSENTRY_ICON_PATH="+iconPath, ) - configureHiddenWindow(command) + winproc.ConfigureHiddenWindow(command) if output, err := command.CombinedOutput(); err != nil { return fmt.Errorf("create startup shortcut: %w: %s", err, strings.TrimSpace(string(output))) } @@ -123,7 +139,7 @@ func readShortcut(shortcutPath string) (string, string, error) { script := `[Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false); $shell = New-Object -ComObject WScript.Shell; $shortcut = $shell.CreateShortcut($env:GOSENTRY_SHORTCUT_PATH); [Console]::Out.Write($shortcut.TargetPath + [Environment]::NewLine + $shortcut.Arguments)` command := exec.Command("powershell.exe", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", script) command.Env = append(os.Environ(), "GOSENTRY_SHORTCUT_PATH="+shortcutPath) - configureHiddenWindow(command) + winproc.ConfigureHiddenWindow(command) output, err := command.CombinedOutput() if err != nil { return "", "", fmt.Errorf("read startup shortcut: %w: %s", err, strings.TrimSpace(string(output))) @@ -153,7 +169,7 @@ func removeIfExists(path string) error { func cleanupLegacyRegistryAutostart() error { for _, name := range []string{legacyAutostartName, autostartName} { command := exec.Command("reg.exe", "delete", `HKCU\Software\Microsoft\Windows\CurrentVersion\Run`, "/v", name, "/f") - configureHiddenWindow(command) + winproc.ConfigureHiddenWindow(command) _ = command.Run() } return nil @@ -162,7 +178,7 @@ func cleanupLegacyRegistryAutostart() error { func legacyRegistryAutostartExists() bool { for _, name := range []string{legacyAutostartName, autostartName} { command := exec.Command("reg.exe", "query", `HKCU\Software\Microsoft\Windows\CurrentVersion\Run`, "/v", name) - configureHiddenWindow(command) + winproc.ConfigureHiddenWindow(command) if command.Run() == nil { return true } diff --git a/src/core/autostart_windows_test.go b/src/platform/autostart/autostart_windows_test.go similarity index 95% rename from src/core/autostart_windows_test.go rename to src/platform/autostart/autostart_windows_test.go index 99bd0fa..c0a0d66 100644 --- a/src/core/autostart_windows_test.go +++ b/src/platform/autostart/autostart_windows_test.go @@ -1,12 +1,14 @@ //go:build windows -package core +package autostart import ( "os" "path/filepath" "syscall" "testing" + + "gitea.mixdep.ru/mix/gosentry/src/domain" ) func TestParseRegistryRunValue(t *testing.T) { @@ -111,8 +113,8 @@ func TestCreateStartupShortcutHandlesCyrillicPath(t *testing.T) { if !sameWindowsPath(actual, targetPath) { t.Fatalf("shortcut target mismatch: got %q want %q", actual, targetPath) } - if arguments != StartInTrayArgument { - t.Fatalf("shortcut arguments mismatch: got %q want %q", arguments, StartInTrayArgument) + if arguments != domain.StartInTrayArgument { + t.Fatalf("shortcut arguments mismatch: got %q want %q", arguments, domain.StartInTrayArgument) } } @@ -138,7 +140,7 @@ func TestCreateStartupShortcutHandlesSpaces(t *testing.T) { if !sameWindowsPath(actual, targetPath) { t.Fatalf("shortcut target mismatch: got %q want %q", actual, targetPath) } - if arguments != StartInTrayArgument { - t.Fatalf("shortcut arguments mismatch: got %q want %q", arguments, StartInTrayArgument) + if arguments != domain.StartInTrayArgument { + t.Fatalf("shortcut arguments mismatch: got %q want %q", arguments, domain.StartInTrayArgument) } } diff --git a/src/core/desktop_linux.go b/src/platform/desktop/desktop_linux.go similarity index 93% rename from src/core/desktop_linux.go rename to src/platform/desktop/desktop_linux.go index c099bd4..ddda38c 100644 --- a/src/core/desktop_linux.go +++ b/src/platform/desktop/desktop_linux.go @@ -1,11 +1,12 @@ //go:build linux -package core +package desktop import ( "fmt" "os" "path/filepath" + "strconv" ) func InstallDesktopIntegration(appID string, executablePath string, icon []byte) (string, error) { @@ -40,6 +41,10 @@ StartupWMClass=%s return iconPath, nil } +func quoteDesktopExec(path string) string { + return strconv.Quote(path) +} + func xdgDataHome() (string, error) { dataHome := os.Getenv("XDG_DATA_HOME") if dataHome == "" { diff --git a/src/core/desktop_other.go b/src/platform/desktop/desktop_other.go similarity index 89% rename from src/core/desktop_other.go rename to src/platform/desktop/desktop_other.go index 8ce6ce5..dc93542 100644 --- a/src/core/desktop_other.go +++ b/src/platform/desktop/desktop_other.go @@ -1,6 +1,6 @@ //go:build !linux -package core +package desktop func InstallDesktopIntegration(appID string, executablePath string, icon []byte) (string, error) { return "", nil diff --git a/src/platform/winproc/winproc_other.go b/src/platform/winproc/winproc_other.go new file mode 100644 index 0000000..97d5b96 --- /dev/null +++ b/src/platform/winproc/winproc_other.go @@ -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) {} diff --git a/src/platform/winproc/winproc_windows.go b/src/platform/winproc/winproc_windows.go new file mode 100644 index 0000000..ddae028 --- /dev/null +++ b/src/platform/winproc/winproc_windows.go @@ -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 +} diff --git a/src/runner/cleanup.go b/src/runner/cleanup.go new file mode 100644 index 0000000..04571f7 --- /dev/null +++ b/src/runner/cleanup.go @@ -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 +} diff --git a/src/runner/cleanup_test.go b/src/runner/cleanup_test.go new file mode 100644 index 0000000..b55bc01 --- /dev/null +++ b/src/runner/cleanup_test.go @@ -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)) + } +} diff --git a/src/runner/exitcodes.go b/src/runner/exitcodes.go new file mode 100644 index 0000000..9d05433 --- /dev/null +++ b/src/runner/exitcodes.go @@ -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) } diff --git a/src/runner/invocation.go b/src/runner/invocation.go new file mode 100644 index 0000000..b72d513 --- /dev/null +++ b/src/runner/invocation.go @@ -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 "" + } + return strings.ReplaceAll(strings.TrimSpace(arguments), "\r\n", "\n") +} + +func logArguments(arguments string) string { return LogArguments(arguments) } diff --git a/src/core/runner_other.go b/src/runner/invocation_other.go similarity index 56% rename from src/core/runner_other.go rename to src/runner/invocation_other.go index 5c94ead..43c5b10 100644 --- a/src/core/runner_other.go +++ b/src/runner/invocation_other.go @@ -1,6 +1,6 @@ //go:build !windows -package core +package runner import ( "context" @@ -12,9 +12,3 @@ func shellCommand(ctx context.Context, command string) *exec.Cmd { // and avoids a hard dependency on a larger shell such as bash. return exec.CommandContext(ctx, "sh", "-c", command) } - -func configureHiddenWindow(command *exec.Cmd) { - // Non-Windows platforms do not create a new console window for sh -c from a - // desktop process in the same way Windows does, so no extra process attribute - // is required here. -} diff --git a/src/core/runner_windows.go b/src/runner/invocation_windows.go similarity index 79% rename from src/core/runner_windows.go rename to src/runner/invocation_windows.go index fa37fff..9447509 100644 --- a/src/core/runner_windows.go +++ b/src/runner/invocation_windows.go @@ -1,4 +1,4 @@ -package core +package runner import ( "context" @@ -56,14 +56,3 @@ func startsWithWindowsRootedPath(command string) bool { command[1] == ':' && (command[2] == '\\' || command[2] == '/') } - -func configureHiddenWindow(command *exec.Cmd) { - // GoSentry is a GUI scheduler, so child commands should not flash a console - // window on Windows. CREATE_NO_WINDOW keeps cmd.exe and simple console tools - // quiet while stdout/stderr are still captured through pipes. - if command.SysProcAttr == nil { - command.SysProcAttr = &syscall.SysProcAttr{} - } - command.SysProcAttr.CreationFlags |= 0x08000000 - command.SysProcAttr.HideWindow = true -} diff --git a/src/runner/logfile.go b/src/runner/logfile.go new file mode 100644 index 0000000..641886b --- /dev/null +++ b/src/runner/logfile.go @@ -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 +} diff --git a/src/runner/runner.go b/src/runner/runner.go new file mode 100644 index 0000000..873d607 --- /dev/null +++ b/src/runner/runner.go @@ -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 = "" + } + if stderr == "" { + stderr = "" + } + return "stdout:\n" + stdout + "\n\nstderr:\n" + stderr +} diff --git a/src/core/runner_test.go b/src/runner/runner_test.go similarity index 64% rename from src/core/runner_test.go rename to src/runner/runner_test.go index 11081bc..aadae4a 100644 --- a/src/core/runner_test.go +++ b/src/runner/runner_test.go @@ -1,4 +1,4 @@ -package core +package runner import ( "context" @@ -7,11 +7,155 @@ import ( "runtime" "strings" "testing" + "time" + + "gitea.mixdep.ru/mix/gosentry/src/domain" + "gitea.mixdep.ru/mix/gosentry/src/platform/winproc" ) +func echoCommand(message string) string { + if runtime.GOOS == "windows" { + return "echo " + message + } + return "echo '" + strings.ReplaceAll(message, "'", "'\\''") + "'" +} + +func TestRunJobLogFileAllHeaders(t *testing.T) { + logsDir := t.TempDir() + job := domain.Job{ + ID: 99, + Name: "Log Header Test", + Command: echoCommand("header test output"), + SuccessExitCodes: "0,1", + } + + record := RunJob(context.Background(), &job, "Schedule", logsDir) + if record.LogFile == "" { + t.Fatal("expected log file to be written") + } + + data, err := os.ReadFile(record.LogFile) + if err != nil { + t.Fatal(err) + } + content := string(data) + + for _, want := range []string{ + "job_id: 99", + "job_name: Log Header Test", + "trigger: Schedule", + "state: OK", + "detail: ", + "command: " + job.Command, + "arguments: ", + "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") { + t.Errorf("empty stdout should show , got:\n%s", got) + } + if !strings.Contains(got, "stderr:\n") { + t.Errorf("empty stderr should show , got:\n%s", got) + } +} + +func TestLogArguments(t *testing.T) { + cases := []struct{ input, want string }{ + {"", ""}, + {" ", ""}, + {"--flag", "--flag"}, + {"--flag\r\n--value", "--flag\n--value"}, + {"--flag\n--value", "--flag\n--value"}, + } + for _, tc := range cases { + if got := logArguments(tc.input); got != tc.want { + t.Errorf("logArguments(%q) = %q, want %q", tc.input, got, tc.want) + } + } +} + +func TestSanitizeFileName(t *testing.T) { + cases := []struct{ input, want string }{ + {"Hello Test", "Hello_Test"}, + {"job-1_ok", "job-1_ok"}, + {"!!!", "job"}, + {"", "job"}, + {"A/B:C", "A_B_C"}, + } + for _, tc := range cases { + if got := sanitizeFileName(tc.input); got != tc.want { + t.Errorf("sanitizeFileName(%q) = %q, want %q", tc.input, got, tc.want) + } + } +} + func TestRunJobWritesLogFile(t *testing.T) { logsDir := t.TempDir() - job := Job{ + job := domain.Job{ ID: 42, Name: "Hello Test", Command: echoCommand("hello from test"), @@ -46,7 +190,7 @@ func TestRunJobRunsQuotedWindowsExecutable(t *testing.T) { } logsDir := t.TempDir() - job := Job{ + job := domain.Job{ ID: 43, Name: "Quoted Windows Command", Command: `"C:\Windows\System32\cmd.exe" /C echo quoted command ok`, @@ -75,7 +219,7 @@ func TestRunJobRunsUnquotedWindowsProgramPathWithSpaces(t *testing.T) { if err := os.WriteFile(scriptPath, []byte("@echo off\r\necho unquoted command ok\r\n"), 0o755); err != nil { t.Fatal(err) } - job := Job{ + job := domain.Job{ ID: 44, Name: "Unquoted Windows Command", Command: scriptPath, @@ -96,7 +240,7 @@ func TestRunJobRunsWindowsCommandWithSeparateArguments(t *testing.T) { } logsDir := t.TempDir() - job := Job{ + job := domain.Job{ ID: 45, Name: "Separate Arguments", Command: `C:\Windows\System32\cmd.exe`, @@ -117,7 +261,7 @@ func TestRunJobAcceptsConfiguredExitCode(t *testing.T) { if runtime.GOOS == "windows" { command = `C:\Windows\System32\cmd.exe` } - job := Job{ + job := domain.Job{ ID: 46, Name: "Accepted Exit Code", Command: command, @@ -141,7 +285,7 @@ func TestRunJobRejectsUnconfiguredExitCode(t *testing.T) { if runtime.GOOS == "windows" { command = `C:\Windows\System32\cmd.exe` } - job := Job{ + job := domain.Job{ ID: 47, Name: "Rejected Exit Code", Command: command, @@ -167,7 +311,7 @@ func TestRunJobStartOnlyDoesNotWaitForExitCode(t *testing.T) { command = `C:\Windows\System32\cmd.exe` arguments = "/C\nexit /b 7" } - job := Job{ + job := domain.Job{ ID: 48, Name: "Start Only", Command: command, @@ -188,7 +332,7 @@ func TestRunJobStartOnlyDoesNotWaitForExitCode(t *testing.T) { } func TestRunJobStartOnlyReportsStartFailure(t *testing.T) { - job := Job{ + job := domain.Job{ ID: 49, Name: "Missing Start Only", Command: "definitely-missing-gosentry-command", @@ -223,7 +367,7 @@ func TestDirectCommandDoesNotHideWindow(t *testing.T) { t.Skip("Windows window visibility only") } - invocation := jobInvocation(context.Background(), Job{ + invocation := jobInvocation(context.Background(), domain.Job{ Command: `C:\Windows\System32\cmd.exe`, Arguments: "/C\necho visible direct process", }) @@ -237,11 +381,11 @@ func TestShellCommandHidesWindow(t *testing.T) { t.Skip("Windows window visibility only") } - invocation := jobInvocation(context.Background(), Job{Command: "echo hidden shell process"}) + invocation := jobInvocation(context.Background(), domain.Job{Command: "echo hidden shell process"}) if !invocation.hideWindow { t.Fatal("shell command should request hidden startup window") } - configureHiddenWindow(invocation.command) + winproc.ConfigureHiddenWindow(invocation.command) if invocation.command.SysProcAttr == nil || !invocation.command.SysProcAttr.HideWindow { t.Fatal("expected shell command to be hidden") } @@ -253,7 +397,7 @@ func TestShellCommandUsesWindowsSafeQuoting(t *testing.T) { } command := shellCommand(context.Background(), `"C:\Program Files\FreeFileSync\FreeFileSync.exe" "D:\Local\Programs\FreeFileSync\Jobs\Auto.ffs_batch"`) - configureHiddenWindow(command) + winproc.ConfigureHiddenWindow(command) want := `cmd.exe /S /C ""C:\Program Files\FreeFileSync\FreeFileSync.exe" "D:\Local\Programs\FreeFileSync\Jobs\Auto.ffs_batch""` if command.SysProcAttr == nil { diff --git a/src/scheduler/clock.go b/src/scheduler/clock.go new file mode 100644 index 0000000..b12b29e --- /dev/null +++ b/src/scheduler/clock.go @@ -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() + } +} diff --git a/src/scheduler/scheduler.go b/src/scheduler/scheduler.go new file mode 100644 index 0000000..b9f52db --- /dev/null +++ b/src/scheduler/scheduler.go @@ -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() +} diff --git a/src/scheduler/scheduler_test.go b/src/scheduler/scheduler_test.go new file mode 100644 index 0000000..56978e1 --- /dev/null +++ b/src/scheduler/scheduler_test.go @@ -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) + } +} diff --git a/src/core/paths.go b/src/storage/paths.go similarity index 99% rename from src/core/paths.go rename to src/storage/paths.go index ffd4735..5c482e3 100644 --- a/src/core/paths.go +++ b/src/storage/paths.go @@ -1,4 +1,4 @@ -package core +package storage import ( "os" diff --git a/src/core/store.go b/src/storage/store.go similarity index 86% rename from src/core/store.go rename to src/storage/store.go index ce347f9..e9fa495 100644 --- a/src/core/store.go +++ b/src/storage/store.go @@ -1,4 +1,4 @@ -package core +package storage import ( "errors" @@ -7,15 +7,16 @@ import ( "runtime" "strings" + "gitea.mixdep.ru/mix/gosentry/src/domain" "go.yaml.in/yaml/v4" ) type Store struct { Paths Paths - Config Config + Config domain.Config } -func OpenStore() (*Store, []Job, error) { +func OpenStore() (*Store, []domain.Job, error) { paths, err := ResolvePaths() if err != nil { return nil, nil, err @@ -57,17 +58,17 @@ func (s *Store) SaveConfig() error { return writeYAML(s.Paths.ConfigPath, s.Config) } -func (s *Store) SaveJobs(jobs []Job) error { +func (s *Store) SaveJobs(jobs []domain.Job) error { if err := os.MkdirAll(s.Paths.JobsDir, 0o755); err != nil { return err } - return writeYAML(s.Paths.JobsPath, JobsFile{Jobs: jobs}) + return writeYAML(s.Paths.JobsPath, domain.JobsFile{Jobs: jobs}) } -func loadOrCreateConfig(paths Paths) (Config, error) { +func loadOrCreateConfig(paths Paths) (domain.Config, error) { // Defaults favor a portable installation: settings and jobs begin next to the // executable, while logs are grouped under a dedicated subdirectory. - config := Config{ + config := domain.Config{ JobsDir: ".", LogsDir: "logs", MaxLogFiles: 100, @@ -98,10 +99,10 @@ func loadOrCreateConfig(paths Paths) (Config, error) { data, err := os.ReadFile(configPath) if err != nil { - return Config{}, err + return domain.Config{}, err } if err := yaml.Unmarshal(data, &config); err != nil { - return Config{}, err + return domain.Config{}, err } if strings.TrimSpace(config.JobsDir) == "" { // Empty paths are treated as missing values rather than intentional root @@ -120,27 +121,27 @@ func loadOrCreateConfig(paths Paths) (Config, error) { return config, nil } -func loadOrCreateJobs(path string) ([]Job, error) { +func loadOrCreateJobs(path string) ([]domain.Job, error) { if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) { // The first run creates harmless sample jobs so a new user can immediately // see scheduled and manual execution without inventing a command. jobs := defaultJobs() normalizeJobs(jobs) - return jobs, writeYAML(path, JobsFile{Jobs: jobs}) + return jobs, writeYAML(path, domain.JobsFile{Jobs: jobs}) } data, err := os.ReadFile(path) if err != nil { return nil, err } - var file JobsFile + var file domain.JobsFile if err := yaml.Unmarshal(data, &file); err != nil { return nil, err } return file.Jobs, nil } -func normalizeJobs(jobs []Job) { +func normalizeJobs(jobs []domain.Job) { next := 1 for index := range jobs { job := &jobs[index] @@ -168,23 +169,9 @@ func normalizeJobs(jobs []Job) { if job.SuccessExitCodes == "" { job.SuccessExitCodes = "0" } - if job.LastRun == "" { - job.LastRun = "Never" - } - if job.Output == "" { - job.Output = "No command output captured yet." - } - if job.Enabled { - job.LastState = "Ready" - job.NextRun = "After start" - } else { - job.LastState = "Paused" - job.NextRun = "Paused" - } - // Runtime fields are reconstructed each time the app starts. Persisted run - // records live in log files, not in jobs.yaml, to keep the jobs file easy - // to review and edit by hand. - job.Logs = nil + // Runtime state (last run, next run, status, output, activity) is no longer + // part of Job. It is reconstructed each time the app starts via + // domain.NewRuntime, so normalizeJobs only touches durable configuration. } } @@ -222,8 +209,8 @@ func writeYAML(path string, value any) error { return os.WriteFile(path, data, 0o644) } -func defaultJobs() []Job { - return []Job{ +func defaultJobs() []domain.Job { + return []domain.Job{ { ID: 1, Name: "Hello scheduler", diff --git a/src/storage/store_test.go b/src/storage/store_test.go new file mode 100644 index 0000000..9d14f03 --- /dev/null +++ b/src/storage/store_test.go @@ -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) + } + } +} diff --git a/src/ui/history_view.go b/src/ui/history_view.go new file mode 100644 index 0000000..b168e48 --- /dev/null +++ b/src/ui/history_view.go @@ -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 +} diff --git a/src/ui/job_dialog.go b/src/ui/job_dialog.go new file mode 100644 index 0000000..1e79767 --- /dev/null +++ b/src/ui/job_dialog.go @@ -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() +} diff --git a/src/ui/jobs_view.go b/src/ui/jobs_view.go new file mode 100644 index 0000000..452604f --- /dev/null +++ b/src/ui/jobs_view.go @@ -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 +} diff --git a/src/ui/jobs_view_test.go b/src/ui/jobs_view_test.go new file mode 100644 index 0000000..f99f7a0 --- /dev/null +++ b/src/ui/jobs_view_test.go @@ -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) + } +} diff --git a/src/ui/layout.go b/src/ui/layout.go new file mode 100644 index 0000000..798178b --- /dev/null +++ b/src/ui/layout.go @@ -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) + } +} diff --git a/src/ui/mainwindow.go b/src/ui/mainwindow.go new file mode 100644 index 0000000..37a4878 --- /dev/null +++ b/src/ui/mainwindow.go @@ -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 +} diff --git a/src/ui/run.go b/src/ui/run.go new file mode 100644 index 0000000..f087f82 --- /dev/null +++ b/src/ui/run.go @@ -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() +} diff --git a/src/ui/settings_view.go b/src/ui/settings_view.go new file mode 100644 index 0000000..5f4e3c5 --- /dev/null +++ b/src/ui/settings_view.go @@ -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)) +} diff --git a/src/ui/singleinstance.go b/src/ui/singleinstance.go new file mode 100644 index 0000000..59b59f8 --- /dev/null +++ b/src/ui/singleinstance.go @@ -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() + }) + } + }() +} diff --git a/src/ui/tray.go b/src/ui/tray.go new file mode 100644 index 0000000..012e5e7 --- /dev/null +++ b/src/ui/tray.go @@ -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() + }) +}