Compare commits
14 Commits
v1.0.0
...
0a50f3c66b
| Author | SHA1 | Date | |
|---|---|---|---|
| 0a50f3c66b | |||
| 1240297cca | |||
| 5b0e6fe51b | |||
| 5170cc5f99 | |||
| a735bfd116 | |||
| 28f0a0d8e2 | |||
| 2ef18e759c | |||
| 77bd2db286 | |||
| 1b6a3604cf | |||
| cb37377346 | |||
| b2402f4c72 | |||
| 648325a690 | |||
| 27927f3ab1 | |||
| 276539c383 |
@@ -8,7 +8,8 @@ application service, scheduler, storage, and command runner in one binary.
|
|||||||
- [docs/STANDARDS.md](docs/STANDARDS.md) — **required.** Code-quality rules and
|
- [docs/STANDARDS.md](docs/STANDARDS.md) — **required.** Code-quality rules and
|
||||||
the list of intentional behavior. Do not "fix" anything listed there as
|
the list of intentional behavior. Do not "fix" anything listed there as
|
||||||
intentional; if a change contradicts it, update the document in the same commit.
|
intentional; if a change contradicts it, update the document in the same commit.
|
||||||
- [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) — package contracts and event flow.
|
- [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) — package contracts, event flow, and
|
||||||
|
§Platform layer (why and where OS-specific code lives).
|
||||||
- [docs/TESTS.md](docs/TESTS.md) — test layout and conventions.
|
- [docs/TESTS.md](docs/TESTS.md) — test layout and conventions.
|
||||||
- [docs/ROADMAP.md](docs/ROADMAP.md) — deliberately out of scope.
|
- [docs/ROADMAP.md](docs/ROADMAP.md) — deliberately out of scope.
|
||||||
|
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ creating, grouping, pausing, running, and monitoring scheduled shell commands.
|
|||||||
- Desktop notifications on job failure.
|
- Desktop notifications on job failure.
|
||||||
- Windows tray icon: left-click to show the window, right-click for the menu.
|
- Windows tray icon: left-click to show the window, right-click for the menu.
|
||||||
- Autostart on login (Windows shortcut; Linux XDG desktop entry).
|
- Autostart on login (Windows shortcut; Linux XDG desktop entry).
|
||||||
- Detailed or compact job list, and a default or branded theme; both are remembered.
|
- Detailed or compact job list, and a system or branded theme; both are remembered.
|
||||||
|
|
||||||
## Platforms
|
## Platforms
|
||||||
|
|
||||||
@@ -74,7 +74,7 @@ portable application: moving the program folder also moves its configuration.
|
|||||||
"execution_mode": "parallel",
|
"execution_mode": "parallel",
|
||||||
"overlap_policy": "skip",
|
"overlap_policy": "skip",
|
||||||
"default_timeout_seconds": 0,
|
"default_timeout_seconds": 0,
|
||||||
"theme": "default",
|
"theme": "gosentry",
|
||||||
"job_list_view": "detailed"
|
"job_list_view": "detailed"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -83,7 +83,7 @@ That is the file GoSentry writes on first run. `default_timeout_seconds` is the
|
|||||||
run timeout applied to jobs that do not set their own; `0` means no timeout, and
|
run timeout applied to jobs that do not set their own; `0` means no timeout, and
|
||||||
it is written out even though it is zero, because a missing value and a
|
it is written out even though it is zero, because a missing value and a
|
||||||
deliberate "no timeout" have to stay distinguishable in a hand-edited file.
|
deliberate "no timeout" have to stay distinguishable in a hand-edited file.
|
||||||
`theme` is `default` or `gosentry` (the branded teal/amber look), and
|
`theme` is `system` or `gosentry` (the branded teal/amber look), and
|
||||||
`job_list_view` is `detailed` or `compact` — both are remembered from the
|
`job_list_view` is `detailed` or `compact` — both are remembered from the
|
||||||
choices made in the app. Keys left at their off value (`start_on_login`,
|
choices made in the app. Keys left at their off value (`start_on_login`,
|
||||||
`paused`) are omitted until they are turned on.
|
`paused`) are omitted until they are turned on.
|
||||||
@@ -126,22 +126,55 @@ include the run timestamp and job name:
|
|||||||
|
|
||||||
## Schedules
|
## Schedules
|
||||||
|
|
||||||
Interval schedules using Go duration syntax:
|
GoSentry accepts two schedule forms: fixed `@every` intervals and standard
|
||||||
|
5-field cron expressions.
|
||||||
|
|
||||||
|
### `@every` intervals
|
||||||
|
|
||||||
|
Write `@every` followed by a [Go duration](https://pkg.go.dev/time#ParseDuration)
|
||||||
|
— a positive number with a unit suffix. Units can be combined in one value:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
@every 10s
|
@every 10s every 10 seconds
|
||||||
@every 5m
|
@every 5m every 5 minutes
|
||||||
@every 1h30m
|
@every 1h every hour
|
||||||
|
@every 1h30m every hour and a half (same as @every 90m)
|
||||||
|
@every 2h45m10s hours, minutes, and seconds combined
|
||||||
```
|
```
|
||||||
|
|
||||||
Standard 5-field cron expressions:
|
Supported units:
|
||||||
|
|
||||||
|
| Unit | Meaning |
|
||||||
|
|------|---------|
|
||||||
|
| `ns` | nanoseconds |
|
||||||
|
| `us`, `µs` | microseconds |
|
||||||
|
| `ms` | milliseconds |
|
||||||
|
| `s` | seconds |
|
||||||
|
| `m` | minutes |
|
||||||
|
| `h` | hours |
|
||||||
|
|
||||||
|
`@every` does **not** support days, weeks, months, or years — those follow a
|
||||||
|
calendar, not a fixed interval. For “every day at 02:00”, “on the 1st of each
|
||||||
|
month”, or “once a year”, use a cron expression (below).
|
||||||
|
|
||||||
|
The scheduler checks due jobs once per second, so values shorter than `1s` are
|
||||||
|
accepted but will not fire faster than once a second.
|
||||||
|
|
||||||
|
### Cron expressions
|
||||||
|
|
||||||
|
Five fields: minute, hour, day-of-month, month, day-of-week.
|
||||||
|
|
||||||
```text
|
```text
|
||||||
*/5 * * * * every five minutes
|
*/5 * * * * every five minutes
|
||||||
0 2 * * * every day at 02:00
|
0 2 * * * every day at 02:00
|
||||||
30 9 * * 1-5 weekdays at 09:30
|
30 9 * * 1-5 weekdays at 09:30
|
||||||
|
0 0 1 * * first day of every month at midnight
|
||||||
|
0 0 1 1 * every year on 1 January at midnight
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Named descriptors are also accepted: `@hourly`, `@daily`, `@weekly`,
|
||||||
|
`@monthly`, `@yearly` (and `@annually`, `@midnight`).
|
||||||
|
|
||||||
## Using The App
|
## Using The App
|
||||||
|
|
||||||
1. Start GoSentry.
|
1. Start GoSentry.
|
||||||
@@ -168,8 +201,12 @@ loading a new list discards the run state of the old one.
|
|||||||
|
|
||||||
The **Start on login** checkbox shows an `OK` or `Problem` status. Saving with
|
The **Start on login** checkbox shows an `OK` or `Problem` status. Saving with
|
||||||
it enabled writes an autostart entry using the current executable path.
|
it enabled writes an autostart entry using the current executable path.
|
||||||
Autostart entries include `--start-in-tray` so scheduled jobs run after sign-in
|
When **Keep running in the system tray** is also enabled, the entry includes
|
||||||
without opening the main window.
|
`--start-in-tray` so scheduled jobs run after sign-in without opening the main
|
||||||
|
window. With the tray option off, autostart still works but opens the main
|
||||||
|
window normally. Changing the tray setting updates close behaviour and the
|
||||||
|
autostart entry immediately; the tray icon itself updates only after you restart
|
||||||
|
GoSentry (a Fyne limitation — see [docs/ROADMAP.md](docs/ROADMAP.md)).
|
||||||
|
|
||||||
## Queue Settings
|
## Queue Settings
|
||||||
|
|
||||||
|
|||||||
+63
-2
@@ -57,6 +57,65 @@ flowchart LR
|
|||||||
svc -->|"Set / Status via Manager"| autostart
|
svc -->|"Set / Status via Manager"| autostart
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Platform layer
|
||||||
|
|
||||||
|
GoSentry ships one binary per target OS. Platform-specific code is not a
|
||||||
|
workaround for missing cross-platform support — it **is** the cross-platform
|
||||||
|
strategy: shared interfaces and call sites, with OS-specific implementations
|
||||||
|
selected at **compile time** (`*_windows.go`, `//go:build linux`, and similar).
|
||||||
|
Runtime `runtime.GOOS` checks appear only for small UI details (see below), not
|
||||||
|
for autostart, file-manager integration, or command invocation.
|
||||||
|
|
||||||
|
Callers (`app.Service`, `ui`, `runner`) depend on the shared API; they do not
|
||||||
|
branch on the operating system.
|
||||||
|
|
||||||
|
| Package / file | Windows | Linux | Other (`!windows && !linux`) |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `platform/autostart` | Startup-folder `.lnk` shortcut | XDG `~/.config/autostart/gosentry.desktop` | Stub — `Set` returns an error when enabled |
|
||||||
|
| `platform/desktop` | no-op | Installs `.desktop` + icon under XDG data home | no-op |
|
||||||
|
| `platform/filemanager` | `explorer` | `xdg-open` | Unsupported — `Open` returns an error |
|
||||||
|
| `platform/winproc` | `CREATE_NO_WINDOW` / `HideWindow` on child processes | no-op | no-op |
|
||||||
|
| `runner/invocation_*` | `cmd.exe /S /C` with Windows-safe quoting | `sh -c` | `sh -c` (same as Linux) |
|
||||||
|
|
||||||
|
**Why separate implementations are required**
|
||||||
|
|
||||||
|
- **Autostart** — each OS defines its own login startup mechanism (shortcut,
|
||||||
|
XDG Autostart, LaunchAgents on macOS). There is no portable API in Go, Fyne, or
|
||||||
|
the standard library; a third-party helper would still wrap the same per-OS
|
||||||
|
code behind an interface.
|
||||||
|
- **Opening a folder** — the desktop shell exposes no shared “reveal in file
|
||||||
|
manager” call; each platform invokes its registered handler (`explorer`,
|
||||||
|
`xdg-open`, `open` on macOS).
|
||||||
|
- **Command shell** — users expect OS-native semantics (`cmd.exe` batch files,
|
||||||
|
`%VAR%`, and path rules on Windows; POSIX `sh` on Linux). A single shell for
|
||||||
|
all platforms would break commands on one side or the other.
|
||||||
|
- **Hidden console window** — launching a child process from a GUI app can flash
|
||||||
|
a console on Windows only; Linux and macOS do not need equivalent flags.
|
||||||
|
|
||||||
|
**Deliberate platform choices (not OS API limits)**
|
||||||
|
|
||||||
|
- **Window and tray icons** — Fyne accepts icons on every platform, but Windows
|
||||||
|
renders the notification area and titlebar from multi-size `.ico` resources
|
||||||
|
(embedded via `packaging/windows/gosentry.rc`), while Linux StatusNotifier
|
||||||
|
trays scale better from a larger PNG. `ui/run.go` and `ui/tray.go` branch on
|
||||||
|
`runtime.GOOS` for asset selection only.
|
||||||
|
- **Sample job commands** in `storage/store.go` — demo `echo` lines differ only
|
||||||
|
because shell quoting rules differ; real jobs are user-authored per platform.
|
||||||
|
|
||||||
|
**Adding new platform code**
|
||||||
|
|
||||||
|
- Put OS integration in `src/platform/<name>/` with a small shared API, or use
|
||||||
|
`*_GOOS.go` files in the owning package when the surface is a single function
|
||||||
|
(as in `runner/invocation_*`).
|
||||||
|
- Do not scatter `runtime.GOOS` through `app.Service` or UI business logic.
|
||||||
|
- Unsupported platforms get an explicit stub (return an error or no-op) rather
|
||||||
|
than silently doing nothing — see `autostart_other.go` and
|
||||||
|
`filemanager_other.go`.
|
||||||
|
|
||||||
|
macOS autostart and file-manager handlers are not implemented yet; see
|
||||||
|
[ROADMAP.md](ROADMAP.md) for blocked or deferred cross-platform work (for
|
||||||
|
example window-maximized detection, which would need per-OS native calls).
|
||||||
|
|
||||||
## Main Flows
|
## Main Flows
|
||||||
|
|
||||||
1. Startup:
|
1. Startup:
|
||||||
@@ -113,8 +172,10 @@ flowchart LR
|
|||||||
7. Autostart:
|
7. Autostart:
|
||||||
`UpdateSettings` in the Service calls `autostart.Manager.Set`. The Manager
|
`UpdateSettings` in the Service calls `autostart.Manager.Set`. The Manager
|
||||||
interface has two implementations: Windows writes a `.lnk` shortcut to the
|
interface has two implementations: Windows writes a `.lnk` shortcut to the
|
||||||
user Startup folder; Linux writes an XDG Autostart `.desktop` file. Both
|
user Startup folder; Linux writes an XDG Autostart `.desktop` file. When
|
||||||
entries pass `--start-in-tray`.
|
`KeepRunningInTray` is enabled the entry passes `--start-in-tray`; when it is
|
||||||
|
off the entry launches the executable without that flag so the main window
|
||||||
|
opens after sign-in.
|
||||||
|
|
||||||
8. Error surfacing:
|
8. Error surfacing:
|
||||||
Background errors (failed JSON saves, cleanup errors) are emitted as
|
Background errors (failed JSON saves, cleanup errors) are emitted as
|
||||||
|
|||||||
@@ -2,6 +2,94 @@
|
|||||||
|
|
||||||
All notable GoSentry changes are recorded in this file.
|
All notable GoSentry changes are recorded in this file.
|
||||||
|
|
||||||
|
## 1.0.1 - 2026-08-04
|
||||||
|
|
||||||
|
**Sample jobs include a disabled failure test for desktop notifications.**
|
||||||
|
|
||||||
|
- **`storage.defaultJobs`** — new disabled example *Failure notification test*
|
||||||
|
(folder Examples). Run it manually to trigger a failed run and verify
|
||||||
|
Settings → Notifications without waiting on the scheduler.
|
||||||
|
|
||||||
|
**Platform layer rationale is documented in ARCHITECTURE.md.**
|
||||||
|
|
||||||
|
- **`docs/ARCHITECTURE.md`** — new §Platform layer: why autostart, file manager,
|
||||||
|
shell, and winproc are OS-specific; compile-time vs runtime branching; rules
|
||||||
|
for adding platform code.
|
||||||
|
|
||||||
|
**KeepRunningInTray is wired to runtime; autostart respects the tray setting.**
|
||||||
|
|
||||||
|
**Application:**
|
||||||
|
|
||||||
|
- **Keep running in the system tray** now controls behaviour: with the tray on
|
||||||
|
(default), closing the window hides it and autostart uses `--start-in-tray`;
|
||||||
|
with the tray off, closing quits the app and autostart opens the main window.
|
||||||
|
- Saving a tray change updates close behaviour and the autostart entry
|
||||||
|
immediately. The notification-area icon follows the saved value after a
|
||||||
|
restart; Settings shows a hint when a restart is needed (Fyne cannot add or
|
||||||
|
remove the icon mid-session).
|
||||||
|
- A stale autostart shortcut that still passes `--start-in-tray` no longer hides
|
||||||
|
the window when the tray setting is off — saved config wins over the CLI flag.
|
||||||
|
|
||||||
|
**The branded theme is the default, Fyne's built-in theme is System, and the
|
||||||
|
test suite is leaner.**
|
||||||
|
|
||||||
|
**Settings:**
|
||||||
|
|
||||||
|
- **The branded GoSentry theme is now the default.** Fresh installs, the
|
||||||
|
**Defaults** button, and configs that omit `theme` all open in the teal/amber
|
||||||
|
look; users who prefer Fyne's built-in theme can still pick **System** in
|
||||||
|
Settings.
|
||||||
|
- The Fyne built-in theme option is labelled **System** (stored as `"system"`);
|
||||||
|
configs that still say `"default"` are read as System and rewritten on save.
|
||||||
|
- The **About** repository link points at GitHub (`mixeme/gosentry`) instead of
|
||||||
|
the private Gitea mirror.
|
||||||
|
|
||||||
|
**Jobs:**
|
||||||
|
|
||||||
|
- The **Disable auto** row gained a top inset matching the gap below it, so it
|
||||||
|
no longer sits flush against the tab bar.
|
||||||
|
|
||||||
|
**Documentation:**
|
||||||
|
|
||||||
|
- The **README Schedules** section now documents `@every` in full: supported Go
|
||||||
|
duration units (`ns` through `h`), combined values such as `1h30m`, the link to
|
||||||
|
`time.ParseDuration`, the fact that days/months/years belong in cron rather
|
||||||
|
than `@every`, the one-second scheduler tick floor, cron examples for monthly
|
||||||
|
and yearly runs, and the `@hourly`/`@daily`/… descriptors.
|
||||||
|
|
||||||
|
**Tests:**
|
||||||
|
|
||||||
|
- Three tests with byte-identical coverage to an existing test and no unique
|
||||||
|
assertion are gone: `TestCleanupLogsKeepsFilesWithinAgeLimit`,
|
||||||
|
`TestRunDueEmptyOverlapInheritsGlobal` (its one unique setup guard moved into
|
||||||
|
`TestRunDueQueueRerunsAfterFinish`), and `TestSameWindowsPathHandlesSpaces`
|
||||||
|
(its spaces case folded into `TestSameWindowsPathIgnoresCaseAndQuotes`'s
|
||||||
|
fixture). So are two that carried no assertion at all:
|
||||||
|
`TestEmitWithNoObserversIsNoop` and `TestStoreReturnsWiredStore`.
|
||||||
|
- The four `TestFilteredJobIndexes*` tests are one table-driven
|
||||||
|
`TestFilteredJobIndexes`, matching `TestFilterValue` above it.
|
||||||
|
- `TestMainViewBuilds` is replaced by `TestMainViewRecordStartupAddsHistoryRow`,
|
||||||
|
which exercises the `recordStartup` closure for both wordings `run.go` selects
|
||||||
|
between and asserts the rows reach the History table through its cell callbacks,
|
||||||
|
including the `!windowShown` branch.
|
||||||
|
- `storage.defaultJobs` — the one accidental 0%-coverage gap the review
|
||||||
|
found — is now exercised by
|
||||||
|
`TestLoadOrCreateJobsSeedsSampleJobsOnFirstRun`, which also corrects
|
||||||
|
`docs/TESTS.md`: `TestLoadOrCreateConfigCreatesDefaultsOnFirstRun` never
|
||||||
|
touched jobs, so the "and a sample job" half of its old description was
|
||||||
|
wrong.
|
||||||
|
- `src/runner/seed_test.go`'s hand-rolled `itoa` — 18 lines of digit-by-digit
|
||||||
|
conversion in a file that already imports `strconv` — is replaced with
|
||||||
|
`strconv.FormatInt`.
|
||||||
|
- **`docs/TESTS.md`** records the `-coverpkg` command and the 84.4% baseline
|
||||||
|
(per-package figures understate the suite), design principle 9 (redundancy is
|
||||||
|
judged by comparing coverage profiles, and identical coverage alone is not
|
||||||
|
grounds for deletion), a table of the look-alike tests that are kept with the
|
||||||
|
reason each survives, and the list of functions deliberately at 0%.
|
||||||
|
- **`docs/STANDARDS.md`**'s "Intentional behavior" section points at both lists,
|
||||||
|
so the mechanism `docs/REVIEW.md` describes still reaches them. The spent test
|
||||||
|
review plan is retired.
|
||||||
|
|
||||||
## 1.0.0 - 2026-07-27
|
## 1.0.0 - 2026-07-27
|
||||||
|
|
||||||
**The window opens at the size it asks for, and the Jobs divider can be
|
**The window opens at the size it asks for, and the Jobs divider can be
|
||||||
|
|||||||
@@ -5,6 +5,22 @@ Completed work is recorded in [CHANGELOG.md](CHANGELOG.md), not here.
|
|||||||
|
|
||||||
## Open Items
|
## Open Items
|
||||||
|
|
||||||
|
### Dynamic tray icon toggle
|
||||||
|
|
||||||
|
Fyne exposes `SetSystemTrayIcon` and related APIs only at application startup.
|
||||||
|
There is no supported way to register or remove the notification-area icon
|
||||||
|
after the process is running.
|
||||||
|
|
||||||
|
GoSentry now honours `KeepRunningInTray` from config: close behaviour and the
|
||||||
|
autostart entry update immediately when the user saves Settings; the tray icon
|
||||||
|
follows the saved value on the next launch. Settings shows a restart hint when
|
||||||
|
the tray checkbox changes.
|
||||||
|
|
||||||
|
Revisit when Fyne adds a documented API for mid-session tray registration, or
|
||||||
|
when a stable cross-platform approach exists without reaching into driver
|
||||||
|
internals. Until then, removing the restart hint and applying the icon on save
|
||||||
|
is blocked.
|
||||||
|
|
||||||
### Update check from GitHub releases
|
### Update check from GitHub releases
|
||||||
|
|
||||||
Releases are published as GitHub Releases (tags like `v0.12.0`, built by
|
Releases are published as GitHub Releases (tags like `v0.12.0`, built by
|
||||||
|
|||||||
@@ -66,6 +66,20 @@ change to their shape has to stay compatible on its own.
|
|||||||
- **History tab is session-only.** `JobRuntime.Logs` exists only in memory for the
|
- **History tab is session-only.** `JobRuntime.Logs` exists only in memory for the
|
||||||
current process. Log files on disk feed aggregate statistics via `SeedStats`
|
current process. Log files on disk feed aggregate statistics via `SeedStats`
|
||||||
only. See [ARCHITECTURE.md](ARCHITECTURE.md).
|
only. See [ARCHITECTURE.md](ARCHITECTURE.md).
|
||||||
|
- Several tests share a coverage profile with another test on purpose, and a few
|
||||||
|
functions sit at 0% on purpose. Both lists live in
|
||||||
|
[TESTS.md](TESTS.md) — check them before reporting a test as redundant or a
|
||||||
|
coverage gap as an oversight.
|
||||||
|
- **`KeepRunningInTray` controls tray and close behavior.** When enabled (the
|
||||||
|
default), the app registers a system tray icon at launch, closing the window
|
||||||
|
hides it, and autostart passes `--start-in-tray`. When disabled, no tray icon
|
||||||
|
is registered at launch, closing the window quits the app, and autostart opens
|
||||||
|
the main window. Toggling the setting in Settings updates close behavior and
|
||||||
|
rewrites the autostart entry immediately; the tray icon itself follows the
|
||||||
|
saved value only after a restart because Fyne has no API to add or remove it
|
||||||
|
mid-session (see [ROADMAP.md](ROADMAP.md)).
|
||||||
|
- **`--start-in-tray` defers to config.** A stale autostart shortcut that still
|
||||||
|
passes the flag does not hide the window when `KeepRunningInTray` is off.
|
||||||
|
|
||||||
## Out of scope
|
## Out of scope
|
||||||
|
|
||||||
|
|||||||
+67
-18
@@ -55,6 +55,17 @@ go test -coverprofile=coverage.out ./src/runner
|
|||||||
go tool cover -html=coverage.out
|
go tool cover -html=coverage.out
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Per-package coverage understates the suite, because several packages are
|
||||||
|
exercised from another one's tests — `domain.NewRuntime`, for instance, is
|
||||||
|
covered by the `app` tests. Measure the engine packages together instead:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go test -coverpkg=./src/domain,./src/storage,./src/runner,./src/scheduler,./src/app ./src/domain ./src/storage ./src/runner ./src/scheduler ./src/app
|
||||||
|
```
|
||||||
|
|
||||||
|
That figure was 84.4% at the 2026-08-04 review, which is the number to compare
|
||||||
|
against before concluding that coverage has slipped.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Test Files Overview
|
## Test Files Overview
|
||||||
@@ -82,11 +93,12 @@ Tests schedule parsing and validation.
|
|||||||
|
|
||||||
**Package:** `domain`
|
**Package:** `domain`
|
||||||
|
|
||||||
Tests the normalization rule shared by every consumer of the jobs-list density
|
Tests autostart argument helpers and the jobs-list density normalization rule.
|
||||||
setting.
|
|
||||||
|
|
||||||
| Test | Purpose |
|
| Test | Purpose |
|
||||||
|------|---------|
|
|------|---------|
|
||||||
|
| `TestAutostartArguments` | Verifies `AutostartArguments` returns `--start-in-tray` when the tray is enabled and an empty string when it is off. |
|
||||||
|
| `TestResolveStartHidden` | Verifies hidden autostart requires both the CLI flag and `KeepRunningInTray`. |
|
||||||
| `TestJobListViewIsCompact` | Verifies only the exact `"compact"` value selects one-line rows: empty, differently-cased, and unrecognised values all read as detailed. |
|
| `TestJobListViewIsCompact` | Verifies only the exact `"compact"` value selects one-line rows: empty, differently-cased, and unrecognised values all read as detailed. |
|
||||||
| `TestDefaultConfigUsesDetailedJobList` | Verifies `DefaultConfig` selects the detailed job list. |
|
| `TestDefaultConfigUsesDetailedJobList` | Verifies `DefaultConfig` selects the detailed job list. |
|
||||||
|
|
||||||
@@ -102,7 +114,6 @@ Tests `Service` construction and the state-accessor contract.
|
|||||||
|------|---------|
|
|------|---------|
|
||||||
| `TestNewServiceBuildsRuntimePerJob` | Verifies that `NewService` creates a `JobRuntime` entry for every loaded job. |
|
| `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. |
|
| `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`. |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -175,11 +186,10 @@ and scheduler edge cases using injected `runJob` and `primeDue`.
|
|||||||
| `TestRunDueParallelStartsAllDueJobs` | Parallel mode: both due jobs enter the runner before either completes. |
|
| `TestRunDueParallelStartsAllDueJobs` | Parallel mode: both due jobs enter the runner before either completes. |
|
||||||
| `TestRunDueSequentialSerializes` | Sequential mode: job 2 waits until job 1 finishes. |
|
| `TestRunDueSequentialSerializes` | Sequential mode: job 2 waits until job 1 finishes. |
|
||||||
| `TestRunDueSkipDropsOverlap` | Global skip: no second concurrent run, `PendingRuns` stays 0. |
|
| `TestRunDueSkipDropsOverlap` | Global skip: no second concurrent run, `PendingRuns` stays 0. |
|
||||||
| `TestRunDueQueueRerunsAfterFinish` | Queue: one deferred run after an in-flight finish. |
|
| `TestRunDueQueueRerunsAfterFinish` | Queue: one deferred run after an in-flight finish; also covers an empty per-job policy inheriting the global default. |
|
||||||
| `TestRunDueQueueDrainsMultipleOverlaps` | Queue: multiple missed ticks drain as separate runs. |
|
| `TestRunDueQueueDrainsMultipleOverlaps` | Queue: multiple missed ticks drain as separate runs. |
|
||||||
| `TestRunDuePerJobQueueOverridesGlobalSkip` | Per-job `queue` beats global `skip`. |
|
| `TestRunDuePerJobQueueOverridesGlobalSkip` | Per-job `queue` beats global `skip`. |
|
||||||
| `TestRunDuePerJobSkipOverridesGlobalQueue` | Per-job `skip` beats global `queue`. |
|
| `TestRunDuePerJobSkipOverridesGlobalQueue` | Per-job `skip` beats global `queue`. |
|
||||||
| `TestRunDueEmptyOverlapInheritsGlobal` | Empty per-job policy inherits the global default. |
|
|
||||||
| `TestRunNowSequentialGuard` | Manual run refused while another job runs in sequential mode. |
|
| `TestRunNowSequentialGuard` | Manual run refused while another job runs in sequential mode. |
|
||||||
| `TestStartRunLockedRollbackOnSaveFailure` | Regression: run does not start when `SaveJobs` fails. |
|
| `TestStartRunLockedRollbackOnSaveFailure` | Regression: run does not start when `SaveJobs` fails. |
|
||||||
| `TestRunDueQueueDrainSkippedWhenPaused` | Queued overlaps are not drained while the scheduler is paused. |
|
| `TestRunDueQueueDrainSkippedWhenPaused` | Queued overlaps are not drained while the scheduler is paused. |
|
||||||
@@ -196,7 +206,6 @@ Tests the event-emission and observer-subscription machinery.
|
|||||||
| Test | Purpose |
|
| Test | Purpose |
|
||||||
|------|---------|
|
|------|---------|
|
||||||
| `TestEmitDeliversToAllObserversInOrder` | Verifies that all registered observers receive emitted events in registration order. |
|
| `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). |
|
| `TestObserverCanReadServiceState` | Verifies that an observer called by `emit` can safely read Service state (jobs, runtimes). |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -234,9 +243,11 @@ Tests JSON round-tripping, default generation, and backward compatibility.
|
|||||||
| `TestJobsRoundTrip` | Verifies that jobs saved to JSON are reloaded with identical field values. |
|
| `TestJobsRoundTrip` | Verifies that jobs saved to JSON are reloaded with identical field values. |
|
||||||
| `TestConfigRoundTrip` | Verifies that settings saved to JSON are reloaded with identical field values. |
|
| `TestConfigRoundTrip` | Verifies that settings saved to JSON are reloaded with identical field values. |
|
||||||
| `TestNormalizeJobsFillsDefaults` | Verifies that `normalizeJobs` assigns sequential IDs and sets default name, schedule, and command for jobs missing those fields. |
|
| `TestNormalizeJobsFillsDefaults` | Verifies that `normalizeJobs` assigns sequential IDs and sets default name, schedule, and command for jobs missing those fields. |
|
||||||
| `TestLoadOrCreateConfigCreatesDefaultsOnFirstRun` | Verifies that a missing config file is created with sane defaults and a sample job. |
|
| `TestLoadOrCreateConfigCreatesDefaultsOnFirstRun` | Verifies that a missing config file is created with sane defaults. |
|
||||||
|
| `TestLoadOrCreateJobsSeedsSampleJobsOnFirstRun` | Verifies that a missing jobs file is created with the sample jobs from `defaultJobs`. |
|
||||||
| `TestLoadOrCreateConfigKeepsZeroTimeoutOnReload` | Verifies that `default_timeout_seconds: 0` survives a reload rather than being normalized away — 0 is a value, not a missing field. |
|
| `TestLoadOrCreateConfigKeepsZeroTimeoutOnReload` | Verifies that `default_timeout_seconds: 0` survives a reload rather than being normalized away — 0 is a value, not a missing field. |
|
||||||
| `TestLoadOrCreateConfigMigratesJobsDir` | Verifies that a pre-0.15 `jobs_dir` becomes `jobs_file` pointing at the same `jobs.json`, and that the retired key is not written back. |
|
| `TestLoadOrCreateConfigMigratesJobsDir` | Verifies that a pre-0.15 `jobs_dir` becomes `jobs_file` pointing at the same `jobs.json`, and that the retired key is not written back. |
|
||||||
|
| `TestLoadOrCreateConfigMigratesLegacyThemeDefault` | Verifies that a config storing the retired `"default"` theme value is normalized to `system` on load. |
|
||||||
| `TestLoadJobsFileReportsMissingWithoutCreating` | Verifies that `LoadJobsFile` reports a missing file as not-found without creating or seeding it, and normalizes the jobs it does load. |
|
| `TestLoadJobsFileReportsMissingWithoutCreating` | Verifies that `LoadJobsFile` reports a missing file as not-found without creating or seeding it, and normalizes the jobs it does load. |
|
||||||
| `TestApplyConfigPathsDerivesJobsDir` | Verifies that the configured jobs file resolves against the program folder and that `Paths.JobsDir` is derived from it. |
|
| `TestApplyConfigPathsDerivesJobsDir` | Verifies that the configured jobs file resolves against the program folder and that `Paths.JobsDir` is derived from it. |
|
||||||
| `TestJobTimeoutRoundTripsThreeStates` | Verifies the on-disk encoding that keeps "inherit" and "no timeout" distinguishable: `nil` is omitted entirely, an explicit `0` is written and read back as set. |
|
| `TestJobTimeoutRoundTripsThreeStates` | Verifies the on-disk encoding that keeps "inherit" and "no timeout" distinguishable: `nil` is omitted entirely, an explicit `0` is written and read back as set. |
|
||||||
@@ -354,8 +365,7 @@ Tests log-file cleanup by age and by count.
|
|||||||
| Test | Purpose |
|
| Test | Purpose |
|
||||||
|------|---------|
|
|------|---------|
|
||||||
| `TestCleanupLogsMissingDirReturnsNil` | Verifies that cleanup returns nil (not an error) when the logs directory does not exist. |
|
| `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. |
|
| `TestCleanupLogsRemovesFilesPastMaxAge` | Verifies that `.log` files older than `MaxLogAgeDays` are deleted and files within the limit are retained. |
|
||||||
| `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. |
|
| `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. |
|
| `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. |
|
| `TestCleanupLogsSubdirsNotDeleted` | Verifies that subdirectories inside the logs directory are not deleted by cleanup. |
|
||||||
@@ -372,13 +382,14 @@ Tests Windows autostart via shortcuts in the Startup folder.
|
|||||||
|
|
||||||
| Test | Purpose |
|
| Test | Purpose |
|
||||||
|------|---------|
|
|------|---------|
|
||||||
| `TestSameWindowsPathIgnoresCaseAndQuotes` | Verifies that Windows path comparison is case-insensitive and handles quote marks correctly. |
|
| `TestSameWindowsPathIgnoresCaseAndQuotes` | Verifies that Windows path comparison is case-insensitive, handles quote marks, and matches paths containing spaces. |
|
||||||
| `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. |
|
| `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. |
|
| `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`. |
|
| `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. |
|
| `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. |
|
| `TestCreateStartupShortcutHandlesSpaces` | Verifies that `.lnk` files are created with correct `TargetPath` and `--start-in-tray` arguments when the path contains spaces. |
|
||||||
|
| `TestCreateStartupShortcutWithoutTrayFlag` | Verifies that autostart shortcuts omit `--start-in-tray` when the tray setting is off. |
|
||||||
|
| `TestAutostartStatusRequiresMatchingTrayFlag` | Verifies `AutostartStatus` reports a problem when the shortcut arguments do not match `KeepRunningInTray`. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -392,6 +403,19 @@ Tests Linux autostart via XDG Desktop Entry files.
|
|||||||
| Test | Purpose |
|
| Test | Purpose |
|
||||||
|------|---------|
|
|------|---------|
|
||||||
| `TestLinuxAutostartStartsInTray` | Verifies that the XDG Desktop Entry is created with `--start-in-tray` in the `Exec=` field. |
|
| `TestLinuxAutostartStartsInTray` | Verifies that the XDG Desktop Entry is created with `--start-in-tray` in the `Exec=` field. |
|
||||||
|
| `TestLinuxAutostartWithoutTrayFlag` | Verifies that the desktop entry omits `--start-in-tray` when the tray setting is off. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### src/ui/tray_test.go
|
||||||
|
|
||||||
|
**Package:** `ui`
|
||||||
|
|
||||||
|
Tests startup helpers for tray and autostart interaction.
|
||||||
|
|
||||||
|
| Test | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `TestResolveStartHiddenUsesDomainHelper` | Verifies the UI startup helper stays aligned with `domain.ResolveStartHidden`. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -437,10 +461,7 @@ widgets are assembled.
|
|||||||
| `TestFilterValue` | Verifies that `filterValue` returns the correct display string for the current folder filter. |
|
| `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. |
|
| `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. |
|
| `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. |
|
| `TestFilteredJobIndexes` | Table: verifies the "All" filter returns every index, a named folder returns only its own jobs, "No folder" matches empty and blank folder fields, and an empty job list yields no indexes. |
|
||||||
| `TestFilteredJobIndexesByNamedFolder` | Verifies that filtering by a named folder returns only jobs in that folder. |
|
|
||||||
| `TestFilteredJobIndexesNoFolder` | Verifies that the "No folder" filter returns only jobs with an empty folder field. |
|
|
||||||
| `TestFilteredJobIndexesEmptySlice` | Verifies that filtering an empty job slice returns an empty index list. |
|
|
||||||
| `TestNextJobListViewFlipsBothWays` | Verifies the density toggle alternates between detailed and compact from either starting value. |
|
| `TestNextJobListViewFlipsBothWays` | Verifies the density toggle alternates between detailed and compact from either starting value. |
|
||||||
| `TestViewToggleTextNamesTheAction` | Verifies the toggle button is labelled with the action it performs, not the state it is in. |
|
| `TestViewToggleTextNamesTheAction` | Verifies the toggle button is labelled with the action it performs, not the state it is in. |
|
||||||
| `TestJobListViewToggleShrinksRowsAndPersists` | End-to-end: one tap shrinks the row height, relabels the button, and reaches the config; tapping back undoes all three. |
|
| `TestJobListViewToggleShrinksRowsAndPersists` | End-to-end: one tap shrinks the row height, relabels the button, and reaches the config; tapping back undoes all three. |
|
||||||
@@ -500,6 +521,7 @@ Tests the theme-derived sizing helpers in `layout.go`.
|
|||||||
| Test | Purpose |
|
| Test | Purpose |
|
||||||
|------|---------|
|
|------|---------|
|
||||||
| `TestRowOverlapMatchesInnerPadding` | Pins `rowOverlap` to `-theme.InnerPadding()` under two themes, the property that lets it follow a theme instead of drifting from a hand-tuned literal. |
|
| `TestRowOverlapMatchesInnerPadding` | Pins `rowOverlap` to `-theme.InnerPadding()` under two themes, the property that lets it follow a theme instead of drifting from a hand-tuned literal. |
|
||||||
|
| `TestCancelRowOverlapAddsBackOneInnerPadding` | Verifies that `cancelRowOverlap` adds back exactly one inner padding on the top edge only, leaving width and the row below unaffected. |
|
||||||
| `TestCaptionColumnWidth` | Covers no captions, one, and several of varying length, at two text sizes. |
|
| `TestCaptionColumnWidth` | Covers no captions, one, and several of varying length, at two text sizes. |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -514,8 +536,8 @@ Tests the branded theme and the stored theme choice.
|
|||||||
|------|---------|
|
|------|---------|
|
||||||
| `TestGoSentryThemeBrandColors` | Verifies the brand colors land on the semantically correct `ColorName`s in both the light and dark variants. |
|
| `TestGoSentryThemeBrandColors` | Verifies the brand colors land on the semantically correct `ColorName`s in both the light and dark variants. |
|
||||||
| `TestGoSentryThemeDelegatesUnbrandedColors` | Verifies unbranded color names fall through to the base theme rather than rendering transparent. |
|
| `TestGoSentryThemeDelegatesUnbrandedColors` | Verifies unbranded color names fall through to the base theme rather than rendering transparent. |
|
||||||
| `TestThemeForChoice` | Verifies the GoSentry choice yields the branded primary and every other value — including the empty legacy one — yields the default theme. |
|
| `TestThemeForChoice` | Verifies the GoSentry choice and the empty legacy value yield the branded primary; only the explicit system choice yields Fyne's built-in theme. |
|
||||||
| `TestThemeLabelRoundTrip` | Verifies the dropdown labels round-trip and that the empty value maps to the Default label rather than a blank option. |
|
| `TestThemeLabelRoundTrip` | Verifies the dropdown labels round-trip and that the empty value maps to the GoSentry label rather than a blank option. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -528,7 +550,7 @@ Tests main view construction with an injected `*app.Service`.
|
|||||||
| Test | Purpose |
|
| Test | Purpose |
|
||||||
|------|---------|
|
|------|---------|
|
||||||
| `TestMainViewFitsTheDefaultWindowSize` | Verifies the assembled content's minimum fits the window size the app asks for, so Fyne never silently widens the window past it. The store's config path is deliberately long, since it was the path label that used to grow the Settings tab. |
|
| `TestMainViewFitsTheDefaultWindowSize` | Verifies the assembled content's minimum fits the window size the app asks for, so Fyne never silently widens the window past it. The store's config path is deliberately long, since it was the path label that used to grow the Settings tab. |
|
||||||
| `TestMainViewBuilds` | Verifies `newMainView` assembles tabs without panic using `fyne.io/fyne/v2/test`. |
|
| `TestMainViewRecordStartupAddsHistoryRow` | Verifies the `recordStartup` closure `newMainView` returns appends the startup receipt to History and redraws the table, with the windowed and tray wordings `run.go` selects between. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -550,6 +572,23 @@ Tests main view construction with an injected `*app.Service`.
|
|||||||
|
|
||||||
8. **Geometry is measured, not eyeballed** — The `ui` tests that build widgets under `test.NewApp()` assert sizes and offsets, and several re-run under a scaled theme. That is what keeps [STANDARDS.md](STANDARDS.md)'s "measure at build time, never a pixel constant" rule enforceable rather than aspirational.
|
8. **Geometry is measured, not eyeballed** — The `ui` tests that build widgets under `test.NewApp()` assert sizes and offsets, and several re-run under a scaled theme. That is what keeps [STANDARDS.md](STANDARDS.md)'s "measure at build time, never a pixel constant" rule enforceable rather than aspirational.
|
||||||
|
|
||||||
|
9. **Redundancy is measured, not read** — Before deleting a test as a duplicate, run both in isolation with `-coverprofile` and compare the profiles. Identical coverage alone is *not* grounds for deletion: several kept tests hit the same statements while asserting genuinely different properties (see the table below). Deletion requires identical coverage **and** assertions that are a subset of the survivor's.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Look-alike tests that are kept
|
||||||
|
|
||||||
|
Every pair here has an identical coverage profile, so a redundancy pass will
|
||||||
|
flag them again. They were measured under principle 9 and kept because the
|
||||||
|
assertions differ — not because nobody looked.
|
||||||
|
|
||||||
|
| Tests | Why both stay |
|
||||||
|
|-------|---------------|
|
||||||
|
| `TestSetGlobalPausePersistsToConfigFile` / `TestSetGlobalPauseUpdatesRuntimesAndEmits` | The first asserts the flag reaches `gosentry.json`, which is what makes the pause survive a restart; the second asserts the in-memory runtimes and the emitted event. |
|
||||||
|
| `TestRunDueQueueDrainsMultipleOverlaps` / `TestRunDueQueueRerunsAfterFinish` | The first drains three queued occurrences rather than one, so it is the test that would catch a drain loop that fires only once. |
|
||||||
|
| `TestCreateStartupShortcutHandlesCyrillicPath` / `TestCreateStartupShortcutHandlesSpaces` | Non-ASCII paths and paths with spaces are different real-world failure modes for the WScript.Shell COM call. |
|
||||||
|
| `TestRunJobLogFileAllHeaders` / `TestRunJobRecordFields` / `TestRunJobWritesLogFile` | Three different subjects: the log file's headers, the returned `RunRecord`'s fields, and the log file's name and directory. The fixtures differ too — only `TestRunJobWritesLogFile` runs the `Manual` trigger. Merging them into one `RunJob` call was measured and declined: it saves ~90 ms (the three cost 0.14 s combined; the `runner` package's seconds are `TestRunJobTimesOut` and `TestRunJobZeroTimeoutMeansNoTimeout`, which wait on purpose) and would drop the `Manual` path from the header assertions. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Remaining Test Coverage Gaps
|
## Remaining Test Coverage Gaps
|
||||||
@@ -557,3 +596,13 @@ Tests main view construction with an injected `*app.Service`.
|
|||||||
- Full GUI E2E — tab navigation, dialog flows, and native file pickers are not exercised end-to-end; the `ui` tests assemble views and measure them, but nothing drives a real window.
|
- Full GUI E2E — tab navigation, dialog flows, and native file pickers are not exercised end-to-end; the `ui` tests assemble views and measure them, but nothing drives a real window.
|
||||||
- History is session-only by design — `.log` files seed aggregate stats only, not the History table (see [STANDARDS.md](STANDARDS.md))
|
- History is session-only by design — `.log` files seed aggregate stats only, not the History table (see [STANDARDS.md](STANDARDS.md))
|
||||||
- Fyne's headless driver cannot report a maximized window, which is why window-size persistence stays frozen in [ROADMAP.md](ROADMAP.md)
|
- Fyne's headless driver cannot report a maximized window, which is why window-size persistence stays frozen in [ROADMAP.md](ROADMAP.md)
|
||||||
|
|
||||||
|
### Functions deliberately at 0%
|
||||||
|
|
||||||
|
A coverage run over the non-UI packages reports these as uncovered. All are
|
||||||
|
intentional; none is an oversight to be "fixed" with a test.
|
||||||
|
|
||||||
|
- The real `Clock` — a fake is injected everywhere it is used.
|
||||||
|
- `storage.OpenStore`, `storage.ResolvePaths`, `app.Service.Start`, `app.Service.Open` — process entry points, exercised by running the app.
|
||||||
|
- The autostart and desktop-icon wrappers — OS integration, driven only on a real desktop.
|
||||||
|
- `app.Service.ShouldNotifyOnFailure` — a getter under the mutex.
|
||||||
|
|||||||
@@ -33,12 +33,6 @@ func TestEmitDeliversToAllObserversInOrder(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestEmitWithNoObserversIsNoop(t *testing.T) {
|
|
||||||
svc := newTestService(nil)
|
|
||||||
// Must not panic with an empty observer list.
|
|
||||||
svc.emit(JobChanged{})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Observers may read Service state from within OnEvent without deadlocking,
|
// Observers may read Service state from within OnEvent without deadlocking,
|
||||||
// because emit is called outside the state lock.
|
// because emit is called outside the state lock.
|
||||||
func TestObserverCanReadServiceState(t *testing.T) {
|
func TestObserverCanReadServiceState(t *testing.T) {
|
||||||
|
|||||||
@@ -481,10 +481,10 @@ func validateConfig(config domain.Config) error {
|
|||||||
if config.DefaultTimeoutSeconds < 0 {
|
if config.DefaultTimeoutSeconds < 0 {
|
||||||
return errors.New("default timeout must not be negative (0 means no timeout)")
|
return errors.New("default timeout must not be negative (0 means no timeout)")
|
||||||
}
|
}
|
||||||
// Empty Theme is accepted and normalized to the default on load, so older
|
// Empty Theme is accepted and normalized to the branded theme on load, so
|
||||||
// configs (and hand-built ones) stay valid without an explicit theme.
|
// older configs (and hand-built ones) stay valid without an explicit theme.
|
||||||
if config.Theme != "" && config.Theme != domain.ThemeDefault && config.Theme != domain.ThemeGoSentry {
|
if config.Theme != "" && config.Theme != domain.ThemeSystem && config.Theme != domain.ThemeGoSentry {
|
||||||
return errors.New("theme must be 'default' or 'gosentry'")
|
return errors.New("theme must be 'system' or 'gosentry'")
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-4
@@ -16,24 +16,26 @@ func (s *Service) InstallDesktopIcon(appID string, iconBytes []byte) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// AutostartStatus reports whether the platform autostart entry matches the
|
// AutostartStatus reports whether the platform autostart entry matches the
|
||||||
// current StartOnLogin setting in the stored config.
|
// current StartOnLogin and KeepRunningInTray settings in the stored config.
|
||||||
func (s *Service) AutostartStatus() (ok bool, message string) {
|
func (s *Service) AutostartStatus() (ok bool, message string) {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
enabled := s.store.Config.StartOnLogin
|
enabled := s.store.Config.StartOnLogin
|
||||||
|
startInTray := s.store.Config.KeepRunningInTray
|
||||||
execPath := s.store.Paths.ExecutablePath
|
execPath := s.store.Paths.ExecutablePath
|
||||||
manager := s.manager
|
manager := s.manager
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
if manager == nil {
|
if manager == nil {
|
||||||
return false, "autostart not available"
|
return false, "autostart not available"
|
||||||
}
|
}
|
||||||
return manager.Status(enabled, execPath)
|
return manager.Status(enabled, startInTray, execPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ApplyAutostart writes or removes the platform autostart entry to match the
|
// ApplyAutostart writes or removes the platform autostart entry to match the
|
||||||
// current StartOnLogin setting in the stored config. Call after UpdateSettings.
|
// current StartOnLogin and KeepRunningInTray settings. Call after UpdateSettings.
|
||||||
func (s *Service) ApplyAutostart() error {
|
func (s *Service) ApplyAutostart() error {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
enabled := s.store.Config.StartOnLogin
|
enabled := s.store.Config.StartOnLogin
|
||||||
|
startInTray := s.store.Config.KeepRunningInTray
|
||||||
execPath := s.store.Paths.ExecutablePath
|
execPath := s.store.Paths.ExecutablePath
|
||||||
iconPath := s.store.Paths.DesktopIcon
|
iconPath := s.store.Paths.DesktopIcon
|
||||||
manager := s.manager
|
manager := s.manager
|
||||||
@@ -41,5 +43,5 @@ func (s *Service) ApplyAutostart() error {
|
|||||||
if manager == nil {
|
if manager == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return manager.Set(enabled, execPath, iconPath)
|
return manager.Set(enabled, startInTray, execPath, iconPath)
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-45
@@ -255,6 +255,10 @@ func TestRunDueQueueRerunsAfterFinish(t *testing.T) {
|
|||||||
svc := newQueueService(t, domain.ExecutionModeParallel, domain.OverlapPolicyQueue, []domain.Job{
|
svc := newQueueService(t, domain.ExecutionModeParallel, domain.OverlapPolicyQueue, []domain.Job{
|
||||||
{ID: 1, Name: "A", Schedule: "@every 1h", Command: "echo", Enabled: true},
|
{ID: 1, Name: "A", Schedule: "@every 1h", Command: "echo", Enabled: true},
|
||||||
})
|
})
|
||||||
|
// Empty per-job OverlapPolicy inherits the global queue policy.
|
||||||
|
if svc.jobs[0].OverlapPolicy != "" {
|
||||||
|
t.Fatalf("test setup: job OverlapPolicy = %q, want empty (inherit)", svc.jobs[0].OverlapPolicy)
|
||||||
|
}
|
||||||
|
|
||||||
entered := make(chan int, 2)
|
entered := make(chan int, 2)
|
||||||
release := make(chan struct{})
|
release := make(chan struct{})
|
||||||
@@ -451,51 +455,6 @@ func TestRunDuePerJobSkipOverridesGlobalQueue(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestRunDueEmptyOverlapInheritsGlobal verifies that a job with no own policy
|
|
||||||
// inherits the global default: with global "queue" and an empty Job.OverlapPolicy
|
|
||||||
// the job queues a re-run.
|
|
||||||
func TestRunDueEmptyOverlapInheritsGlobal(t *testing.T) {
|
|
||||||
svc := newQueueService(t, domain.ExecutionModeParallel, domain.OverlapPolicyQueue, []domain.Job{
|
|
||||||
{ID: 1, Name: "A", Schedule: "@every 1h", Command: "echo", Enabled: true},
|
|
||||||
})
|
|
||||||
if svc.jobs[0].OverlapPolicy != "" {
|
|
||||||
t.Fatalf("test setup: job OverlapPolicy = %q, want empty (inherit)", svc.jobs[0].OverlapPolicy)
|
|
||||||
}
|
|
||||||
|
|
||||||
entered := make(chan int, 2)
|
|
||||||
release := make(chan struct{})
|
|
||||||
svc.runJob = func(_ context.Context, job *domain.Job, _ string, _ string, _ time.Duration) (domain.RunRecord, error) {
|
|
||||||
entered <- job.ID
|
|
||||||
<-release
|
|
||||||
return domain.RunRecord{Time: "t", JobID: job.ID, JobName: job.Name, State: "Success"}, nil
|
|
||||||
}
|
|
||||||
done := completions(svc)
|
|
||||||
|
|
||||||
primeDue(t, svc, 1)
|
|
||||||
svc.RunDue(time.Now())
|
|
||||||
if id := <-entered; id != 1 {
|
|
||||||
t.Fatalf("started job = %d, want 1", id)
|
|
||||||
}
|
|
||||||
|
|
||||||
primeDue(t, svc, 1)
|
|
||||||
svc.RunDue(time.Now())
|
|
||||||
expectNoEntry(t, entered)
|
|
||||||
|
|
||||||
svc.mu.Lock()
|
|
||||||
pending := svc.runtimes[1].PendingRuns
|
|
||||||
svc.mu.Unlock()
|
|
||||||
if pending != 1 {
|
|
||||||
t.Fatalf("empty per-job policy must inherit global queue, PendingRuns = %d", pending)
|
|
||||||
}
|
|
||||||
|
|
||||||
close(release)
|
|
||||||
waitRecord(t, done)
|
|
||||||
if id := <-entered; id != 1 {
|
|
||||||
t.Fatalf("inherited-queue re-run job = %d, want 1", id)
|
|
||||||
}
|
|
||||||
waitRecord(t, done)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestRunNowSequentialGuard verifies the sequential-mode guard in RunNow: a manual
|
// TestRunNowSequentialGuard verifies the sequential-mode guard in RunNow: a manual
|
||||||
// run is refused while another job is running, and allowed once nothing is.
|
// run is refused while another job is running, and allowed once nothing is.
|
||||||
func TestRunNowSequentialGuard(t *testing.T) {
|
func TestRunNowSequentialGuard(t *testing.T) {
|
||||||
|
|||||||
@@ -47,11 +47,3 @@ func TestJobsReturnsCopy(t *testing.T) {
|
|||||||
t.Errorf("Service state leaked through Jobs(): name = %q, want %q", again[0].Name, "Original")
|
t.Errorf("Service state leaked through Jobs(): name = %q, want %q", again[0].Name, "Original")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestStoreReturnsWiredStore(t *testing.T) {
|
|
||||||
store := &storage.Store{}
|
|
||||||
svc := NewService(store, nil)
|
|
||||||
if svc.Store() != store {
|
|
||||||
t.Error("Store() did not return the wired store")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+1
-1
@@ -3,4 +3,4 @@ package app
|
|||||||
// Version is the application version shown in the GUI and used by build
|
// Version is the application version shown in the GUI and used by build
|
||||||
// scripts in artifact names. It is a var rather than a const so release builds
|
// scripts in artifact names. It is a var rather than a const so release builds
|
||||||
// can override it with Go ldflags when CI tags a build.
|
// can override it with Go ldflags when CI tags a build.
|
||||||
var Version = "1.0.0"
|
var Version = "1.0.1"
|
||||||
|
|||||||
+22
-5
@@ -5,6 +5,23 @@ package domain
|
|||||||
// launches omit this flag and open the normal window.
|
// launches omit this flag and open the normal window.
|
||||||
const StartInTrayArgument = "--start-in-tray"
|
const StartInTrayArgument = "--start-in-tray"
|
||||||
|
|
||||||
|
// AutostartArguments returns the command-line suffix written to a platform
|
||||||
|
// autostart entry when KeepRunningInTray is enabled. An empty string means the
|
||||||
|
// app should open its window normally after sign-in.
|
||||||
|
func AutostartArguments(keepInTray bool) string {
|
||||||
|
if keepInTray {
|
||||||
|
return StartInTrayArgument
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveStartHidden reports whether an autostart launch should skip showing
|
||||||
|
// the main window. The CLI flag is ignored when KeepRunningInTray is off so a
|
||||||
|
// stale shortcut cannot hide the app with no tray icon to restore it.
|
||||||
|
func ResolveStartHidden(cliStartInTray, keepInTray bool) bool {
|
||||||
|
return cliStartInTray && keepInTray
|
||||||
|
}
|
||||||
|
|
||||||
// ExecutionMode controls whether due jobs run concurrently or one at a time.
|
// ExecutionMode controls whether due jobs run concurrently or one at a time.
|
||||||
type ExecutionMode string
|
type ExecutionMode string
|
||||||
|
|
||||||
@@ -21,8 +38,8 @@ const (
|
|||||||
type Theme string
|
type Theme string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// ThemeDefault keeps Fyne's built-in theme — the original look.
|
// ThemeSystem keeps Fyne's built-in theme, following the platform look.
|
||||||
ThemeDefault Theme = "default"
|
ThemeSystem Theme = "system"
|
||||||
// ThemeGoSentry applies the branded teal/amber theme derived from the logo
|
// ThemeGoSentry applies the branded teal/amber theme derived from the logo
|
||||||
// and app icon.
|
// and app icon.
|
||||||
ThemeGoSentry Theme = "gosentry"
|
ThemeGoSentry Theme = "gosentry"
|
||||||
@@ -87,8 +104,8 @@ type Config struct {
|
|||||||
// omitempty would hide a deliberate choice from the hand-editable config.
|
// omitempty would hide a deliberate choice from the hand-editable config.
|
||||||
DefaultTimeoutSeconds int `json:"default_timeout_seconds"`
|
DefaultTimeoutSeconds int `json:"default_timeout_seconds"`
|
||||||
Paused bool `json:"paused,omitempty"`
|
Paused bool `json:"paused,omitempty"`
|
||||||
// Theme selects the visual appearance. Empty is treated as ThemeDefault so
|
// Theme selects the visual appearance. Empty is treated as ThemeGoSentry so
|
||||||
// configs written before this field existed keep the original look.
|
// configs written before this field existed pick up the branded look.
|
||||||
Theme Theme `json:"theme,omitempty"`
|
Theme Theme `json:"theme,omitempty"`
|
||||||
// JobListView selects the Jobs list density. Empty is treated as
|
// JobListView selects the Jobs list density. Empty is treated as
|
||||||
// JobListViewDetailed so configs written before this field existed keep the
|
// JobListViewDetailed so configs written before this field existed keep the
|
||||||
@@ -110,7 +127,7 @@ func DefaultConfig() Config {
|
|||||||
NotifyOnFailure: true,
|
NotifyOnFailure: true,
|
||||||
ExecutionMode: ExecutionModeParallel,
|
ExecutionMode: ExecutionModeParallel,
|
||||||
OverlapPolicy: OverlapPolicySkip,
|
OverlapPolicy: OverlapPolicySkip,
|
||||||
Theme: ThemeDefault,
|
Theme: ThemeGoSentry,
|
||||||
JobListView: JobListViewDetailed,
|
JobListView: JobListViewDetailed,
|
||||||
DefaultTimeoutSeconds: 0,
|
DefaultTimeoutSeconds: 0,
|
||||||
}
|
}
|
||||||
|
|||||||
+18
-20
@@ -2,29 +2,27 @@ package domain
|
|||||||
|
|
||||||
import "testing"
|
import "testing"
|
||||||
|
|
||||||
// TestJobListViewIsCompact pins the normalization rule: only the exact
|
func TestAutostartArguments(t *testing.T) {
|
||||||
// "compact" value selects the one-line rows, so empty and unrecognised values
|
if got := AutostartArguments(true); got != StartInTrayArgument {
|
||||||
// (including configs written before the field existed) keep the detailed look.
|
t.Errorf("AutostartArguments(true) = %q, want %q", got, StartInTrayArgument)
|
||||||
func TestJobListViewIsCompact(t *testing.T) {
|
|
||||||
cases := []struct {
|
|
||||||
view JobListView
|
|
||||||
want bool
|
|
||||||
}{
|
|
||||||
{JobListViewCompact, true},
|
|
||||||
{JobListViewDetailed, false},
|
|
||||||
{"", false},
|
|
||||||
{"Compact", false},
|
|
||||||
{"tiny", false},
|
|
||||||
}
|
|
||||||
for _, tc := range cases {
|
|
||||||
if got := tc.view.IsCompact(); got != tc.want {
|
|
||||||
t.Errorf("JobListView(%q).IsCompact() = %v, want %v", tc.view, got, tc.want)
|
|
||||||
}
|
}
|
||||||
|
if got := AutostartArguments(false); got != "" {
|
||||||
|
t.Errorf("AutostartArguments(false) = %q, want empty", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDefaultConfigUsesDetailedJobList(t *testing.T) {
|
func TestResolveStartHidden(t *testing.T) {
|
||||||
if got := DefaultConfig().JobListView; got != JobListViewDetailed {
|
cases := []struct {
|
||||||
t.Errorf("default JobListView = %q, want %q", got, JobListViewDetailed)
|
cli, keep, want bool
|
||||||
|
}{
|
||||||
|
{true, true, true},
|
||||||
|
{true, false, false},
|
||||||
|
{false, true, false},
|
||||||
|
{false, false, false},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
if got := ResolveStartHidden(tc.cli, tc.keep); got != tc.want {
|
||||||
|
t.Errorf("ResolveStartHidden(%v, %v) = %v, want %v", tc.cli, tc.keep, got, tc.want)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ package autostart
|
|||||||
// Manager controls platform autostart for the application.
|
// Manager controls platform autostart for the application.
|
||||||
type Manager interface {
|
type Manager interface {
|
||||||
// Set writes or removes the platform autostart entry to match enabled.
|
// Set writes or removes the platform autostart entry to match enabled.
|
||||||
Set(enabled bool, executablePath, iconPath string) error
|
// When enabled, startInTray selects whether the entry passes --start-in-tray.
|
||||||
// Status reports whether the platform autostart entry matches expectedEnabled.
|
Set(enabled, startInTray bool, executablePath, iconPath string) error
|
||||||
Status(expectedEnabled bool, executablePath string) (ok bool, message string)
|
// Status reports whether the platform autostart entry matches expectedEnabled
|
||||||
|
// and startInTray.
|
||||||
|
Status(expectedEnabled, startInTray bool, executablePath string) (ok bool, message string)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,17 +17,17 @@ type linuxManager struct{}
|
|||||||
// New returns the Linux autostart Manager.
|
// New returns the Linux autostart Manager.
|
||||||
func New() Manager { return linuxManager{} }
|
func New() Manager { return linuxManager{} }
|
||||||
|
|
||||||
func (linuxManager) Set(enabled bool, executablePath, iconPath string) error {
|
func (linuxManager) Set(enabled, startInTray bool, executablePath, iconPath string) error {
|
||||||
return SetAutostart(enabled, executablePath, iconPath)
|
return SetAutostart(enabled, startInTray, executablePath, iconPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (linuxManager) Status(expectedEnabled bool, executablePath string) (bool, string) {
|
func (linuxManager) Status(expectedEnabled, startInTray bool, executablePath string) (bool, string) {
|
||||||
return AutostartStatus(expectedEnabled, executablePath)
|
return AutostartStatus(expectedEnabled, startInTray, executablePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
const autostartDesktopFileName = "gosentry.desktop"
|
const autostartDesktopFileName = "gosentry.desktop"
|
||||||
|
|
||||||
func SetAutostart(enabled bool, executablePath string, iconPath string) error {
|
func SetAutostart(enabled bool, startInTray bool, executablePath string, iconPath string) error {
|
||||||
desktopPath, err := autostartDesktopPath()
|
desktopPath, err := autostartDesktopPath()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -36,15 +36,19 @@ func SetAutostart(enabled bool, executablePath string, iconPath string) error {
|
|||||||
if err := os.MkdirAll(filepath.Dir(desktopPath), 0o755); err != nil {
|
if err := os.MkdirAll(filepath.Dir(desktopPath), 0o755); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
execLine := quoteDesktopExec(executablePath)
|
||||||
|
if args := domain.AutostartArguments(startInTray); args != "" {
|
||||||
|
execLine += " " + args
|
||||||
|
}
|
||||||
desktopFile := fmt.Sprintf(`[Desktop Entry]
|
desktopFile := fmt.Sprintf(`[Desktop Entry]
|
||||||
Type=Application
|
Type=Application
|
||||||
Name=GoSentry
|
Name=GoSentry
|
||||||
Comment=GoSentry desktop scheduler
|
Comment=GoSentry desktop scheduler
|
||||||
Exec=%s %s
|
Exec=%s
|
||||||
%s
|
%s
|
||||||
Terminal=false
|
Terminal=false
|
||||||
X-GNOME-Autostart-enabled=true
|
X-GNOME-Autostart-enabled=true
|
||||||
`, quoteDesktopExec(executablePath), domain.StartInTrayArgument, desktopIconLine(iconPath))
|
`, execLine, desktopIconLine(iconPath))
|
||||||
return os.WriteFile(desktopPath, []byte(desktopFile), 0o644)
|
return os.WriteFile(desktopPath, []byte(desktopFile), 0o644)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,7 +58,7 @@ X-GNOME-Autostart-enabled=true
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func AutostartStatus(expectedEnabled bool, executablePath string) (bool, string) {
|
func AutostartStatus(expectedEnabled bool, startInTray bool, executablePath string) (bool, string) {
|
||||||
desktopPath, err := autostartDesktopPath()
|
desktopPath, err := autostartDesktopPath()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, "Cannot resolve XDG autostart directory"
|
return false, "Cannot resolve XDG autostart directory"
|
||||||
@@ -70,9 +74,15 @@ func AutostartStatus(expectedEnabled bool, executablePath string) (bool, string)
|
|||||||
if readErr != nil {
|
if readErr != nil {
|
||||||
return false, "Autostart desktop entry is missing"
|
return false, "Autostart desktop entry is missing"
|
||||||
}
|
}
|
||||||
expectedExec := "Exec=" + quoteDesktopExec(executablePath) + " " + domain.StartInTrayArgument
|
expectedExec := "Exec=" + quoteDesktopExec(executablePath)
|
||||||
|
if args := domain.AutostartArguments(startInTray); args != "" {
|
||||||
|
expectedExec += " " + args
|
||||||
|
}
|
||||||
if !strings.Contains(string(data), expectedExec) {
|
if !strings.Contains(string(data), expectedExec) {
|
||||||
return false, "Autostart desktop entry points to another executable"
|
if startInTray {
|
||||||
|
return false, "Autostart desktop entry does not start in tray"
|
||||||
|
}
|
||||||
|
return false, "Autostart desktop entry starts in tray while setting is off"
|
||||||
}
|
}
|
||||||
return true, "Autostart is configured"
|
return true, "Autostart is configured"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ func TestLinuxAutostartStartsInTray(t *testing.T) {
|
|||||||
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||||
|
|
||||||
executablePath := "/opt/Go Sentry/gosentry"
|
executablePath := "/opt/Go Sentry/gosentry"
|
||||||
if err := SetAutostart(true, executablePath, "/opt/Go Sentry/gosentry.png"); err != nil {
|
if err := SetAutostart(true, true, executablePath, "/opt/Go Sentry/gosentry.png"); err != nil {
|
||||||
t.Fatalf("enable autostart: %v", err)
|
t.Fatalf("enable autostart: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,3 +33,29 @@ func TestLinuxAutostartStartsInTray(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLinuxAutostartWithoutTrayFlag(t *testing.T) {
|
||||||
|
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||||
|
|
||||||
|
executablePath := "/opt/Go Sentry/gosentry"
|
||||||
|
if err := SetAutostart(true, false, executablePath, ""); err != nil {
|
||||||
|
t.Fatalf("enable autostart: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
desktopPath, err := autostartDesktopPath()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("resolve desktop path: %v", err)
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(desktopPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read desktop entry: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
expectedExec := "Exec=" + quoteDesktopExec(executablePath)
|
||||||
|
if !strings.Contains(string(data), expectedExec) {
|
||||||
|
t.Fatalf("desktop entry should not include tray flag: %s", data)
|
||||||
|
}
|
||||||
|
if strings.Contains(string(data), domain.StartInTrayArgument) {
|
||||||
|
t.Fatalf("desktop entry must not pass --start-in-tray: %s", data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,22 +9,22 @@ type otherManager struct{}
|
|||||||
// New returns the stub autostart Manager for unsupported platforms.
|
// New returns the stub autostart Manager for unsupported platforms.
|
||||||
func New() Manager { return otherManager{} }
|
func New() Manager { return otherManager{} }
|
||||||
|
|
||||||
func (otherManager) Set(enabled bool, executablePath, iconPath string) error {
|
func (otherManager) Set(enabled, startInTray bool, executablePath, iconPath string) error {
|
||||||
return SetAutostart(enabled, executablePath, iconPath)
|
return SetAutostart(enabled, startInTray, executablePath, iconPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (otherManager) Status(expectedEnabled bool, executablePath string) (bool, string) {
|
func (otherManager) Status(expectedEnabled, startInTray bool, executablePath string) (bool, string) {
|
||||||
return AutostartStatus(expectedEnabled, executablePath)
|
return AutostartStatus(expectedEnabled, startInTray, executablePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
func SetAutostart(enabled bool, executablePath string, iconPath string) error {
|
func SetAutostart(enabled bool, startInTray bool, executablePath string, iconPath string) error {
|
||||||
if !enabled {
|
if !enabled {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return fmt.Errorf("autostart is not implemented for this platform")
|
return fmt.Errorf("autostart is not implemented for this platform")
|
||||||
}
|
}
|
||||||
|
|
||||||
func AutostartStatus(expectedEnabled bool, executablePath string) (bool, string) {
|
func AutostartStatus(expectedEnabled bool, startInTray bool, executablePath string) (bool, string) {
|
||||||
if !expectedEnabled {
|
if !expectedEnabled {
|
||||||
return true, "Autostart is off"
|
return true, "Autostart is off"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,18 +16,18 @@ type windowsManager struct{}
|
|||||||
// New returns the Windows autostart Manager.
|
// New returns the Windows autostart Manager.
|
||||||
func New() Manager { return windowsManager{} }
|
func New() Manager { return windowsManager{} }
|
||||||
|
|
||||||
func (windowsManager) Set(enabled bool, executablePath, iconPath string) error {
|
func (windowsManager) Set(enabled, startInTray bool, executablePath, iconPath string) error {
|
||||||
return SetAutostart(enabled, executablePath, iconPath)
|
return SetAutostart(enabled, startInTray, executablePath, iconPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (windowsManager) Status(expectedEnabled bool, executablePath string) (bool, string) {
|
func (windowsManager) Status(expectedEnabled, startInTray bool, executablePath string) (bool, string) {
|
||||||
return AutostartStatus(expectedEnabled, executablePath)
|
return AutostartStatus(expectedEnabled, startInTray, executablePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
const autostartName = "GoSentry"
|
const autostartName = "GoSentry"
|
||||||
const startupShortcutFile = autostartName + ".lnk"
|
const startupShortcutFile = autostartName + ".lnk"
|
||||||
|
|
||||||
func SetAutostart(enabled bool, executablePath string, iconPath string) error {
|
func SetAutostart(enabled bool, startInTray bool, executablePath string, iconPath string) error {
|
||||||
// Windows autostart used to write HKCU\Run values, but that approach became
|
// Windows autostart used to write HKCU\Run values, but that approach became
|
||||||
// brittle once paths with spaces and the "--start-in-tray" argument entered
|
// brittle once paths with spaces and the "--start-in-tray" argument entered
|
||||||
// the picture. A Startup-folder shortcut stores target path and arguments as
|
// the picture. A Startup-folder shortcut stores target path and arguments as
|
||||||
@@ -39,12 +39,12 @@ func SetAutostart(enabled bool, executablePath string, iconPath string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if enabled {
|
if enabled {
|
||||||
return createStartupShortcut(shortcutPath, executablePath, iconPath)
|
return createStartupShortcut(shortcutPath, executablePath, iconPath, domain.AutostartArguments(startInTray))
|
||||||
}
|
}
|
||||||
return removeIfExists(shortcutPath)
|
return removeIfExists(shortcutPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
func AutostartStatus(expectedEnabled bool, executablePath string) (bool, string) {
|
func AutostartStatus(expectedEnabled bool, startInTray bool, executablePath string) (bool, string) {
|
||||||
shortcutPath, err := startupShortcutPath()
|
shortcutPath, err := startupShortcutPath()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, "Startup folder cannot be resolved"
|
return false, "Startup folder cannot be resolved"
|
||||||
@@ -74,9 +74,13 @@ func AutostartStatus(expectedEnabled bool, executablePath string) (bool, string)
|
|||||||
if !sameWindowsPath(actual, executablePath) {
|
if !sameWindowsPath(actual, executablePath) {
|
||||||
return false, "Autostart shortcut points to another executable"
|
return false, "Autostart shortcut points to another executable"
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(arguments) != domain.StartInTrayArgument {
|
expectedArgs := domain.AutostartArguments(startInTray)
|
||||||
|
if strings.TrimSpace(arguments) != expectedArgs {
|
||||||
|
if startInTray {
|
||||||
return false, "Autostart shortcut does not start in tray"
|
return false, "Autostart shortcut does not start in tray"
|
||||||
}
|
}
|
||||||
|
return false, "Autostart shortcut starts in tray while setting is off"
|
||||||
|
}
|
||||||
return true, "Autostart is configured"
|
return true, "Autostart is configured"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,7 +92,7 @@ func startupShortcutPath() (string, error) {
|
|||||||
return filepath.Join(appData, "Microsoft", "Windows", "Start Menu", "Programs", "Startup", startupShortcutFile), nil
|
return filepath.Join(appData, "Microsoft", "Windows", "Start Menu", "Programs", "Startup", startupShortcutFile), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func createStartupShortcut(shortcutPath string, executablePath string, iconPath string) error {
|
func createStartupShortcut(shortcutPath string, executablePath string, iconPath string, arguments string) error {
|
||||||
if err := os.MkdirAll(filepath.Dir(shortcutPath), 0755); err != nil {
|
if err := os.MkdirAll(filepath.Dir(shortcutPath), 0755); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -106,7 +110,7 @@ func createStartupShortcut(shortcutPath string, executablePath string, iconPath
|
|||||||
command.Env = append(os.Environ(),
|
command.Env = append(os.Environ(),
|
||||||
"GOSENTRY_SHORTCUT_PATH="+shortcutPath,
|
"GOSENTRY_SHORTCUT_PATH="+shortcutPath,
|
||||||
"GOSENTRY_TARGET_PATH="+executablePath,
|
"GOSENTRY_TARGET_PATH="+executablePath,
|
||||||
"GOSENTRY_ARGUMENTS="+domain.StartInTrayArgument,
|
"GOSENTRY_ARGUMENTS="+arguments,
|
||||||
"GOSENTRY_WORKING_DIRECTORY="+workingDirectory,
|
"GOSENTRY_WORKING_DIRECTORY="+workingDirectory,
|
||||||
"GOSENTRY_ICON_PATH="+iconPath,
|
"GOSENTRY_ICON_PATH="+iconPath,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -12,14 +12,8 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestSameWindowsPathIgnoresCaseAndQuotes(t *testing.T) {
|
func TestSameWindowsPathIgnoresCaseAndQuotes(t *testing.T) {
|
||||||
if !sameWindowsPath(`"D:\Apps\GoSentry\gosentry.exe"`, `d:\apps\gosentry\gosentry.exe`) {
|
|
||||||
t.Fatal("expected paths to match")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSameWindowsPathHandlesSpaces(t *testing.T) {
|
|
||||||
if !sameWindowsPath(`"D:\Local Git\GoSentry\gosentry.exe"`, `d:\local git\gosentry\gosentry.exe`) {
|
if !sameWindowsPath(`"D:\Local Git\GoSentry\gosentry.exe"`, `d:\local git\gosentry\gosentry.exe`) {
|
||||||
t.Fatal("expected paths with spaces to match")
|
t.Fatal("expected paths to match")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,7 +82,7 @@ func TestCreateStartupShortcutHandlesCyrillicPath(t *testing.T) {
|
|||||||
t.Fatalf("create target file: %v", err)
|
t.Fatalf("create target file: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := createStartupShortcut(shortcutPath, targetPath, ""); err != nil {
|
if err := createStartupShortcut(shortcutPath, targetPath, "", domain.StartInTrayArgument); err != nil {
|
||||||
t.Fatalf("create shortcut: %v", err)
|
t.Fatalf("create shortcut: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,6 +98,51 @@ func TestCreateStartupShortcutHandlesCyrillicPath(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCreateStartupShortcutWithoutTrayFlag(t *testing.T) {
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
shortcutPath := filepath.Join(tempDir, "GoSentry.lnk")
|
||||||
|
targetPath := filepath.Join(tempDir, "gosentry.exe")
|
||||||
|
if err := os.WriteFile(targetPath, []byte("test"), 0644); err != nil {
|
||||||
|
t.Fatalf("create target file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := createStartupShortcut(shortcutPath, targetPath, "", ""); err != nil {
|
||||||
|
t.Fatalf("create shortcut: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, arguments, err := readShortcut(shortcutPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read shortcut: %v", err)
|
||||||
|
}
|
||||||
|
if arguments != "" {
|
||||||
|
t.Fatalf("shortcut arguments mismatch: got %q want empty", arguments)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAutostartStatusRequiresMatchingTrayFlag(t *testing.T) {
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
t.Setenv("APPDATA", tempDir)
|
||||||
|
shortcutPath, err := startupShortcutPath()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("startupShortcutPath: %v", err)
|
||||||
|
}
|
||||||
|
targetPath := filepath.Join(tempDir, "gosentry.exe")
|
||||||
|
if err := os.WriteFile(targetPath, []byte("test"), 0644); err != nil {
|
||||||
|
t.Fatalf("create target: %v", err)
|
||||||
|
}
|
||||||
|
if err := createStartupShortcut(shortcutPath, targetPath, "", domain.StartInTrayArgument); err != nil {
|
||||||
|
t.Fatalf("create shortcut: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ok, message := AutostartStatus(true, false, targetPath)
|
||||||
|
if ok {
|
||||||
|
t.Fatalf("expected problem when tray flag mismatches, got OK: %s", message)
|
||||||
|
}
|
||||||
|
if message != "Autostart shortcut starts in tray while setting is off" {
|
||||||
|
t.Fatalf("unexpected message: %q", message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestCreateStartupShortcutHandlesSpaces(t *testing.T) {
|
func TestCreateStartupShortcutHandlesSpaces(t *testing.T) {
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
shortcutPath := filepath.Join(tempDir, "GoSentry test.lnk")
|
shortcutPath := filepath.Join(tempDir, "GoSentry test.lnk")
|
||||||
@@ -115,7 +154,7 @@ func TestCreateStartupShortcutHandlesSpaces(t *testing.T) {
|
|||||||
t.Fatalf("create target file: %v", err)
|
t.Fatalf("create target file: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := createStartupShortcut(shortcutPath, targetPath, ""); err != nil {
|
if err := createStartupShortcut(shortcutPath, targetPath, "", domain.StartInTrayArgument); err != nil {
|
||||||
t.Fatalf("create shortcut: %v", err)
|
t.Fatalf("create shortcut: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -50,22 +50,6 @@ func TestCleanupLogsRemovesFilesPastMaxAge(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
// TestCleanupLogsByCountDeletesOldest verifies the count-based policy: when more
|
||||||
// than maxFiles log files exist the oldest (by modification time) are removed.
|
// than maxFiles log files exist the oldest (by modification time) are removed.
|
||||||
// maxAgeDays=0 disables age-based cleanup so the test exercises count only.
|
// maxAgeDays=0 disables age-based cleanup so the test exercises count only.
|
||||||
|
|||||||
+1
-20
@@ -22,7 +22,7 @@ func writeTestLog(t *testing.T, dir, filename, state string, durationMS int64, j
|
|||||||
content.WriteString("\n")
|
content.WriteString("\n")
|
||||||
}
|
}
|
||||||
if durationMS >= 0 {
|
if durationMS >= 0 {
|
||||||
content.WriteString("state: " + state + "\nduration: " + itoa(durationMS) + "\n\n")
|
content.WriteString("state: " + state + "\nduration: " + strconv.FormatInt(durationMS, 10) + "\n\n")
|
||||||
} else {
|
} else {
|
||||||
content.WriteString("state: " + state + "\n\n")
|
content.WriteString("state: " + state + "\n\n")
|
||||||
}
|
}
|
||||||
@@ -31,25 +31,6 @@ func writeTestLog(t *testing.T, dir, filename, state string, durationMS int64, j
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func itoa(n int64) string {
|
|
||||||
if n == 0 {
|
|
||||||
return "0"
|
|
||||||
}
|
|
||||||
neg := n < 0
|
|
||||||
if neg {
|
|
||||||
n = -n
|
|
||||||
}
|
|
||||||
buf := make([]byte, 0, 20)
|
|
||||||
for n > 0 {
|
|
||||||
buf = append([]byte{byte('0' + n%10)}, buf...)
|
|
||||||
n /= 10
|
|
||||||
}
|
|
||||||
if neg {
|
|
||||||
buf = append([]byte{'-'}, buf...)
|
|
||||||
}
|
|
||||||
return string(buf)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSeedStatsBasic(t *testing.T) {
|
func TestSeedStatsBasic(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
job := domain.Job{ID: 1, Name: "Build"}
|
job := domain.Job{ID: 1, Name: "Build"}
|
||||||
|
|||||||
+37
-3
@@ -16,6 +16,21 @@ type Store struct {
|
|||||||
Config domain.Config
|
Config domain.Config
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PeekKeepRunningInTray reads keep_running_in_tray from gosentry.json for startup
|
||||||
|
// decisions that must run before app.Open(). On error it returns the built-in
|
||||||
|
// default.
|
||||||
|
func PeekKeepRunningInTray() bool {
|
||||||
|
paths, err := ResolvePaths()
|
||||||
|
if err != nil {
|
||||||
|
return domain.DefaultConfig().KeepRunningInTray
|
||||||
|
}
|
||||||
|
config, err := loadOrCreateConfig(paths)
|
||||||
|
if err != nil {
|
||||||
|
return domain.DefaultConfig().KeepRunningInTray
|
||||||
|
}
|
||||||
|
return config.KeepRunningInTray
|
||||||
|
}
|
||||||
|
|
||||||
func OpenStore() (*Store, []domain.Job, error) {
|
func OpenStore() (*Store, []domain.Job, error) {
|
||||||
paths, err := ResolvePaths()
|
paths, err := ResolvePaths()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -118,7 +133,10 @@ func loadOrCreateConfig(paths Paths) (domain.Config, error) {
|
|||||||
// the setting impossible to persist. Negative values are rejected by
|
// the setting impossible to persist. Negative values are rejected by
|
||||||
// app.validateConfig before they can be saved.
|
// app.validateConfig before they can be saved.
|
||||||
if config.Theme == "" {
|
if config.Theme == "" {
|
||||||
config.Theme = domain.ThemeDefault
|
config.Theme = domain.ThemeGoSentry
|
||||||
|
}
|
||||||
|
if config.Theme == "default" {
|
||||||
|
config.Theme = domain.ThemeSystem
|
||||||
}
|
}
|
||||||
return config, nil
|
return config, nil
|
||||||
}
|
}
|
||||||
@@ -151,8 +169,9 @@ func loadOrCreateJobs(path string) ([]domain.Job, error) {
|
|||||||
if found {
|
if found {
|
||||||
return jobs, nil
|
return jobs, nil
|
||||||
}
|
}
|
||||||
// Seed harmless sample jobs so a new user can immediately see scheduled
|
// Seed sample jobs so a new user can immediately see scheduled and manual
|
||||||
// and manual execution without inventing a command.
|
// execution without inventing a command. The failure sample stays disabled
|
||||||
|
// so it does not spam notifications; Run now still works for testing.
|
||||||
jobs = defaultJobs()
|
jobs = defaultJobs()
|
||||||
normalizeJobs(jobs)
|
normalizeJobs(jobs)
|
||||||
return jobs, writeJSON(path, domain.JobsFile{Jobs: jobs})
|
return jobs, writeJSON(path, domain.JobsFile{Jobs: jobs})
|
||||||
@@ -252,9 +271,24 @@ func defaultJobs() []domain.Job {
|
|||||||
Command: echoCommand("This paused sample should not run until enabled"),
|
Command: echoCommand("This paused sample should not run until enabled"),
|
||||||
Enabled: false,
|
Enabled: false,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
ID: 4,
|
||||||
|
Name: "Failure notification test",
|
||||||
|
Folder: "Examples",
|
||||||
|
Schedule: "@every 1m",
|
||||||
|
Command: failCommand(),
|
||||||
|
Enabled: false,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func failCommand() string {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
return "exit /b 1"
|
||||||
|
}
|
||||||
|
return "exit 1"
|
||||||
|
}
|
||||||
|
|
||||||
func echoCommand(message string) string {
|
func echoCommand(message string) string {
|
||||||
if runtime.GOOS == "windows" {
|
if runtime.GOOS == "windows" {
|
||||||
return "echo " + message
|
return "echo " + message
|
||||||
|
|||||||
@@ -171,8 +171,8 @@ func TestLoadOrCreateConfigCreatesDefaultsOnFirstRun(t *testing.T) {
|
|||||||
if got.DefaultTimeoutSeconds != 0 {
|
if got.DefaultTimeoutSeconds != 0 {
|
||||||
t.Errorf("default DefaultTimeoutSeconds = %d, want 0 (no timeout)", got.DefaultTimeoutSeconds)
|
t.Errorf("default DefaultTimeoutSeconds = %d, want 0 (no timeout)", got.DefaultTimeoutSeconds)
|
||||||
}
|
}
|
||||||
if got.Theme != domain.ThemeDefault {
|
if got.Theme != domain.ThemeGoSentry {
|
||||||
t.Errorf("default Theme = %q, want %q", got.Theme, domain.ThemeDefault)
|
t.Errorf("default Theme = %q, want %q", got.Theme, domain.ThemeGoSentry)
|
||||||
}
|
}
|
||||||
if got.JobListView != domain.JobListViewDetailed {
|
if got.JobListView != domain.JobListViewDetailed {
|
||||||
t.Errorf("default JobListView = %q, want %q", got.JobListView, domain.JobListViewDetailed)
|
t.Errorf("default JobListView = %q, want %q", got.JobListView, domain.JobListViewDetailed)
|
||||||
@@ -183,6 +183,69 @@ func TestLoadOrCreateConfigCreatesDefaultsOnFirstRun(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestLoadOrCreateJobsSeedsSampleJobsOnFirstRun verifies that a missing
|
||||||
|
// jobs.json is created with the sample jobs from defaultJobs, so a new user
|
||||||
|
// sees scheduled and manual execution without inventing a command.
|
||||||
|
func TestLoadOrCreateJobsSeedsSampleJobsOnFirstRun(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "jobs.json")
|
||||||
|
|
||||||
|
got, err := loadOrCreateJobs(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := defaultJobs()
|
||||||
|
if len(got) != len(want) {
|
||||||
|
t.Fatalf("got %d jobs, want %d", len(got), len(want))
|
||||||
|
}
|
||||||
|
for i := range want {
|
||||||
|
if got[i].Name != want[i].Name || got[i].Schedule != want[i].Schedule || got[i].Command != want[i].Command || got[i].Enabled != want[i].Enabled {
|
||||||
|
t.Errorf("job %d = %+v, want %+v", i, got[i], want[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The function must have written the seeded jobs to jobs.json.
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("jobs.json should have been created: %v", err)
|
||||||
|
}
|
||||||
|
var file domain.JobsFile
|
||||||
|
if err := json.Unmarshal(data, &file); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(file.Jobs) != len(want) {
|
||||||
|
t.Errorf("jobs.json has %d jobs, want %d", len(file.Jobs), len(want))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLoadOrCreateConfigMigratesLegacyThemeDefault covers a gosentry.json that
|
||||||
|
// still stores the retired "default" theme value: load normalizes it to system.
|
||||||
|
func TestLoadOrCreateConfigMigratesLegacyThemeDefault(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
paths := Paths{
|
||||||
|
AppDir: dir,
|
||||||
|
ConfigPath: filepath.Join(dir, ConfigFileName),
|
||||||
|
}
|
||||||
|
legacy := map[string]any{
|
||||||
|
"jobs_file": "jobs.json",
|
||||||
|
"logs_dir": "logs",
|
||||||
|
"max_log_files": 100,
|
||||||
|
"max_log_age_days": 30,
|
||||||
|
"theme": "default",
|
||||||
|
}
|
||||||
|
if err := writeJSON(paths.ConfigPath, legacy); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := loadOrCreateConfig(paths)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.Theme != domain.ThemeSystem {
|
||||||
|
t.Errorf("migrated Theme = %q, want %q", got.Theme, domain.ThemeSystem)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestLoadOrCreateConfigKeepsZeroTimeoutOnReload guards the "0 = no timeout"
|
// TestLoadOrCreateConfigKeepsZeroTimeoutOnReload guards the "0 = no timeout"
|
||||||
// setting against being normalized away when an existing gosentry.json is read
|
// setting against being normalized away when an existing gosentry.json is read
|
||||||
// back. Loading must not treat 0 as a missing value.
|
// back. Loading must not treat 0 as a missing value.
|
||||||
|
|||||||
+7
-1
@@ -336,7 +336,13 @@ func newJobsView(w fyne.Window, svc *app.Service) (fyne.CanvasObject, func()) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
toolbar := container.NewHBox(addButton, editButton, runButton, pauseButton, deleteButton, layout.NewSpacer())
|
toolbar := container.NewHBox(addButton, editButton, runButton, pauseButton, deleteButton, layout.NewSpacer())
|
||||||
globalControls := container.NewHBox(stopAllButton, schedulerState, layout.NewSpacer())
|
// The row sits directly under the tab bar with no AppTabs inset, while the
|
||||||
|
// default VBox gap below it is one theme padding — add the same on top so
|
||||||
|
// the button is not flush against the tabs.
|
||||||
|
globalControls := container.New(
|
||||||
|
layout.NewCustomPaddedLayout(theme.Padding(), 0, 0, 0),
|
||||||
|
container.NewHBox(stopAllButton, schedulerState, layout.NewSpacer()),
|
||||||
|
)
|
||||||
// The whole filter is one row: caption on the left, view toggle on the right,
|
// The whole filter is one row: caption on the left, view toggle on the right,
|
||||||
// select filling what is left. The border layout gives both edges their
|
// select filling what is left. The border layout gives both edges their
|
||||||
// MinSize, so the header is a line shorter than a stacked caption would make it.
|
// MinSize, so the header is a line shorter than a stacked caption would make it.
|
||||||
|
|||||||
+26
-31
@@ -56,48 +56,43 @@ func TestFolderOptionsAppendsUniqueFolders(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFilteredJobIndexesAll(t *testing.T) {
|
func TestFilteredJobIndexes(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{
|
jobs := []domain.Job{
|
||||||
{Folder: "Maintenance"}, // index 0
|
{Folder: "Maintenance"}, // index 0
|
||||||
{Folder: ""}, // index 1
|
{Folder: ""}, // index 1 — no folder
|
||||||
{Folder: "Maintenance"}, // index 2
|
{Folder: "Maintenance"}, // index 2
|
||||||
{Folder: "Reports"}, // index 3
|
{Folder: "Reports"}, // index 3
|
||||||
|
{Folder: " "}, // index 4 — blank reads as no folder
|
||||||
|
}
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
jobs []domain.Job
|
||||||
|
filter string
|
||||||
|
want []int
|
||||||
|
}{
|
||||||
|
{"all folders", jobs, allFolders, []int{0, 1, 2, 3, 4}},
|
||||||
|
{"named folder", jobs, "Maintenance", []int{0, 2}},
|
||||||
|
{"no folder", jobs, noFolder, []int{1, 4}},
|
||||||
|
{"empty job list", nil, allFolders, nil},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
got := filteredJobIndexes(tc.jobs, tc.filter)
|
||||||
|
if !sameIndexes(got, tc.want) {
|
||||||
|
t.Errorf("%s: filteredJobIndexes(_, %q) = %v, want %v", tc.name, tc.filter, got, tc.want)
|
||||||
}
|
}
|
||||||
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) {
|
func sameIndexes(got, want []int) bool {
|
||||||
jobs := []domain.Job{
|
if len(got) != len(want) {
|
||||||
{Folder: "Maintenance"}, // index 0 — excluded
|
return false
|
||||||
{Folder: ""}, // index 1 — no folder → included
|
|
||||||
{Folder: " "}, // index 2 — blank → included
|
|
||||||
}
|
}
|
||||||
got := filteredJobIndexes(jobs, noFolder)
|
for i := range got {
|
||||||
if len(got) != 2 || got[0] != 1 || got[1] != 2 {
|
if got[i] != want[i] {
|
||||||
t.Errorf("noFolder filter: got %v, want [1 2]", got)
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return true
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNextJobListViewFlipsBothWays(t *testing.T) {
|
func TestNextJobListViewFlipsBothWays(t *testing.T) {
|
||||||
|
|||||||
@@ -3,12 +3,16 @@ package ui
|
|||||||
import (
|
import (
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"gitea.mixdep.ru/mix/gosentry/src/app"
|
"gitea.mixdep.ru/mix/gosentry/src/app"
|
||||||
"gitea.mixdep.ru/mix/gosentry/src/domain"
|
"gitea.mixdep.ru/mix/gosentry/src/domain"
|
||||||
"gitea.mixdep.ru/mix/gosentry/src/storage"
|
"gitea.mixdep.ru/mix/gosentry/src/storage"
|
||||||
|
|
||||||
|
"fyne.io/fyne/v2"
|
||||||
|
"fyne.io/fyne/v2/container"
|
||||||
"fyne.io/fyne/v2/test"
|
"fyne.io/fyne/v2/test"
|
||||||
|
"fyne.io/fyne/v2/widget"
|
||||||
)
|
)
|
||||||
|
|
||||||
// newTestStore builds a Store rooted in a temp directory. It is separate from
|
// newTestStore builds a Store rooted in a temp directory. It is separate from
|
||||||
@@ -69,7 +73,35 @@ func TestMainViewFitsTheDefaultWindowSize(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMainViewBuilds(t *testing.T) {
|
// historyTable returns the History tab's table. It is the only widget.Table the
|
||||||
|
// main view builds, so the search does not need to know the tab order.
|
||||||
|
func historyTable(t *testing.T, content fyne.CanvasObject) *widget.Table {
|
||||||
|
t.Helper()
|
||||||
|
tabs, ok := content.(*container.AppTabs)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("main view is not the expected AppTabs container")
|
||||||
|
}
|
||||||
|
for _, item := range tabs.Items {
|
||||||
|
found := findFirst(item.Content, func(o fyne.CanvasObject) bool {
|
||||||
|
_, ok := o.(*widget.Table)
|
||||||
|
return ok
|
||||||
|
})
|
||||||
|
if found != nil {
|
||||||
|
return found.(*widget.Table)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatal("main view has no history table")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMainViewRecordStartupAddsHistoryRow covers the second return value of
|
||||||
|
// newMainView. run.go calls it once per launch with a different windowShown
|
||||||
|
// flag depending on whether the app started into the tray, and that call is the
|
||||||
|
// only thing that puts the startup receipt into History — so both the wording
|
||||||
|
// and the fact that the table is redrawn are worth pinning. Building the full
|
||||||
|
// tab set and setting it as the window content is a side benefit: no other test
|
||||||
|
// assembles all three tabs together.
|
||||||
|
func TestMainViewRecordStartupAddsHistoryRow(t *testing.T) {
|
||||||
testApp := test.NewApp()
|
testApp := test.NewApp()
|
||||||
defer testApp.Quit()
|
defer testApp.Quit()
|
||||||
|
|
||||||
@@ -80,9 +112,39 @@ func TestMainViewBuilds(t *testing.T) {
|
|||||||
defer svc.Stop()
|
defer svc.Stop()
|
||||||
|
|
||||||
content, recordStartup := newMainView(w, svc)
|
content, recordStartup := newMainView(w, svc)
|
||||||
if content == nil {
|
|
||||||
t.Fatal("newMainView returned nil content")
|
|
||||||
}
|
|
||||||
w.SetContent(content)
|
w.SetContent(content)
|
||||||
recordStartup(0, true)
|
|
||||||
|
table := historyTable(t, content)
|
||||||
|
if rows, _ := table.Length(); rows != 0 {
|
||||||
|
t.Fatalf("history rows before startup = %d, want 0", rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
recordStartup(1500*time.Millisecond, true)
|
||||||
|
recordStartup(20*time.Millisecond, false)
|
||||||
|
|
||||||
|
rows, _ := table.Length()
|
||||||
|
if rows != 2 {
|
||||||
|
t.Fatalf("history rows after two startup records = %d, want 2", rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read the rows back through the table's own cell callbacks, which is what
|
||||||
|
// the redraw does; a value only in the events slice would not prove the
|
||||||
|
// table was refreshed with it.
|
||||||
|
cell := table.CreateCell()
|
||||||
|
cellText := func(row, col int) string {
|
||||||
|
table.UpdateCell(widget.TableCellID{Row: row, Col: col}, cell)
|
||||||
|
return cell.(*widget.Label).Text
|
||||||
|
}
|
||||||
|
if got := cellText(0, 2); got != "Application" {
|
||||||
|
t.Errorf("startup row job = %q, want %q", got, "Application")
|
||||||
|
}
|
||||||
|
if got := cellText(0, 3); got != "Started" {
|
||||||
|
t.Errorf("startup row state = %q, want %q", got, "Started")
|
||||||
|
}
|
||||||
|
if got := cellText(0, 4); got != "Window shown in 1.5s" {
|
||||||
|
t.Errorf("windowed startup detail = %q, want %q", got, "Window shown in 1.5s")
|
||||||
|
}
|
||||||
|
if got := cellText(1, 4); got != "Started in tray in 20ms" {
|
||||||
|
t.Errorf("tray startup detail = %q, want %q", got, "Started in tray in 20ms")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-3
@@ -6,6 +6,7 @@ import (
|
|||||||
|
|
||||||
"gitea.mixdep.ru/mix/gosentry/assets"
|
"gitea.mixdep.ru/mix/gosentry/assets"
|
||||||
"gitea.mixdep.ru/mix/gosentry/src/app"
|
"gitea.mixdep.ru/mix/gosentry/src/app"
|
||||||
|
"gitea.mixdep.ru/mix/gosentry/src/storage"
|
||||||
|
|
||||||
"fyne.io/fyne/v2"
|
"fyne.io/fyne/v2"
|
||||||
fyneapp "fyne.io/fyne/v2/app"
|
fyneapp "fyne.io/fyne/v2/app"
|
||||||
@@ -29,7 +30,9 @@ const defaultWindowHeight = 660
|
|||||||
// mainwindow.go split keeps lifecycle separate from view construction.
|
// mainwindow.go split keeps lifecycle separate from view construction.
|
||||||
func Run(startInTray bool) {
|
func Run(startInTray bool) {
|
||||||
started := time.Now()
|
started := time.Now()
|
||||||
instanceListener, primary := acquireSingleInstance(!startInTray)
|
keepInTray := storage.PeekKeepRunningInTray()
|
||||||
|
startHidden := resolveStartHidden(startInTray, keepInTray)
|
||||||
|
instanceListener, primary := acquireSingleInstance(!startHidden)
|
||||||
if !primary {
|
if !primary {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -56,7 +59,6 @@ func Run(startInTray bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
w := a.NewWindow("GoSentry " + app.Version)
|
w := a.NewWindow("GoSentry " + app.Version)
|
||||||
configureSystemTray(a, w)
|
|
||||||
prefs := a.Preferences()
|
prefs := a.Preferences()
|
||||||
winW := float32(prefs.FloatWithFallback("window.width", defaultWindowWidth))
|
winW := float32(prefs.FloatWithFallback("window.width", defaultWindowWidth))
|
||||||
winH := float32(prefs.FloatWithFallback("window.height", defaultWindowHeight))
|
winH := float32(prefs.FloatWithFallback("window.height", defaultWindowHeight))
|
||||||
@@ -67,13 +69,16 @@ func Run(startInTray bool) {
|
|||||||
a.Run()
|
a.Run()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
keepInTray = svc.Store().Config.KeepRunningInTray
|
||||||
|
startHidden = resolveStartHidden(startInTray, keepInTray)
|
||||||
|
applyTrayBehavior(a, w, keepInTray, false)
|
||||||
// Apply the persisted theme before building content so the window renders in
|
// Apply the persisted theme before building content so the window renders in
|
||||||
// the chosen theme from the first frame rather than flashing the default one.
|
// the chosen theme from the first frame rather than flashing the default one.
|
||||||
applyTheme(a, svc.Store().Config.Theme)
|
applyTheme(a, svc.Store().Config.Theme)
|
||||||
content, recordStartup := newMainView(w, svc)
|
content, recordStartup := newMainView(w, svc)
|
||||||
w.SetContent(content)
|
w.SetContent(content)
|
||||||
serveSingleInstance(instanceListener, w)
|
serveSingleInstance(instanceListener, w)
|
||||||
if startInTray {
|
if startHidden {
|
||||||
// Autostart launches intentionally stay hidden, so "window shown" would be
|
// Autostart launches intentionally stay hidden, so "window shown" would be
|
||||||
// a misleading metric. Record a separate startup event for the tray path
|
// a misleading metric. Record a separate startup event for the tray path
|
||||||
// instead of forcing one timing definition onto two different UX flows.
|
// instead of forcing one timing definition onto two different UX flows.
|
||||||
|
|||||||
+44
-17
@@ -12,7 +12,7 @@ import (
|
|||||||
"fyne.io/fyne/v2/widget"
|
"fyne.io/fyne/v2/widget"
|
||||||
)
|
)
|
||||||
|
|
||||||
const projectRepositoryURL = "https://gitea.mixdep.ru/mix/gosentry"
|
const projectRepositoryURL = "https://github.com/mixeme/gosentry"
|
||||||
|
|
||||||
// settingsCaptions lists every settingsRow caption in the tab, in no
|
// settingsCaptions lists every settingsRow caption in the tab, in no
|
||||||
// particular order. settingsView measures this once with captionColumnWidth
|
// particular order. settingsView measures this once with captionColumnWidth
|
||||||
@@ -37,8 +37,16 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
|
|||||||
var loadFields func(domain.Config)
|
var loadFields func(domain.Config)
|
||||||
startOnLogin := widget.NewCheck("Start on login", nil)
|
startOnLogin := widget.NewCheck("Start on login", nil)
|
||||||
startOnLogin.SetChecked(store.Config.StartOnLogin)
|
startOnLogin.SetChecked(store.Config.StartOnLogin)
|
||||||
|
minimizeToTray := widget.NewCheck("Keep running in the system tray", nil)
|
||||||
|
minimizeToTray.SetChecked(store.Config.KeepRunningInTray)
|
||||||
autostartStatus := widget.NewLabel("")
|
autostartStatus := widget.NewLabel("")
|
||||||
|
trayRestartHint := widget.NewLabel("")
|
||||||
|
trayRestartHint.Truncation = fyne.TextTruncateClip
|
||||||
refreshAutostartStatus := func() {
|
refreshAutostartStatus := func() {
|
||||||
|
if settingsPendingAutostart(startOnLogin, minimizeToTray, store.Config) {
|
||||||
|
autostartStatus.SetText("Pending: save settings to apply")
|
||||||
|
return
|
||||||
|
}
|
||||||
ok, message := svc.AutostartStatus()
|
ok, message := svc.AutostartStatus()
|
||||||
if ok {
|
if ok {
|
||||||
autostartStatus.SetText("OK: " + message)
|
autostartStatus.SetText("OK: " + message)
|
||||||
@@ -46,22 +54,27 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
|
|||||||
}
|
}
|
||||||
autostartStatus.SetText("Problem: " + message)
|
autostartStatus.SetText("Problem: " + message)
|
||||||
}
|
}
|
||||||
startOnLogin.OnChanged = func(bool) {
|
refreshTrayRestartHint := func(pending bool) {
|
||||||
if startOnLogin.Checked != store.Config.StartOnLogin {
|
if pending {
|
||||||
autostartStatus.SetText("Pending: save settings to apply")
|
trayRestartHint.SetText("Pending: restart GoSentry after save for the tray icon change to take effect.")
|
||||||
} else {
|
return
|
||||||
refreshAutostartStatus()
|
|
||||||
}
|
}
|
||||||
|
trayRestartHint.SetText("")
|
||||||
|
}
|
||||||
|
startOnLogin.OnChanged = func(bool) {
|
||||||
|
refreshAutostartStatus()
|
||||||
|
updateSaveState()
|
||||||
|
}
|
||||||
|
minimizeToTray.OnChanged = func(bool) {
|
||||||
|
refreshAutostartStatus()
|
||||||
|
refreshTrayRestartHint(minimizeToTray.Checked != store.Config.KeepRunningInTray)
|
||||||
updateSaveState()
|
updateSaveState()
|
||||||
}
|
}
|
||||||
refreshAutostartStatus()
|
refreshAutostartStatus()
|
||||||
minimizeToTray := widget.NewCheck("Keep running in the system tray", nil)
|
|
||||||
minimizeToTray.SetChecked(store.Config.KeepRunningInTray)
|
|
||||||
minimizeToTray.OnChanged = func(bool) { updateSaveState() }
|
|
||||||
notifications := widget.NewCheck("Show desktop notifications for failed jobs", nil)
|
notifications := widget.NewCheck("Show desktop notifications for failed jobs", nil)
|
||||||
notifications.SetChecked(store.Config.NotifyOnFailure)
|
notifications.SetChecked(store.Config.NotifyOnFailure)
|
||||||
notifications.OnChanged = func(bool) { updateSaveState() }
|
notifications.OnChanged = func(bool) { updateSaveState() }
|
||||||
themeSelect := widget.NewSelect([]string{themeLabelDefault, themeLabelGoSentry}, nil)
|
themeSelect := widget.NewSelect([]string{themeLabelSystem, themeLabelGoSentry}, nil)
|
||||||
themeSelect.SetSelected(themeLabel(store.Config.Theme))
|
themeSelect.SetSelected(themeLabel(store.Config.Theme))
|
||||||
// Preview the theme the moment it is picked so the choice is visible before
|
// Preview the theme the moment it is picked so the choice is visible before
|
||||||
// saving; Save persists it. Reverting the selection reverts the preview, and
|
// saving; Save persists it. Reverting the selection reverts the preview, and
|
||||||
@@ -158,6 +171,7 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
|
|||||||
config.OverlapPolicy = domain.OverlapPolicy(overlapPolicySelect.Selected)
|
config.OverlapPolicy = domain.OverlapPolicy(overlapPolicySelect.Selected)
|
||||||
config.DefaultTimeoutSeconds = timeout
|
config.DefaultTimeoutSeconds = timeout
|
||||||
config.Theme = themeFromLabel(themeSelect.Selected)
|
config.Theme = themeFromLabel(themeSelect.Selected)
|
||||||
|
previousKeepInTray := store.Config.KeepRunningInTray
|
||||||
if err := svc.UpdateSettings(config); err != nil {
|
if err := svc.UpdateSettings(config); err != nil {
|
||||||
settingsStatus.SetText("Save failed: " + err.Error())
|
settingsStatus.SetText("Save failed: " + err.Error())
|
||||||
return
|
return
|
||||||
@@ -168,6 +182,12 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
refreshAutostartStatus()
|
refreshAutostartStatus()
|
||||||
|
applyTrayBehavior(fyne.CurrentApp(), w, config.KeepRunningInTray, true)
|
||||||
|
if previousKeepInTray != config.KeepRunningInTray {
|
||||||
|
trayRestartHint.SetText(trayRestartHintText)
|
||||||
|
} else {
|
||||||
|
refreshTrayRestartHint(false)
|
||||||
|
}
|
||||||
settingsStatus.SetText("Saved")
|
settingsStatus.SetText("Saved")
|
||||||
// The form now matches the persisted config, so disable Save again.
|
// The form now matches the persisted config, so disable Save again.
|
||||||
updateSaveState()
|
updateSaveState()
|
||||||
@@ -215,11 +235,12 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
|
|||||||
logsDir.SetText(c.LogsDir)
|
logsDir.SetText(c.LogsDir)
|
||||||
maxLogFiles.SetText(strconv.Itoa(c.MaxLogFiles))
|
maxLogFiles.SetText(strconv.Itoa(c.MaxLogFiles))
|
||||||
maxLogAgeDays.SetText(strconv.Itoa(c.MaxLogAgeDays))
|
maxLogAgeDays.SetText(strconv.Itoa(c.MaxLogAgeDays))
|
||||||
if startOnLogin.Checked != store.Config.StartOnLogin {
|
if settingsPendingAutostart(startOnLogin, minimizeToTray, store.Config) {
|
||||||
autostartStatus.SetText("Pending: save settings to apply")
|
autostartStatus.SetText("Pending: save settings to apply")
|
||||||
} else {
|
} else {
|
||||||
refreshAutostartStatus()
|
refreshAutostartStatus()
|
||||||
}
|
}
|
||||||
|
refreshTrayRestartHint(minimizeToTray.Checked != store.Config.KeepRunningInTray)
|
||||||
settingsStatus.SetText("")
|
settingsStatus.SetText("")
|
||||||
updateSaveState()
|
updateSaveState()
|
||||||
}
|
}
|
||||||
@@ -234,6 +255,7 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
|
|||||||
startOnLogin: startOnLogin,
|
startOnLogin: startOnLogin,
|
||||||
autostartStatus: autostartStatus,
|
autostartStatus: autostartStatus,
|
||||||
minimizeToTray: minimizeToTray,
|
minimizeToTray: minimizeToTray,
|
||||||
|
trayRestartHint: trayRestartHint,
|
||||||
notifications: notifications,
|
notifications: notifications,
|
||||||
themeSelect: themeSelect,
|
themeSelect: themeSelect,
|
||||||
executionModeSelect: executionModeSelect,
|
executionModeSelect: executionModeSelect,
|
||||||
@@ -254,24 +276,29 @@ func settingsView(w fyne.Window, svc *app.Service) fyne.CanvasObject {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func settingsPendingAutostart(startOnLogin, minimizeToTray *widget.Check, saved domain.Config) bool {
|
||||||
|
return startOnLogin.Checked != saved.StartOnLogin ||
|
||||||
|
minimizeToTray.Checked != saved.KeepRunningInTray
|
||||||
|
}
|
||||||
|
|
||||||
// Theme dropdown labels. These are the human-facing captions; themeLabel and
|
// Theme dropdown labels. These are the human-facing captions; themeLabel and
|
||||||
// themeFromLabel translate between them and the stored domain.Theme values so the
|
// themeFromLabel translate between them and the stored domain.Theme values so the
|
||||||
// select never leaks the on-disk "default"/"gosentry" strings to the user.
|
// select never leaks the on-disk "system"/"gosentry" strings to the user.
|
||||||
const (
|
const (
|
||||||
themeLabelDefault = "Default"
|
themeLabelSystem = "System"
|
||||||
themeLabelGoSentry = "GoSentry"
|
themeLabelGoSentry = "GoSentry"
|
||||||
)
|
)
|
||||||
|
|
||||||
func themeLabel(choice domain.Theme) string {
|
func themeLabel(choice domain.Theme) string {
|
||||||
if choice == domain.ThemeGoSentry {
|
if choice == domain.ThemeSystem {
|
||||||
return themeLabelGoSentry
|
return themeLabelSystem
|
||||||
}
|
}
|
||||||
return themeLabelDefault
|
return themeLabelGoSentry
|
||||||
}
|
}
|
||||||
|
|
||||||
func themeFromLabel(label string) domain.Theme {
|
func themeFromLabel(label string) domain.Theme {
|
||||||
if label == themeLabelGoSentry {
|
if label == themeLabelGoSentry {
|
||||||
return domain.ThemeGoSentry
|
return domain.ThemeGoSentry
|
||||||
}
|
}
|
||||||
return domain.ThemeDefault
|
return domain.ThemeSystem
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ type settingsFormFields struct {
|
|||||||
startOnLogin *widget.Check
|
startOnLogin *widget.Check
|
||||||
autostartStatus *widget.Label
|
autostartStatus *widget.Label
|
||||||
minimizeToTray *widget.Check
|
minimizeToTray *widget.Check
|
||||||
|
trayRestartHint *widget.Label
|
||||||
notifications *widget.Check
|
notifications *widget.Check
|
||||||
themeSelect *widget.Select
|
themeSelect *widget.Select
|
||||||
executionModeSelect *widget.Select
|
executionModeSelect *widget.Select
|
||||||
@@ -59,6 +60,7 @@ func newSettingsLayout(f settingsFormFields) fyne.CanvasObject {
|
|||||||
// empty caption, so the Application section fits in a half-width column.
|
// empty caption, so the Application section fits in a half-width column.
|
||||||
settingsRow(capW, "", f.autostartStatus),
|
settingsRow(capW, "", f.autostartStatus),
|
||||||
settingsRow(capW, "Tray", f.minimizeToTray),
|
settingsRow(capW, "Tray", f.minimizeToTray),
|
||||||
|
settingsRow(capW, "", f.trayRestartHint),
|
||||||
settingsRow(capW, "Notifications", f.notifications),
|
settingsRow(capW, "Notifications", f.notifications),
|
||||||
// Theme is the one row here whose value is not text: the Select paints
|
// Theme is the one row here whose value is not text: the Select paints
|
||||||
// a box out to the row's edge, so the section's overlap would leave it
|
// a box out to the row's edge, so the section's overlap would leave it
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ func serveSingleInstance(listener net.Listener, w fyne.Window) {
|
|||||||
// Accept runs on its own goroutine, so focusing the window must be
|
// Accept runs on its own goroutine, so focusing the window must be
|
||||||
// marshaled onto the main thread like every other widget update.
|
// marshaled onto the main thread like every other widget update.
|
||||||
fyne.Do(func() {
|
fyne.Do(func() {
|
||||||
|
mainWindowHidden = false
|
||||||
w.Show()
|
w.Show()
|
||||||
w.RequestFocus()
|
w.RequestFocus()
|
||||||
})
|
})
|
||||||
|
|||||||
+6
-6
@@ -103,15 +103,15 @@ func (t gosentryTheme) Font(style fyne.TextStyle) fyne.Resource { return t.base.
|
|||||||
func (t gosentryTheme) Icon(name fyne.ThemeIconName) fyne.Resource { return t.base.Icon(name) }
|
func (t gosentryTheme) Icon(name fyne.ThemeIconName) fyne.Resource { return t.base.Icon(name) }
|
||||||
func (t gosentryTheme) Size(name fyne.ThemeSizeName) float32 { return t.base.Size(name) }
|
func (t gosentryTheme) Size(name fyne.ThemeSizeName) float32 { return t.base.Size(name) }
|
||||||
|
|
||||||
// themeFor maps a stored Theme choice to a concrete fyne.Theme. Anything other
|
// themeFor maps a stored Theme choice to a concrete fyne.Theme. Only the
|
||||||
// than the explicit GoSentry choice (including the empty/legacy value) keeps
|
// explicit system choice keeps Fyne's built-in theme; everything else
|
||||||
// Fyne's built-in theme.
|
// (including the empty/legacy value) uses the branded GoSentry theme.
|
||||||
func themeFor(choice domain.Theme) fyne.Theme {
|
func themeFor(choice domain.Theme) fyne.Theme {
|
||||||
if choice == domain.ThemeGoSentry {
|
if choice == domain.ThemeSystem {
|
||||||
return newGoSentryTheme()
|
|
||||||
}
|
|
||||||
return theme.DefaultTheme()
|
return theme.DefaultTheme()
|
||||||
}
|
}
|
||||||
|
return newGoSentryTheme()
|
||||||
|
}
|
||||||
|
|
||||||
// applyTheme installs the theme for the given choice on the running app. Fyne
|
// applyTheme installs the theme for the given choice on the running app. Fyne
|
||||||
// refreshes every canvas when the theme changes, so this works both at startup
|
// refreshes every canvas when the theme changes, so this works both at startup
|
||||||
|
|||||||
+15
-15
@@ -54,32 +54,32 @@ func TestGoSentryThemeDelegatesUnbrandedColors(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// themeFor maps the stored choice to the right theme: the GoSentry choice yields
|
// themeFor maps the stored choice to the right theme: the GoSentry choice and the
|
||||||
// the branded teal primary; every other value (including the empty legacy value)
|
// empty legacy value yield the branded teal primary; only the explicit system
|
||||||
// yields the default theme, whose primary is not the brand teal.
|
// choice yields Fyne's built-in theme.
|
||||||
func TestThemeForChoice(t *testing.T) {
|
func TestThemeForChoice(t *testing.T) {
|
||||||
gosentry := themeFor(domain.ThemeGoSentry)
|
for _, choice := range []domain.Theme{domain.ThemeGoSentry, ""} {
|
||||||
if got := gosentry.Color(theme.ColorNamePrimary, theme.VariantLight); got != brandTeal {
|
branded := themeFor(choice)
|
||||||
t.Errorf("themeFor(gosentry) primary = %v, want brand teal %v", got, brandTeal)
|
if got := branded.Color(theme.ColorNamePrimary, theme.VariantLight); got != brandTeal {
|
||||||
|
t.Errorf("themeFor(%q) primary = %v, want brand teal %v", choice, got, brandTeal)
|
||||||
}
|
}
|
||||||
for _, choice := range []domain.Theme{domain.ThemeDefault, ""} {
|
|
||||||
def := themeFor(choice)
|
|
||||||
if got := def.Color(theme.ColorNamePrimary, theme.VariantLight); got == brandTeal {
|
|
||||||
t.Errorf("themeFor(%q) should not use the brand teal primary", choice)
|
|
||||||
}
|
}
|
||||||
|
sys := themeFor(domain.ThemeSystem)
|
||||||
|
if got := sys.Color(theme.ColorNamePrimary, theme.VariantLight); got == brandTeal {
|
||||||
|
t.Errorf("themeFor(system) should not use the brand teal primary")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The dropdown label helpers must round-trip, and the empty/legacy value must map
|
// The dropdown label helpers must round-trip, and the empty/legacy value must map
|
||||||
// to the Default label so the select never shows a blank option.
|
// to the GoSentry label so the select never shows a blank option.
|
||||||
func TestThemeLabelRoundTrip(t *testing.T) {
|
func TestThemeLabelRoundTrip(t *testing.T) {
|
||||||
if got := themeFromLabel(themeLabel(domain.ThemeGoSentry)); got != domain.ThemeGoSentry {
|
if got := themeFromLabel(themeLabel(domain.ThemeGoSentry)); got != domain.ThemeGoSentry {
|
||||||
t.Errorf("round-trip gosentry = %q", got)
|
t.Errorf("round-trip gosentry = %q", got)
|
||||||
}
|
}
|
||||||
if got := themeFromLabel(themeLabel(domain.ThemeDefault)); got != domain.ThemeDefault {
|
if got := themeFromLabel(themeLabel(domain.ThemeSystem)); got != domain.ThemeSystem {
|
||||||
t.Errorf("round-trip default = %q", got)
|
t.Errorf("round-trip system = %q", got)
|
||||||
}
|
}
|
||||||
if got := themeLabel(""); got != themeLabelDefault {
|
if got := themeLabel(""); got != themeLabelGoSentry {
|
||||||
t.Errorf("empty theme label = %q, want %q", got, themeLabelDefault)
|
t.Errorf("empty theme label = %q, want %q", got, themeLabelGoSentry)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+45
-16
@@ -4,12 +4,46 @@ import (
|
|||||||
"runtime"
|
"runtime"
|
||||||
|
|
||||||
"gitea.mixdep.ru/mix/gosentry/assets"
|
"gitea.mixdep.ru/mix/gosentry/assets"
|
||||||
|
"gitea.mixdep.ru/mix/gosentry/src/domain"
|
||||||
|
|
||||||
"fyne.io/fyne/v2"
|
"fyne.io/fyne/v2"
|
||||||
fynedesktop "fyne.io/fyne/v2/driver/desktop"
|
fynedesktop "fyne.io/fyne/v2/driver/desktop"
|
||||||
)
|
)
|
||||||
|
|
||||||
func configureSystemTray(a fyne.App, w fyne.Window) {
|
// systemTrayRegistered tracks whether this process registered a tray icon at
|
||||||
|
// launch. Fyne cannot add or remove the icon mid-session, so toggling
|
||||||
|
// KeepRunningInTray in Settings updates close behavior immediately and shows a
|
||||||
|
// restart hint for the icon itself.
|
||||||
|
var systemTrayRegistered bool
|
||||||
|
|
||||||
|
// mainWindowHidden tracks whether the primary window was hidden via the tray
|
||||||
|
// close intercept. Fyne exposes no Window.Visible API, so the flag drives the
|
||||||
|
// reveal-on-tray-disable path in applyTrayBehavior.
|
||||||
|
var mainWindowHidden bool
|
||||||
|
|
||||||
|
const trayRestartHintText = "Restart GoSentry for the tray icon change to take effect."
|
||||||
|
|
||||||
|
func resolveStartHidden(cliStartInTray, keepInTray bool) bool {
|
||||||
|
return domain.ResolveStartHidden(cliStartInTray, keepInTray)
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyTrayBehavior configures window close handling for KeepRunningInTray.
|
||||||
|
// When revealIfHidden is true and the tray is off, a hidden window is shown so
|
||||||
|
// the user can still reach the app after disabling the tray mid-session.
|
||||||
|
func applyTrayBehavior(a fyne.App, w fyne.Window, keepInTray bool, revealIfHidden bool) {
|
||||||
|
if keepInTray && !systemTrayRegistered {
|
||||||
|
registerSystemTray(a, w)
|
||||||
|
systemTrayRegistered = true
|
||||||
|
}
|
||||||
|
setWindowCloseBehavior(w, keepInTray)
|
||||||
|
if !keepInTray && revealIfHidden && mainWindowHidden {
|
||||||
|
mainWindowHidden = false
|
||||||
|
w.Show()
|
||||||
|
w.RequestFocus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func registerSystemTray(a fyne.App, w fyne.Window) {
|
||||||
desk, ok := a.(fynedesktop.App)
|
desk, ok := a.(fynedesktop.App)
|
||||||
if !ok {
|
if !ok {
|
||||||
// Not every Fyne driver exposes desktop tray features. Returning silently
|
// Not every Fyne driver exposes desktop tray features. Returning silently
|
||||||
@@ -34,26 +68,13 @@ func configureSystemTray(a fyne.App, w fyne.Window) {
|
|||||||
// localized label — which our literal "Quit" does not. Setting IsQuit makes
|
// localized label — which our literal "Quit" does not. Setting IsQuit makes
|
||||||
// Fyne reuse this item instead of adding a duplicate, regardless of locale.
|
// Fyne reuse this item instead of adding a duplicate, regardless of locale.
|
||||||
|
|
||||||
// Window size persistence is frozen: w.Canvas().Size() returns the maximized
|
|
||||||
// dimensions when the window is maximized, so saving here would corrupt the
|
|
||||||
// stored size. Needs cross-platform maximized-state detection (IsZoomed /
|
|
||||||
// _NET_WM_STATE / NSWindow.isZoomed) before it can be re-enabled safely.
|
|
||||||
// See ROADMAP.md — "Window size — skip saving when maximized".
|
|
||||||
//
|
|
||||||
// saveWindowSize := func() {
|
|
||||||
// size := w.Canvas().Size()
|
|
||||||
// prefs := a.Preferences()
|
|
||||||
// prefs.SetFloat("window.width", float64(size.Width))
|
|
||||||
// prefs.SetFloat("window.height", float64(size.Height))
|
|
||||||
// }
|
|
||||||
|
|
||||||
quit := fyne.NewMenuItem("Quit", func() {
|
quit := fyne.NewMenuItem("Quit", func() {
|
||||||
// saveWindowSize()
|
|
||||||
a.Quit()
|
a.Quit()
|
||||||
})
|
})
|
||||||
quit.IsQuit = true
|
quit.IsQuit = true
|
||||||
menu := fyne.NewMenu("GoSentry",
|
menu := fyne.NewMenu("GoSentry",
|
||||||
fyne.NewMenuItem("Show", func() {
|
fyne.NewMenuItem("Show", func() {
|
||||||
|
mainWindowHidden = false
|
||||||
w.Show()
|
w.Show()
|
||||||
w.RequestFocus()
|
w.RequestFocus()
|
||||||
}),
|
}),
|
||||||
@@ -62,11 +83,19 @@ func configureSystemTray(a fyne.App, w fyne.Window) {
|
|||||||
)
|
)
|
||||||
desk.SetSystemTrayMenu(menu)
|
desk.SetSystemTrayMenu(menu)
|
||||||
desk.SetSystemTrayWindow(w)
|
desk.SetSystemTrayWindow(w)
|
||||||
|
}
|
||||||
|
|
||||||
|
func setWindowCloseBehavior(w fyne.Window, keepInTray bool) {
|
||||||
|
if keepInTray {
|
||||||
w.SetCloseIntercept(func() {
|
w.SetCloseIntercept(func() {
|
||||||
// Closing hides the window instead of quitting because scheduler tools are
|
// Closing hides the window instead of quitting because scheduler tools are
|
||||||
// expected to keep working in the background. The explicit Quit tray item
|
// expected to keep working in the background. The explicit Quit tray item
|
||||||
// remains the way to stop the process.
|
// remains the way to stop the process.
|
||||||
// saveWindowSize()
|
mainWindowHidden = true
|
||||||
w.Hide()
|
w.Hide()
|
||||||
})
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mainWindowHidden = false
|
||||||
|
w.SetCloseIntercept(nil)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestResolveStartHiddenUsesDomainHelper(t *testing.T) {
|
||||||
|
// resolveStartHidden is the UI alias used at startup; it must stay aligned
|
||||||
|
// with domain.ResolveStartHidden so run.go and tests share one definition.
|
||||||
|
if got := resolveStartHidden(true, false); got {
|
||||||
|
t.Fatal("expected hidden start to require both CLI flag and keepInTray")
|
||||||
|
}
|
||||||
|
if !resolveStartHidden(true, true) {
|
||||||
|
t.Fatal("expected hidden start when both flags are set")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user