Compare commits

39 Commits

Author SHA1 Message Date
mixeme 462752f995 Mark T0.2 as complete 2026-06-18 20:25:39 +03:00
mixeme ef6902d65c T0.2: Add characterization tests at refactoring seams
Pin current behavior at the three seams that will move during refactoring:

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 08:16:03 +03:00
mixeme d24211cab2 Fix autostart status for paths with non-ASCII chars and spaces
readShortcut read the shortcut TargetPath via [Console]::Out.Write, which
uses the system OEM code page by default. On Russian Windows (CP866) this
encoded Cyrillic characters differently from UTF-8, so Go's string(output)
produced a garbled path that never matched os.Executable, causing
AutostartStatus to always report "shortcut points to another executable"
for any install directory that contained non-ASCII characters.

Fix: prepend [Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false)
to the readShortcut PowerShell script so the output is always UTF-8.

Also harden sameWindowsPath against NTFS 8.3 short names: when a directory
name contains spaces Windows assigns a short name (e.g. LOCALG~1 for
"Local Git"), and the OS may use that form when launching from a
Startup-folder shortcut. Add an os.SameFile fallback that compares paths
by volume serial number and file index, which is immune to 8.3 vs long
name differences as well as directory junction points.

Add normalizeWindowsPath helper that strips quotes and the \?\ extended-
length prefix before filepath.Clean so those variants compare equal to
the plain path.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 07:38:18 +03:00
mixeme 0c2c9f1f67 Add comprehensive test suite documentation
Created docs/TESTS.md documenting all 25 tests across 5 test files:
- store_test.go: YAML serialization tests
- scheduler_test.go: Schedule parsing and invocation output tests
- runner_test.go: Command execution, exit codes, and Windows process tests
- autostart_windows_test.go: Windows startup folder shortcut creation tests
- autostart_linux_test.go: Linux XDG Desktop Entry autostart tests

Includes test descriptions, platform requirements, and usage instructions.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-06-18 00:21:33 +03:00
mixeme 89704b0470 Recommend MinGW Mesa variant in VirtualBox/RDP workaround
Update the OpenGL workaround to suggest the mingw release of Mesa instead of
msvc, as it matches the MSYS2 GCC toolchain used to build GoSentry. Both
variants work at runtime, but mingw is the more consistent choice.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-06-18 00:17:20 +03:00
mixeme 975829ed70 Improve command execution modes 2026-06-18 00:13:12 +03:00
mixeme eb6a1907e6 Document tricky design decisions
Add explanatory comments around startup timing, single-instance focus handoff, config migration, and Windows/Linux autostart choices.

The new comments capture why these implementations were chosen, what alternatives were intentionally avoided, and which user-facing problems those tradeoffs solve.
2026-06-17 23:01:05 +03:00
mixeme 2f7bbe4fca Fix startup timing measurement
Bump the version to 0.3.1 and record startup timing after the main window is actually shown.

Keep autostart launches distinct in History by recording a separate tray-start message when the UI intentionally starts hidden.
2026-06-17 22:57:23 +03:00
mixeme 94033e794f Rename project to GoSentry
Rename the application, Go module path, command package, build artifacts, resource script, and embedded icon assets from PySentry/pysentry to GoSentry/gosentry.

Move portable settings to gosentry.yaml while reading legacy pysentry.yaml during the transition, then rewrite settings under the new name.

Update Windows and Linux autostart integration to use GoSentry names while cleaning up legacy PySentry registry, desktop-entry, and systemd artifacts.

Refresh README, architecture notes, roadmap, changelog, and release examples for version 0.3.0.
2026-06-17 07:29:58 +03:00
mixeme d828e34121 Specify release archive 2026-06-16 22:27:04 +03:00
mixeme 872cc82c5c Release version 0.2.5
Bump the application version to 0.2.5 and update documented artifact names.

Document the Windows VirtualBox/RDP OpenGL startup failure and the Mesa software OpenGL workaround.

Record the tray-icon double-click limitation in the roadmap for future Fyne or platform-specific tray work.
2026-06-16 22:08:20 +03:00
mixeme b1fe8bd675 Start autostart launches in tray
Add a shared --start-in-tray argument that lets autostart start the scheduler and tray integration without opening the main window.

Write the argument into Windows Startup shortcuts and Linux XDG Autostart desktop entries, and verify existing autostart entries include it.

Keep manual launches unchanged and let a manual second launch reveal an already-running instance while duplicate autostart launches stay hidden.
2026-06-16 21:52:00 +03:00
mixeme 44f24ab3d8 Use Startup shortcut for Windows autostart
Replace the HKCU Run autostart entry with a per-user Startup folder shortcut. A .lnk stores TargetPath separately, which avoids fragile quoting when the executable path contains spaces.

Remove legacy PySentry and GoSentry Run entries when saving autostart settings, and report shortcut status from the actual shortcut target.

Add Windows tests that create and read a temporary shortcut with spaces in the path so the PowerShell/COM invocation remains covered.
2026-06-16 21:40:48 +03:00
mixeme 0bc9e91d1e Stabilize Jobs details panel width
Wrap dynamic job detail labels so long names, schedules, commands, and status values no longer change the right panel minimum width when the selected job changes.

Point embedded and Windows resource icons at the current asset filenames so tests and Windows builds continue to work after the asset cleanup.
2026-06-16 21:24:03 +03:00
mixeme 079961e735 Delete old icons 2026-06-16 20:32:11 +03:00
mixeme cc294ce718 Rename 2026-06-16 20:31:09 +03:00
mixeme 5f85af27e9 Rename 2026-06-16 20:30:58 +03:00
mixeme d4b1238c5f Delete old icons 2026-06-16 20:30:47 +03:00
mixeme d06f130c5c Create pysentry-icon-16_mix.png 2026-06-16 08:41:58 +03:00
mixeme fd3e8baa0e Icons 2026-06-16 08:33:49 +03:00
mixeme d202f8a94c Replace archived YAML dependency
Switch direct YAML usage from gopkg.in/yaml.v3 to go.yaml.in/yaml/v4, the maintained YAML org fork of the archived go-yaml repository.

Update README dependency and mirroring links so the documented source repository matches the module used by the application.
2026-06-16 08:12:14 +03:00
mixeme c1bd8c952c Release version 0.2.4
Prevent repeated application launches by using a local single-instance control channel. A second process forwards a show command to the already running instance and exits.

Bump the application version to 0.2.4 and update README artifact examples plus docs/CHANGELOG.md.
2026-06-16 08:01:42 +03:00
mixeme e8e0060063 Align Settings sections into compact rows
Add a Settings-specific row layout with a narrower label column so Storage and About no longer waste half the width on captions.

Format the Application section with the same row layout and reserve a third column for the autostart status. The checkbox column is sized to fit the longest Application checkbox so the section reads like a table.
2026-06-16 07:57:32 +03:00
mixeme 7252d3683c Clean up Settings application block
Remove the duplicated application version row from the Settings Application block now that version details live in About.

Keep the autostart control inline and shorten its checkbox text to Start on login.
2026-06-16 07:45:17 +03:00
mixeme 088f6e77b0 Add versioned Docker builder tags and Settings about block
Tag Docker builder images with the current application version in both Linux Docker build scripts so different release environments do not overwrite each other with one floating builder tag.

Replace the Settings Scheduler note with an About block that shows the GoSentry version, Go runtime version, Fyne module version, and the project repository link.
2026-06-16 07:36:00 +03:00
mixeme 4a8feb351e Release version 0.2.3
Improve the History tab by keeping records in chronological order, rendering them as a compact table, and allowing the Time column to toggle ascending or descending order.

Use the native Fyne table header so users can resize columns, including Detail and Log, and show only the log file name instead of the full log path.

Bump the application version to 0.2.3 and update README artifact examples plus docs/CHANGELOG.md.
2026-06-15 21:16:57 +03:00
mixeme e016da5277 Make History chronological and compact
Append new History events at the end instead of prepending them so the tab reads from oldest to newest.

Replace the loose widget.List rendering with a table that exposes Time, Trigger, Job, State, Detail, and Log columns. This removes the large visual gaps between rows and makes the activity log easier to scan.

Use full timestamps for UI-generated events so startup, UI actions, manual runs, and scheduled runs share the same time format.
2026-06-15 21:07:06 +03:00
mixeme c644636e57 Release version 0.2.2
Add Linux desktop integration that installs a user-level .desktop file and icon under XDG data directories so taskbars can match the PySentry window to the application icon.

Pass the installed icon path into Linux autostart desktop entries when available, while keeping the Windows and fallback autostart APIs compatible.

Bump the application version to 0.2.2, update README artifact examples, and record the release notes in docs/CHANGELOG.md. Also adjust the Mermaid architecture diagram so Gitea can render it without invalid SVG line-break tags.
2026-06-15 20:57:16 +03:00
mixeme e2464aab0f Move project documentation into docs
Add ARCHITECTURE.md with a Mermaid component interaction diagram and short descriptions of the main runtime flows.

Move CHANGELOG.md and ROADMAP.md under docs/ so project documentation lives in one place, and update README links plus the project layout description.
2026-06-15 20:44:47 +03:00
mixeme 5ef32566db Show startup timing in History
Measure GUI startup from the beginning of Run until the main view has loaded its store and is ready to build the History model.

Insert the measurement as the first in-memory History event using the existing RunRecord display path: Application / Started / Startup completed in <duration>. This keeps startup diagnostics visible in the UI without creating a separate application log file.

No scheduler, storage, or command log behavior is changed.
2026-06-15 20:40:53 +03:00
mixeme 2932783143 Stabilize Jobs pane layout
Replace the resizable HSplit on the Jobs tab with a border layout that keeps the job list pane at a stable minimum width.

The left pane now uses a small custom layout that asks Fyne for the content minimum size and applies at least 480 logical pixels. This prevents the divider from moving when the selected job changes while still allowing the toolbar buttons, including Delete, to fit when their calculated minimum width is larger.

Only the Jobs tab layout is changed; settings, storage, scheduler behavior, and documentation are intentionally left untouched.
2026-06-15 20:36:53 +03:00
mixeme 91158bf5b8 Update ROADMAP.md 2026-06-15 09:09:19 +03:00
mixeme ab75226cdb Update README.md 2026-06-15 09:05:39 +03:00
mixeme 9214958fd0 Bump version to 0.2.1 2026-06-15 09:04:11 +03:00
mixeme ddabfd2da2 Bump version and add changelog 2026-06-15 08:23:24 +03:00
46 changed files with 2870 additions and 456 deletions
+1
View File
@@ -2,6 +2,7 @@
bin bin
dist dist
logs logs
gosentry.yaml
pysentry.yaml pysentry.yaml
jobs.yaml jobs.yaml
*.exe *.exe
+16 -178
View File
@@ -1,185 +1,23 @@
# ---> Python # Build outputs
# ---> Go
bin/
dist/ dist/
cmd/pysentry/*.syso
# Generated Windows resource compiled from packaging/windows/gosentry.rc.
cmd/gosentry/*.syso
# Local binaries that may be produced by ad-hoc go build commands.
*.exe *.exe
*.test
# Runtime files created next to the executable during local runs.
gosentry.yaml
pysentry.yaml pysentry.yaml
jobs.yaml jobs.yaml
logs/ logs/
# Byte-compiled / optimized / DLL files # Go workspace/cache files that should stay local if a developer creates them.
__pycache__/ go.work
*.py[cod] go.work.sum
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# UV
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
#uv.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
.pdm.toml
.pdm-python
.pdm-build/
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
# Ruff stuff:
.ruff_cache/
# PyPI configuration file
.pypirc
# GoodSync metadata. This is intentionally kept because the directory is local
# to the user's file synchronization setup.
_gsdata_/
+107 -44
View File
@@ -1,8 +1,15 @@
# PySentry # GoSentry
PySentry is a cross-platform desktop scheduler inspired by cron. It provides a native GUI for creating, grouping, pausing, running, and monitoring scheduled shell commands. GoSentry is a cross-platform desktop scheduler inspired by cron. It provides a native GUI for creating, grouping, pausing, running, and monitoring scheduled shell commands.
PySentry is being designed and implemented with assistance from OpenAI Codex. GoSentry is being designed and implemented with assistance from OpenAI Codex.
Project notes:
- [Changelog](docs/CHANGELOG.md)
- [Roadmap](docs/ROADMAP.md)
- [Architecture](docs/ARCHITECTURE.md)
- [Refactoring plan](docs/REFACTORING.md)
## Features ## Features
@@ -66,7 +73,7 @@ sudo apt install golang gcc libgl1-mesa-dev xorg-dev
Windows: Windows:
```powershell ```powershell
# Builds dist\windows\pysentry-<version>-windows-amd64.exe. The script changes # Builds dist\windows\gosentry-<version>-windows-amd64.exe. The script changes
# to the repository root first, so double-clicking it from Explorer works. It # to the repository root first, so double-clicking it from Explorer works. It
# also adds MSYS2 UCRT64 to PATH for this process only, embeds the Windows icon # also adds MSYS2 UCRT64 to PATH for this process only, embeds the Windows icon
# when windres is available, and uses the Windows GUI subsystem so no console # when windres is available, and uses the Windows GUI subsystem so no console
@@ -80,7 +87,7 @@ The binary is written to:
```text ```text
# GUI executable produced by scripts\build-windows.bat. # GUI executable produced by scripts\build-windows.bat.
dist\windows\pysentry-0.1.0-windows-amd64.exe dist\windows\gosentry-0.3.0-windows-amd64.exe
``` ```
Linux: Linux:
@@ -95,15 +102,15 @@ The binary is written to:
```text ```text
# Linux executable produced by scripts/build-linux.sh. # Linux executable produced by scripts/build-linux.sh.
dist/linux/pysentry-0.1.0-linux-amd64 dist/linux/gosentry-0.3.0-linux-amd64
``` ```
Linux using Docker: Linux using Docker:
```bash ```bash
# Builds the Linux binary inside Docker using the image tag # Builds the Linux binary inside Docker using the versioned image tag
# gitea.mixdep.ru/mix/pysentry-builder. Useful from hosts or CI jobs where the # gitea.mixdep.ru/mix/gosentry-builder:<version>. Useful from hosts or CI jobs
# native Linux/Fyne packages are not installed locally. # where the native Linux/Fyne packages are not installed locally.
chmod +x ./scripts/build-linux-docker.sh chmod +x ./scripts/build-linux-docker.sh
./scripts/build-linux-docker.sh ./scripts/build-linux-docker.sh
``` ```
@@ -112,7 +119,7 @@ The binary is copied to:
```text ```text
# Linux executable copied out of the Docker build image. # Linux executable copied out of the Docker build image.
dist\linux\pysentry-0.1.0-linux-amd64 dist\linux\gosentry-0.3.0-linux-amd64
``` ```
Release build from Linux: Release build from Linux:
@@ -120,7 +127,8 @@ Release build from Linux:
```bash ```bash
# Interactively choose Linux amd64, Linux arm64, Windows amd64, or all artifacts # Interactively choose Linux amd64, Linux arm64, Windows amd64, or all artifacts
# from one Linux/Docker workflow. The Dockerfile contains the builder # from one Linux/Docker workflow. The Dockerfile contains the builder
# environment; the build commands live in this script. # environment; the build commands live in this script. Docker runs the build
# with the current user's UID/GID so dist/ files are not owned by root.
chmod +x ./scripts/build-release-linux.sh chmod +x ./scripts/build-release-linux.sh
./scripts/build-release-linux.sh ./scripts/build-release-linux.sh
``` ```
@@ -136,13 +144,13 @@ The binaries are copied to:
```text ```text
# Linux artifact. # Linux artifact.
dist/linux/pysentry-0.1.0-linux-amd64 dist/linux/gosentry-0.3.0-linux-amd64
# Linux arm64 artifact. # Linux arm64 artifact.
dist/linux/pysentry-0.1.0-linux-arm64 dist/linux/gosentry-0.3.0-linux-arm64
# Windows artifact cross-compiled from Linux. # Windows artifact cross-compiled from Linux.
dist/windows/pysentry-0.1.0-windows-amd64.exe dist/windows/gosentry-0.3.0-windows-amd64.exe
``` ```
## Run From Source ## Run From Source
@@ -157,7 +165,7 @@ $env:CGO_ENABLED = '1'
# go run starts the app from source. Use scripts\build-windows.bat when you need # go run starts the app from source. Use scripts\build-windows.bat when you need
# a standalone .exe without a console window. # a standalone .exe without a console window.
& 'C:\Program Files\Go\bin\go.exe' run ./cmd/pysentry & 'C:\Program Files\Go\bin\go.exe' run ./cmd/gosentry
``` ```
Linux: Linux:
@@ -165,17 +173,58 @@ Linux:
```bash ```bash
# CGO must stay enabled because the Fyne GUI links against native Linux desktop # CGO must stay enabled because the Fyne GUI links against native Linux desktop
# libraries. # libraries.
CGO_ENABLED=1 go run ./cmd/pysentry CGO_ENABLED=1 go run ./cmd/gosentry
``` ```
## Troubleshooting
### Windows, VirtualBox, RDP, And OpenGL
GoSentry uses [Fyne](https://fyne.io/), and Fyne uses GLFW/OpenGL to create the
desktop window. In a Windows virtual machine, especially when the session is
opened through RDP inside VirtualBox, the available video driver can fail OpenGL
initialization.
Typical error:
```text
Fyne error: window creation error
Cause: APIUnavailable: WGL: The driver does not appear to support OpenGL
At: fyne.io/fyne/v2@v2.5.3/internal/driver/glfw/driver.go:149
```
Known workaround:
1. Download a Windows Mesa build from
[mesa-dist-win](https://github.com/pal1000/mesa-dist-win/releases). For a
regular Windows x64 GoSentry build, use the archive named like
`mesa3d-<version>-release-mingw.7z`, for example
`mesa3d-26.1.1-release-mingw.7z`. This matches the MSYS2 GCC toolchain used
to build GoSentry. The `devel`, `debug-info`, `tests`, and checksum files
are not needed for this workaround.
2. Open the downloaded archive and use the `x64` build from it.
3. Copy the Mesa OpenGL DLL files from `x64` into the same directory as the
GoSentry `.exe`, for example:
```text
dist\windows\
gosentry-0.3.0-windows-amd64.exe
opengl32.dll
...
```
This makes Windows load Mesa's software OpenGL implementation next to the
application binary, which lets the Fyne window start even when the VirtualBox/RDP
driver does not provide usable OpenGL.
## Storage ## Storage
PySentry creates its runtime files next to the executable by default. GoSentry creates its runtime files next to the executable by default.
`pysentry.yaml` stores application settings: `gosentry.yaml` stores application settings:
```yaml ```yaml
# Directory containing jobs.yaml. "." means "the folder where the PySentry # Directory containing jobs.yaml. "." means "the folder where the GoSentry
# executable lives"; an absolute path can be used when jobs should live elsewhere. # executable lives"; an absolute path can be used when jobs should live elsewhere.
jobs_dir: . jobs_dir: .
@@ -189,7 +238,7 @@ max_log_files: 100
# Delete .log files older than this many days during cleanup. # Delete .log files older than this many days during cleanup.
max_log_age_days: 30 max_log_age_days: 30
# Start PySentry automatically when the current desktop user signs in. # Start GoSentry automatically when the current desktop user signs in.
start_on_login: false start_on_login: false
# Closing the window hides it to the tray instead of stopping the scheduler. # Closing the window hides it to the tray instead of stopping the scheduler.
@@ -220,7 +269,7 @@ jobs:
schedule: '@every 1m' schedule: '@every 1m'
# Command passed to the platform shell: cmd.exe /C on Windows, sh -c on Linux. # Command passed to the platform shell: cmd.exe /C on Windows, sh -c on Linux.
command: echo PySentry test job: scheduler is alive command: echo GoSentry test job: scheduler is alive
# Disabled jobs remain in jobs.yaml but are skipped by the scheduler. # Disabled jobs remain in jobs.yaml but are skipped by the scheduler.
enabled: true enabled: true
@@ -255,7 +304,7 @@ Standard 5-field cron schedules:
## Using The App ## Using The App
1. Start PySentry. 1. Start GoSentry.
2. Use `New job` to create a command. 2. Use `New job` to create a command.
3. Set `Schedule`, `Command`, optional `Folder`, and `Enabled`. 3. Set `Schedule`, `Command`, optional `Folder`, and `Enabled`.
4. Use `Run now` for a manual test run. 4. Use `Run now` for a manual test run.
@@ -267,56 +316,70 @@ Standard 5-field cron schedules:
Changing `jobs_dir` saves the current job list to the new directory. Changing `jobs_dir` saves the current job list to the new directory.
The `Start on login` setting shows an `OK` or `Problem` status next to the checkbox. Saving settings with the checkbox enabled rewrites the autostart entry using the current executable path. The `Start on login` setting shows an `OK` or `Problem` status next to the checkbox. Saving settings with the checkbox enabled rewrites the autostart entry using the current executable path.
Autostart entries add `--start-in-tray`, so scheduled jobs begin running after sign-in without opening the main window.
## Autostart ## Autostart
PySentry is a user desktop application, not a system daemon, so autostart should be configured per user. GoSentry is a user desktop application, not a system daemon, so autostart should be configured per user.
Linux: Linux:
```ini ```ini
# PySentry writes a systemd user unit and enables it with # GoSentry writes an XDG Autostart desktop entry when Start on login is enabled.
# systemctl --user enable --now pysentry.service when Start on login is enabled. # This is better for a GUI/tray application than a systemd user service because
# A user unit starts after login and can run the tray/GUI app in the user's # the desktop environment starts it inside the graphical user session.
# desktop session. # Saving the setting also removes the old ~/.config/systemd/user/pysentry.service
[Unit] # unit if it was created by an earlier GoSentry build.
Description=PySentry desktop scheduler ~/.config/autostart/gosentry.desktop
[Service] [Desktop Entry]
ExecStart=/opt/pysentry/pysentry-0.1.0-linux-amd64 Type=Application
Restart=on-failure Name=GoSentry
Exec=/opt/gosentry/gosentry-0.3.0-linux-amd64 --start-in-tray
[Install] Terminal=false
WantedBy=default.target
``` ```
Windows: Windows:
```text ```text
# PySentry writes an HKCU Run entry when Start on login is enabled. It needs no # GoSentry writes a shortcut to the current user's Startup folder when Start on
# administrator rights and starts PySentry when the current user signs in. Task # login is enabled. A .lnk stores the executable path as a structured TargetPath,
# Scheduler remains a later option if delayed start or elevated tasks become # and stores --start-in-tray as Arguments, so paths with spaces do not need
# necessary. Saving settings with the checkbox enabled rewrites this entry, so it # fragile command-line quoting. Saving settings rewrites the shortcut and removes
# repairs an old path after the executable was moved or renamed. # old HKCU Run entries from earlier builds.
HKCU\Software\Microsoft\Windows\CurrentVersion\Run\PySentry %APPDATA%\Microsoft\Windows\Start Menu\Programs\Startup\GoSentry.lnk
``` ```
## Project Layout ## Project Layout
- `cmd/pysentry` starts the desktop app. - `cmd/gosentry` starts the desktop app.
- `src/gui` contains the GUI. - `src/gui` contains the GUI.
- `src/core` contains YAML storage, command execution, scheduling, and log cleanup. - `src/core` contains YAML storage, command execution, scheduling, and log cleanup.
- `assets` contains app icons that are embedded into the application binary. - `assets` contains app icons that are embedded into the application binary.
- `scripts` contains build helpers. - `scripts` contains build helpers.
- `docs` contains architecture notes, the changelog, and the roadmap.
Build outputs are written to `dist/`. The old local `bin/` directory is not used. Build outputs are written to `dist/`. The old local `bin/` directory is not used.
## Dependencies ## Dependencies
PySentry keeps the direct dependency list intentionally small: GoSentry keeps the direct dependency list intentionally small:
- [`fyne.io/fyne/v2`](https://fyne.io/) for the native GUI. - [`fyne.io/fyne/v2`](https://fyne.io/) for the native GUI.
- `github.com/robfig/cron/v3` for cron schedule parsing. - `github.com/robfig/cron/v3` for cron schedule parsing.
- `gopkg.in/yaml.v3` for YAML settings and jobs. - [`go.yaml.in/yaml/v4`](https://github.com/yaml/go-yaml) for YAML settings and jobs.
The remaining entries in `go.mod` are indirect dependencies pulled by Fyne and the Go module resolver. The remaining entries in `go.mod` are indirect dependencies pulled by Fyne and the Go module resolver.
Source repositories for mirroring:
- Go toolchain: https://go.googlesource.com/go
- Fyne: https://github.com/fyne-io/fyne
- robfig/cron: https://github.com/robfig/cron
- yaml/go-yaml: https://github.com/yaml/go-yaml
To list every direct and indirect Go module used by the current checkout:
```bash
go list -m all
```
+6 -2
View File
@@ -14,7 +14,7 @@ import (
// The blank import enables the compiler directive below; no runtime package // The blank import enables the compiler directive below; no runtime package
// initialization from embed is required. // initialization from embed is required.
// //
//go:embed pysentry-icon.png //go:embed gosentry-icon-big.png
var iconBytes []byte var iconBytes []byte
func Icon() fyne.Resource { func Icon() fyne.Resource {
@@ -22,5 +22,9 @@ func Icon() fyne.Resource {
// for the window icon and tray icon. The Windows Explorer icon is still added // for the window icon and tray icon. The Windows Explorer icon is still added
// by the build script through the .ico resource, because Explorer reads PE // by the build script through the .ico resource, because Explorer reads PE
// resources rather than Fyne runtime state. // resources rather than Fyne runtime state.
return fyne.NewStaticResource("pysentry-icon.png", iconBytes) return fyne.NewStaticResource("gosentry-icon-big.png", iconBytes)
}
func IconBytes() []byte {
return append([]byte(nil), iconBytes...)
} }
Binary file not shown.

After

Width:  |  Height:  |  Size: 299 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 996 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

-9
View File
@@ -1,9 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256">
<rect width="256" height="256" rx="52" fill="#005562"/>
<path d="M128 32 204 64v58c0 50-29 82-76 104-47-22-76-54-76-104V64z" fill="none" stroke="#ffffff" stroke-width="20" stroke-linejoin="round"/>
<path d="M128 76v55l42-32" fill="none" stroke="#ffffff" stroke-width="20" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="128" cy="132" r="14" fill="#ffffff"/>
<rect x="48" y="142" width="160" height="70" rx="16" fill="#00343c"/>
<path d="M74 160l38 17-38 17" fill="none" stroke="#ffb31a" stroke-width="18" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M132 195h48" fill="none" stroke="#ffb31a" stroke-width="18" stroke-linecap="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 745 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

+24
View File
@@ -0,0 +1,24 @@
package main
import (
"os"
"gitea.mixdep.ru/mix/gosentry/src/core"
"gitea.mixdep.ru/mix/gosentry/src/gui"
)
func main() {
// The executable entry point intentionally delegates all startup work to the
// GUI package. Keeping main small makes it easier to add platform-specific
// packaging later without mixing window setup, storage, and scheduler logic.
gui.Run(hasArgument(core.StartInTrayArgument))
}
func hasArgument(argument string) bool {
for _, current := range os.Args[1:] {
if current == argument {
return true
}
}
return false
}
-10
View File
@@ -1,10 +0,0 @@
package main
import "github.com/pysentry/pysentry/src/gui"
func main() {
// The executable entry point intentionally delegates all startup work to the
// GUI package. Keeping main small makes it easier to add platform-specific
// packaging later without mixing window setup, storage, and scheduler logic.
gui.Run()
}
+73
View File
@@ -0,0 +1,73 @@
# GoSentry Architecture
This document shows the current component interaction model. GoSentry is still a
single desktop process: the GUI, scheduler, storage, and command runner live in
one application and communicate through Go function calls and shared in-memory
job state.
## Component Diagram
```mermaid
flowchart LR
user["Desktop user"]
gui["src/gui - Fyne windows, tabs, dialogs"]
store["src/core Store - YAML config and jobs"]
scheduler["src/core Scheduler - @every and cron timing"]
runner["src/core Runner - shell command execution"]
autostart["src/core Autostart - Windows Startup shortcut / Linux desktop startup"]
config["gosentry.yaml - application settings"]
jobs["jobs.yaml - job definitions"]
logs["logs_dir - per-run command output logs"]
shell["Platform shell - cmd.exe /C or sh -c"]
user -->|"edits jobs, settings, runs commands"| gui
gui -->|"OpenStore, SaveConfig, SaveJobs"| store
store -->|"read/write"| config
store -->|"read/write"| jobs
gui -->|"Start, Pause, RunNow, RefreshSchedule"| scheduler
scheduler -->|"SaveJobs after state changes"| store
scheduler -->|"RunJob(trigger)"| runner
runner -->|"execute command"| shell
runner -->|"write stdout/stderr log"| logs
runner -->|"RunRecord with status, duration, log path"| scheduler
scheduler -->|"onChange RunRecord"| gui
gui -->|"display History, command output, job state"| user
gui -->|"SetAutostart, AutostartStatus"| autostart
autostart -->|"use executable path from resolved Paths"| config
```
## Main Flows
1. Startup:
The executable starts `cmd/gosentry`, which calls the GUI package. The GUI
opens the store, loads `gosentry.yaml` and `jobs.yaml`, creates the main tabs,
then starts the scheduler with the loaded job slice.
2. Editing settings or jobs:
The GUI updates the in-memory job/config state and asks `Store` to write YAML
back to disk. Job definitions stay in one `jobs.yaml`; runtime command output
is not stored there.
3. Scheduled run:
`Scheduler` checks due jobs on a one-second ticker. When a job is due, it marks
the job as running, saves state, and starts `Runner` asynchronously.
4. Manual run:
`Run now` calls the same scheduler path as scheduled execution, but the
resulting history record uses the `Manual` trigger.
5. Command execution:
`Runner` executes the command through the platform shell, captures stdout and
stderr, writes one timestamped `.log` file, and returns a `RunRecord`.
6. History update:
The scheduler receives the `RunRecord`, updates the matching job, saves YAML,
runs log cleanup, and calls the GUI callback so the `History` tab refreshes.
7. Autostart:
The Settings tab calls the platform autostart implementation. Windows uses a
shortcut in the current user's Startup folder. Linux uses a desktop-session
startup entry. Both autostart mechanisms pass `--start-in-tray`, so the
scheduler starts without opening the main window after sign-in.
+79
View File
@@ -0,0 +1,79 @@
# Changelog
All notable GoSentry changes are recorded in this file.
## 0.3.1 - 2026-06-17
- Changed startup timing in History to measure until the main window is actually shown instead of stopping during UI construction.
- Added a separate startup History message for autostart launches that begin hidden in the tray.
## 0.3.0 - 2026-06-17
- Renamed the project from PySentry to GoSentry across the GUI, module path, build scripts, generated artifacts, desktop integration, and documentation.
- Renamed the command package to `cmd/gosentry` and Windows resource script to `packaging/windows/gosentry.rc`.
- Renamed portable application settings from `pysentry.yaml` to `gosentry.yaml`, while keeping one-time read compatibility for existing `pysentry.yaml` files.
- Renamed build artifacts from `pysentry-*` to `gosentry-*`.
- Updated autostart and Linux desktop integration to use GoSentry names while cleaning up older PySentry autostart entries.
## 0.2.5 - 2026-06-16
- Stabilized the Jobs details panel so long selected-job fields do not resize the right pane or application window.
- Switched Windows autostart from `HKCU Run` entries to a Startup folder shortcut, fixing executable paths that contain spaces.
- Added `--start-in-tray` autostart launches for Windows and Linux so sign-in startup does not open the main window.
- Added Windows shortcut tests and Linux autostart desktop-entry tests for the new startup-in-tray behavior.
- Updated autostart documentation and architecture notes for the Startup shortcut and XDG desktop-entry behavior.
- Documented the Windows VirtualBox/RDP OpenGL startup failure and the Mesa software OpenGL workaround.
## 0.2.4 - 2026-06-16
- Prevented repeated application launches by forwarding a second start attempt to the already running instance.
- A second instance now asks the first instance to show and focus the existing window, then exits.
## 0.2.3 - 2026-06-15
- Changed History to use chronological ordering with new records appended at the bottom.
- Replaced the History list with a compact table.
- Added Time column sorting in both ascending and descending directions.
- Made History table columns user-resizable through the native Fyne table header.
- Shortened the Log column display to file names instead of full paths.
- Unified UI event timestamps with command run timestamps.
## 0.2.2 - 2026-06-15
- Added Linux desktop integration that installs a user-level `.desktop` file and icon so taskbars can match the running window to the GoSentry icon.
- Added the installed icon path to Linux autostart desktop entries when available.
- Added `ARCHITECTURE.md` with a component interaction diagram and moved project documentation under `docs/`.
- Adjusted the Mermaid architecture diagram to avoid line-break syntax that breaks rendering in Gitea.
- Stabilized the Jobs tab pane layout so switching jobs does not move the divider.
- Added startup timing to the History tab.
## 0.2.1 - 2026-06-15
- Fixed Docker release scripts so container builds keep Go in `PATH`.
- Disabled Go VCS stamping for Docker release builds to avoid failures when `.git` metadata is unavailable inside the container.
- Made Docker release builds write `dist/` artifacts with the current user's UID/GID instead of root ownership.
- Added `ROADMAP.md` with planned delivery formats and packaging priorities.
- Cleaned `.gitignore` for the current Go/Fyne project and kept the local `_gsdata_/` rule.
- Added README links to official Go/Fyne sites and source repositories useful for dependency mirroring.
- Documented Windows dependency installation steps for Go and MSYS2 UCRT64 GCC.
## 0.2.0 - 2026-06-15
- Added working autostart support with status diagnostics in Settings.
- Switched Linux autostart to XDG Autostart `.desktop` files and clean up the legacy user systemd unit.
- Fixed Windows autostart status detection by parsing `HKCU Run` values and comparing executable paths reliably.
- Added background job execution so the GUI does not block while commands run.
- Suppressed Windows console windows for scheduled and manual command runs.
- Added application version display in the window title, Settings, and build artifact names.
- Moved release artifact commands from `Dockerfile` into `scripts/build-release-linux.sh` with interactive target selection.
- Added release build targets for Linux amd64, Linux arm64, and Windows amd64.
- Added README dependency installation notes and official Go/Fyne links.
## 0.1.0 - 2026-06-14
- Added the initial Fyne desktop GUI.
- Added YAML settings and single-file YAML job storage.
- Added `@every` and standard 5-field cron schedules.
- Added manual and scheduled command runs with per-run log files.
- Added job folders, history, global pause, and Windows tray support.
- Added Windows and Linux build helpers.
+304
View File
@@ -0,0 +1,304 @@
# GoSentry Refactoring Plan
Status: proposed — not yet started.
Goal: make the codebase **solid**, **comprehensive**, and **human-readable / maintainable**
without changing observable behavior.
This document is the single source of truth for the refactor. It records the
target architecture, the rationale, and a sequence of small, independently
reviewable tasks. Each task lists the recommended agent model and effort level.
---
## 1. Why refactor
The application works and is well-commented, but its structure does not scale:
| # | Problem | Impact |
|---|---------|--------|
| 1 | `src/gui/app.go` is a 1,057-line monolith | Nothing can be found, reused, or tested in isolation |
| 2 | `src/core` is one flat package mixing 7 concerns | No boundaries; everything can call everything |
| 3 | **Shared mutable `*[]Job`** between GUI and `Scheduler` | GUI mutates the slice with no lock; scheduler locks the same slice → data race |
| 4 | `onChange` mutates Fyne widgets **from the scheduler goroutine** | Latent crash/corruption — Fyne requires UI updates on the main thread |
| 5 | `Job` mixes durable config and runtime state (`yaml:"-"` fields) | The "noise" the model fights to exclude lives in the same struct |
| 6 | Errors swallowed everywhere (`_ = store.SaveJobs(...)`) | Save failures are invisible to the user |
| 7 | No service/controller layer; GUI reaches into `store.Paths`, drives scheduler directly | Business logic is tangled into widget callbacks |
| 8 | Schedule strings re-parsed every tick; no `Schedule` value type | Validation scattered; no single source of truth |
| 9 | Tests only cover `core`; GUI and orchestration untestable | Documented gap in `docs/TESTS.md` |
> Note on layout: the project intentionally **keeps the `src/` directory**. The
> `src/` → `internal/` move was considered and rejected — it is cosmetic for a
> non-imported desktop app and not worth the import-path churn. All packages
> below live under `src/`.
---
## 2. Target architecture
The central change is to **insert an application-service layer** that owns all
state and exposes intent-based methods. This turns the UI into a thin view and
the core packages into stateless engines, dissolving problems 3, 4, 6, and 7.
```
┌──────────────┐ intents ┌─────────────────┐ calls ┌──────────────┐
│ ui (Fyne) │ ───────────▶ │ app.Service │ ─────────▶ │ core engines │
│ thin views │ ◀─────────── │ (sole owner of │ │ scheduler / │
│ fyne.Do only │ events │ state + mutex) │ ◀───────── │ runner / │
└──────────────┘ └─────────────────┘ records │ storage │
└──────────────┘
```
- **One writer.** `app.Service` holds the job list + runtime state behind a
mutex. The UI never mutates state directly — it calls `CreateJob`, `RunNow`,
`SetGlobalPause`, etc.
- **Events flow back** through an observer interface. The UI's listener is the
*only* place that touches widgets, and it marshals onto the main thread with
`fyne.Do`.
- **Core engines are stateless / injected** — scheduler and runner operate on
data passed in, not a shared slice.
### 2.1 Package layout (all under `src/`)
```
cmd/gosentry/
main.go # flag parse → ui.Run
src/
domain/ # pure types, zero external deps
job.go # Job (durable config only — no yaml:"-")
runtime.go # JobRuntime (LastRun/NextRun/State/Output/Logs)
record.go # RunRecord
config.go # Config + StartInTrayArgument
schedule.go # Schedule value object: Parse / Validate / Next()
storage/ # persistence + path resolution + migration
store.go # Load/SaveConfig, Load/SaveJobs
paths.go # ResolvePaths
yaml.go # writeYAML helper
migration.go # pysentry → gosentry legacy handling
scheduler/
scheduler.go # timing loop; drives Service via callbacks
clock.go # Clock interface (real + fake for tests)
runner/
runner.go # RunJob orchestration
invocation.go # build exec.Cmd (shared)
invocation_windows.go # cmd.exe quoting
invocation_other.go # sh -c
exitcodes.go # parse / accept success codes
logfile.go # writeRunLog + sanitizeFileName
cleanup.go # CleanupLogs
platform/
winproc/ # hidden-window helper shared by runner + autostart
winproc_windows.go # CREATE_NO_WINDOW / HideWindow
winproc_other.go # no-op
autostart/
autostart.go # Manager interface + Status type
windows.go linux.go other.go
desktop/
desktop_linux.go other.go
app/
service.go # owns state; CreateJob/UpdateJob/Delete/RunNow/...
events.go # Event types + Observer registration
format.go # display strings (moved out of GUI)
ui/ # renamed from src/gui; thin Fyne views
run.go # Run(): lifecycle, window, tray wiring
mainwindow.go # tab assembly + event listener (fyne.Do)
jobs_view.go # list + details panel + toolbar
job_dialog.go # new/edit form
history_view.go # history table
settings_view.go # settings form
tray.go # system tray
singleinstance.go # localhost IPC
layout.go # minWidthLayout
```
Import paths follow the existing convention, e.g.
`gitea.mixdep.ru/mix/gosentry/src/domain`,
`gitea.mixdep.ru/mix/gosentry/src/app`.
### 2.2 Dependency direction (must stay acyclic)
```
domain ← (no deps)
storage ← domain
runner ← domain, platform/winproc
scheduler← domain
app ← domain, storage, scheduler, runner
ui ← app, domain (Fyne)
platform/autostart, platform/desktop ← (own deps; winproc for windows)
cmd ← ui
```
### 2.3 Key design decisions
1. **Split durable vs. runtime in the domain.** `domain.Job` becomes pure YAML
config (no `yaml:"-"`). Runtime state moves to `domain.JobRuntime`, held by
the service keyed by job ID. (Resolves #5.)
2. **`Schedule` value object.** `schedule.Parse(string) (Schedule, error)`
validates once and exposes `Next(time.Time)`. (Resolves #8.)
3. **Autostart behind a `Manager` interface**, selected per platform — mockable,
no package-level functions.
4. **Injectable `Clock`** in the scheduler → deterministic tests.
5. **Errors surface to the UI.** Service methods return errors; status bar shows
them. No more `_ =` on saves. (Resolves #6.)
6. **Thread-safety contract:** core engines never import Fyne; the UI listener is
the sole widget mutator and always wraps updates in `fyne.Do`. (Resolves #4.)
---
## 3. Task sequence
Tasks are ordered so the tree **compiles and all tests pass after every task**.
Each task is a small, reviewable unit.
**Model guidance**
- `haiku` — mechanical moves, renames, no judgment required.
- `sonnet` — localized logic changes with clear scope.
- `opus` — architecture-shaping work (new layers, concurrency, public APIs).
**Effort guidance** — reasoning depth, not size: `low` / `medium` / `high`.
### Phase 0 — Safety net
| Task | Description | Model | Effort |
|------|-------------|-------|--------|
| T0.1 | Add `scripts/test.sh` + `.bat` running `go vet ./...` and `go test -race ./...`. Document in `docs/TESTS.md`. | haiku | low |
| T0.2 | Add characterization tests that pin current behavior at seams to be moved: store load→save round-trip, scheduler `nextRunTime`, end-to-end `RunJob` log output. (Some exist; fill gaps.) | sonnet | medium |
### Phase 1 — Split the flat `core` package (no logic change)
Mechanical moves + import fixes only. Behavior identical.
| Task | Description | Model | Effort |
|------|-------------|-------|--------|
| T1.1 | Create `src/domain`; move `Job`, `RunRecord`, `Config`, `JobsFile`, `StartInTrayArgument` from `model.go`. Keep `yaml:"-"` fields for now (split happens in Phase 2). Update all references. | sonnet | medium |
| T1.2 | Create `src/platform/winproc`; move `configureHiddenWindow` + hidden-window flags out of `runner_windows.go` / `runner_other.go`. This breaks the future autostart→runner coupling early. | sonnet | medium |
| T1.3 | Create `src/runner`; move `runner.go`, `runner_windows.go`, `runner_other.go`, `runner_test.go`. Point at `winproc`. Split helpers into `invocation*.go`, `exitcodes.go`, `logfile.go`, `cleanup.go` as the file moves. | sonnet | medium |
| T1.4 | Create `src/scheduler`; move `scheduler.go`, `scheduler_test.go`. Still takes `*[]domain.Job` for now. | sonnet | medium |
| T1.5 | Create `src/storage`; move `store.go`, `paths.go`, `store_test.go`. | sonnet | medium |
| T1.6 | Create `src/platform/autostart`; move `autostart_*.go` + tests. Point at `winproc`. | sonnet | medium |
| T1.7 | Create `src/platform/desktop`; move `desktop_linux.go`, `desktop_other.go`. | haiku | low |
| T1.8 | Delete the now-empty `src/core`; run full build + tests on both platforms (or with build tags) to confirm parity. | haiku | low |
### Phase 2 — Domain cleanup
| Task | Description | Model | Effort |
|------|-------------|-------|--------|
| T2.1 | Add `src/domain/schedule.go`: `Schedule` value object with `Parse`, `Validate`, `Next(time.Time)`. Unit-test it. Keep `nextRunTime` as a thin wrapper initially. | opus | high |
| T2.2 | Migrate `scheduler` to use `Schedule` (parse on load/edit, not per tick). Remove duplicated parsing. | sonnet | medium |
| T2.3 | Split `domain.Job` (durable) from `domain.JobRuntime` (transient). Remove all `yaml:"-"` fields and `nextDue` from `Job`. Add `runtime.go`. | opus | high |
| T2.4 | Update `storage`: load/save only `Job`; move runtime initialization out of `normalizeJobs` into a `domain.NewRuntime(job)` constructor. Update round-trip tests. | sonnet | medium |
> After Phase 2 the scheduler and GUI still share state; the `Job`/`JobRuntime`
> split is wired through temporary glue. Phase 3 removes the sharing.
### Phase 3 — Application service layer
| Task | Description | Model | Effort |
|------|-------------|-------|--------|
| T3.1 | Create `src/app/service.go`: `Service` owning `[]domain.Job` + `map[int]*domain.JobRuntime` behind a `sync.Mutex`. Constructor wires `storage`. | opus | high |
| T3.2 | Add `src/app/events.go`: `Event` types (job changed, run recorded, scheduler state) + `Observer` registration. Single-threaded dispatch contract documented. | opus | high |
| T3.3 | Move state-mutating operations into the service: `CreateJob`, `UpdateJob`, `DeleteJob`, `SetEnabled`, `RunNow`, `SetGlobalPause`, `UpdateSettings`. Each returns `error`. | opus | high |
| T3.4 | Convert `scheduler` to operate through the service (no `*[]Job`). Scheduler asks the service for due jobs and reports records back; service is the sole writer. Inject `Clock`. | opus | high |
| T3.5 | Move display/format helpers (`displayFolder`, `displayArguments`, `displayRunMode`, `statusText`, …) from GUI into `src/app/format.go`. | haiku | low |
| T3.6 | Add `src/app` unit tests (no Fyne): create/edit/delete, enable/pause, global pause, run-now path with a fake runner + fake clock. Big coverage win. | opus | high |
### Phase 4 — Carve up the GUI
Rename `src/gui``src/ui` and break `app.go` into focused files. The UI now
talks only to `app.Service` and reacts to events via `fyne.Do`.
| Task | Description | Model | Effort |
|------|-------------|-------|--------|
| T4.1 | Rename package `gui``ui`; split lifecycle into `run.go` + `mainwindow.go`. Wire the event listener and route every widget update through `fyne.Do`. (Resolves #4.) | opus | high |
| T4.2 | Extract `jobs_view.go` (list + details + toolbar), driven by service calls + events. | sonnet | medium |
| T4.3 | Extract `job_dialog.go`; validate schedule via `domain.Schedule.Validate`. | sonnet | medium |
| T4.4 | Extract `history_view.go`. | sonnet | medium |
| T4.5 | Extract `settings_view.go`; surface save/autostart/cleanup errors to the status label. (Resolves #6 in UI.) | sonnet | medium |
| T4.6 | Extract `tray.go`, `singleinstance.go`, `layout.go`. | haiku | low |
| T4.7 | Confirm `app.go` is gone and `ui` imports only `app` + `domain` + Fyne. Manual smoke test on each platform. | sonnet | medium |
### Phase 5 — Hardening & docs
| Task | Description | Model | Effort |
|------|-------------|-------|--------|
| T5.1 | Replace remaining `_ = ...Save...` with propagated/surfaced errors across service + storage. | sonnet | medium |
| T5.2 | Introduce `autostart.Manager` interface + per-platform impls; inject into the service instead of calling package funcs. | sonnet | medium |
| T5.3 | Fill documented test gaps: folder filtering, log cleanup (count + age), settings persistence/migration, concurrent run prevention. | sonnet | high |
| T5.4 | Run `go test -race ./...` clean. Confirm no data race remains. | haiku | low |
| T5.5 | Update `docs/ARCHITECTURE.md`, `docs/TESTS.md`, and the README "Project Layout" section to the new structure. | sonnet | medium |
---
## 3.1 Task completion checklist
Track progress here. Mark tasks complete as they land and pass review.
### Phase 0 — Safety net
- [x] T0.1 — Add test script + `go vet` + `go test -race`
- [x] T0.2 — Add characterization tests
### Phase 1 — Split flat `core` package
- [ ] T1.1 — Create `src/domain`; move Job/RunRecord/Config/etc
- [ ] T1.2 — Create `src/platform/winproc`; move `configureHiddenWindow`
- [ ] T1.3 — Create `src/runner`; move runner logic
- [ ] T1.4 — Create `src/scheduler`; move scheduler
- [ ] T1.5 — Create `src/storage`; move store/paths
- [ ] T1.6 — Create `src/platform/autostart`; move autostart logic
- [ ] T1.7 — Create `src/platform/desktop`; move desktop integration
- [ ] T1.8 — Delete empty `src/core`; build + test both platforms
### Phase 2 — Domain cleanup
- [ ] T2.1 — Add `src/domain/schedule.go`; Schedule value object
- [ ] T2.2 — Migrate `scheduler` to use Schedule
- [ ] T2.3 — Split `domain.Job` (durable) from `domain.JobRuntime` (transient)
- [ ] T2.4 — Update `storage`: load/save Job only; move runtime init
### Phase 3 — Application service layer
- [ ] T3.1 — Create `src/app/service.go`; owns state behind mutex
- [ ] T3.2 — Add `src/app/events.go`; Event types + Observer
- [ ] T3.3 — Add state-mutating operations to service
- [ ] T3.4 — Convert `scheduler` to use service; inject Clock
- [ ] T3.5 — Move display helpers to `src/app/format.go`
- [ ] T3.6 — Add `src/app` unit tests (no Fyne)
### Phase 4 — Carve up the GUI
- [ ] T4.1 — Rename `gui``ui`; split app.go into run.go + mainwindow.go
- [ ] T4.2 — Extract `jobs_view.go`
- [ ] T4.3 — Extract `job_dialog.go`
- [ ] T4.4 — Extract `history_view.go`
- [ ] T4.5 — Extract `settings_view.go`
- [ ] T4.6 — Extract `tray.go`, `singleinstance.go`, `layout.go`
- [ ] T4.7 — Confirm app.go is gone; smoke test both platforms
### Phase 5 — Hardening & docs
- [ ] T5.1 — Surface errors from service + storage
- [ ] T5.2 — Introduce `autostart.Manager` interface
- [ ] T5.3 — Fill test gaps (folder filtering, cleanup, migration, concurrency)
- [ ] T5.4 — Run `go test -race ./...` clean on both platforms
- [ ] T5.5 — Update docs (ARCHITECTURE.md, TESTS.md, README)
---
## 4. Definition of done
- `go vet ./...` clean; `go test -race ./...` green on Windows and Linux.
- No package outside `ui` imports Fyne; no engine mutates UI state.
- `domain.Job` has no `yaml:"-"` fields.
- `app.Service` is the only writer of job/runtime state.
- `src/ui` contains no file over ~250 lines; no single file over ~400.
- `docs/ARCHITECTURE.md` matches the shipped structure.
## 5. Risks & mitigations
| Risk | Mitigation |
|------|-----------|
| Cross-platform code moves break the non-host OS build | Build with both `GOOS=windows` and `GOOS=linux` after each platform-touching task (T1.2, T1.3, T1.6, T1.7). |
| Concurrency change (Phase 3/4) introduces subtle deadlocks | Keep the service mutex non-reentrant; never call back into the UI while holding it; cover with `-race` tests in T3.6. |
| Behavior drift during moves | Characterization tests (T0.2) pin behavior before structural change. |
| Large diff hard to review | Each task is a separate commit/PR; phases land independently. |
+63
View File
@@ -0,0 +1,63 @@
# Roadmap
This file tracks planned GoSentry work that is larger than a single bug fix.
## Post-Field-Test Cleanup
After real-world use confirms the main workflows, clean up temporary
stabilization code and development scaffolding.
Cleanup checklist:
- Review and remove debug-oriented diagnostics that are no longer useful.
- Remove excessive defensive checks once behavior is proven and covered by the
right tests.
- Remove obsolete compatibility cleanup, such as old autostart migration code,
after the transition window is over.
- Delete stale generated files and old build artifacts from local/release flows.
- Revisit tests and remove ones that only lock in temporary implementation
details instead of real user-facing behavior.
- Simplify README notes that were useful during early setup but are too noisy
for normal users.
- Recheck `.gitignore`, Docker scripts, and packaging scripts for rules or
branches that only supported early experiments.
## Tray Interaction
Improve tray icon interaction after choosing a tray backend path.
- Add double-click on the tray icon to show and focus the main window.
- Current Fyne 2.5.3 desktop tray API exposes menu and icon setup, but does not
expose click or double-click callbacks for the tray icon itself.
- Revisit when Fyne exposes this callback, or evaluate a small platform-specific
tray integration if the behavior becomes important enough.
## Delivery And Packaging
Keep a single portable binary as the baseline delivery format. It is simple to
test, easy to copy between machines, and matches the current storage model where
runtime YAML files live next to the executable by default.
Planned delivery variants:
- Windows portable `.zip` with `gosentry.exe`, `README.md`, and `CHANGELOG.md`.
- Linux portable `.tar.gz` archives for `linux-amd64` and `linux-arm64`.
- Debian/Ubuntu `.deb` package once the Linux runtime paths are settled.
- Windows installer later, likely Inno Setup first and MSI/WiX only if needed.
- AppImage as a possible Linux GUI-friendly format after the core workflow is stable.
- Flatpak only after the desktop integration story is clearer.
- winget manifest after stable public Windows releases exist.
Packaging design note:
- Portable builds can keep settings and jobs next to the executable.
- Installer/package builds should move runtime data to per-user locations:
`%APPDATA%\GoSentry` on Windows, and XDG directories such as
`~/.config/gosentry` and `~/.local/share/gosentry` on Linux.
Initial priority:
1. Windows portable `.zip`.
2. Linux portable `.tar.gz` for amd64 and arm64.
3. Debian/Ubuntu `.deb`.
4. Windows installer.
+183
View File
@@ -0,0 +1,183 @@
# GoSentry Test Suite
All tests are located alongside source code in the `src/core/` package. Tests follow Go conventions with `*_test.go` filename patterns.
## Running Tests
### Using the test scripts
The repository provides convenience scripts to run all tests with static analysis:
**Unix/Linux/macOS:**
```bash
./scripts/test.sh
```
**Windows:**
```bash
scripts\test.bat
```
Both scripts run:
1. `go vet ./...` — static analysis for common errors and suspicious code patterns
2. `go test -race ./...` — tests with race condition detection enabled
### Manual test commands
Run all tests:
```bash
go test ./...
```
Run all tests with race detection:
```bash
go test -race ./...
```
Run tests with verbose output:
```bash
go test -v ./...
```
Run a specific test by name:
```bash
go test -run TestRunJobWritesLogFile ./src/core
```
Run tests with code coverage:
```bash
go test -cover ./src/core
go test -coverprofile=coverage.out ./src/core
go tool cover -html=coverage.out
```
---
## Test Files Overview
### store_test.go
**Location:** `src/core/store_test.go`
**Package:** `core`
Tests YAML serialization and storage behavior.
| Test | Purpose |
|------|---------|
| `TestJobsYAMLDoesNotPersistRuntimeNoise` | Verifies that `jobs.yaml` does not persist runtime state fields (LastRun, NextRun, LastState, Output, etc.). Only job definitions are stored; runtime data is kept in memory and log files. |
---
### scheduler_test.go
**Location:** `src/core/scheduler_test.go`
**Package:** `core`
Tests schedule parsing and job invocation output formatting.
| Test | Purpose |
|------|---------|
| `TestNextRunTimeSupportsEvery` | Verifies `@every` duration syntax (e.g., `@every 10s`) correctly calculates next run time. Tests with 10-second interval. |
| `TestNextRunTimeSupportsCron` | Verifies standard 5-field cron expressions (e.g., `*/5 * * * *`) correctly calculate next run time. Tests 5-minute interval. |
| `TestRunningOutputIncludesInvocation` | Verifies the running job output header includes all relevant invocation details: command, arguments, success exit codes, start time, and trigger type. |
---
### runner_test.go
**Location:** `src/core/runner_test.go`
**Package:** `core`
Tests command execution, exit code handling, output capture, and Windows-specific process behavior.
#### Log File Tests
| Test | Purpose |
|------|---------|
| `TestRunJobWritesLogFile` | Verifies that each job execution creates a `.log` file in the configured logs directory with sanitized job name in filename and proper metadata (trigger type, job name, command output). |
#### Command Execution Tests
| Test | Platform | Purpose |
|------|----------|---------|
| `TestRunJobRunsQuotedWindowsExecutable` | Windows | Verifies that executable paths with quotes (e.g., `"C:\Program Files\..."`) are executed correctly via cmd.exe. |
| `TestRunJobRunsUnquotedWindowsProgramPathWithSpaces` | Windows | Verifies that unquoted executable paths with spaces (e.g., `C:\Program Files\App\app.exe`) are quoted and executed correctly. |
| `TestRunJobRunsWindowsCommandWithSeparateArguments` | Windows | Verifies that command and arguments separated in the Job struct are combined and executed correctly. |
#### Exit Code Handling Tests
| Test | Purpose |
|------|---------|
| `TestRunJobAcceptsConfiguredExitCode` | Verifies that exit codes listed in `SuccessExitCodes` (e.g., `"0,1"`) result in "OK" status even if nonzero. Includes detail message about accepted exit code. |
| `TestRunJobRejectsUnconfiguredExitCode` | Verifies that exit codes not listed in `SuccessExitCodes` result in "Failed" status with exit code detail. |
#### Start-Only Mode Tests
| Test | Purpose |
|------|---------|
| `TestRunJobStartOnlyDoesNotWaitForExitCode` | Verifies that jobs with `StartOnly: true` launch the process and return "OK" immediately without waiting for process exit or checking exit code. |
| `TestRunJobStartOnlyReportsStartFailure` | Verifies that jobs with `StartOnly: true` still report "Failed" if the process fails to start (e.g., executable not found). |
#### Utility Function Tests
| Test | Platform | Purpose |
|------|----------|---------|
| `TestParseExitCodes` | All | Verifies that exit code strings with mixed separators (comma, semicolon, newline) are correctly parsed into integer slice. |
| `TestDirectCommandDoesNotHideWindow` | Windows | Verifies that direct executable commands (with explicit path and arguments) do not request hidden window startup. |
| `TestShellCommandHidesWindow` | Windows | Verifies that shell commands (passed to cmd.exe) request hidden window startup to prevent console flash. |
| `TestShellCommandUsesWindowsSafeQuoting` | Windows | Verifies that shell commands use cmd.exe `/S /C` syntax with proper outer quoting to handle paths with spaces and special characters. |
| `TestWindowsShellCommandLineQuotesUnquotedProgramPath` | Windows | Verifies that unquoted program paths in shell commands are quoted while preserving already-quoted arguments. |
---
### autostart_windows_test.go
**Location:** `src/core/autostart_windows_test.go`
**Package:** `core`
**Build Tags:** `//go:build windows` (Windows only)
Tests Windows autostart entry creation via shortcuts in the Startup folder.
| Test | Purpose |
|------|---------|
| `TestParseRegistryRunValue` | Verifies that legacy Windows Registry `Run` entry values are correctly parsed from `reg query` output (for migration/cleanup). |
| `TestSameWindowsPathIgnoresCaseAndQuotes` | Verifies that Windows path comparison is case-insensitive and handles quote marks correctly (e.g., `"D:\..."` matches `d:\...`). |
| `TestSameWindowsPathHandlesSpaces` | Verifies that Windows path comparison correctly matches paths with spaces both with and without quotes. |
| `TestStartupShortcutPathUsesUserStartupFolder` | Verifies that the startup shortcut path resolves to the user's Startup folder using `%APPDATA%` environment variable. |
| `TestCreateStartupShortcutHandlesSpaces` | Verifies that `.lnk` shortcut files are created with correct `TargetPath` and `Arguments` (--start-in-tray) even when target path contains spaces. |
---
### autostart_linux_test.go
**Location:** `src/core/autostart_linux_test.go`
**Package:** `core`
**Build Tags:** `//go:build linux` (Linux only)
Tests Linux autostart entry creation via XDG Desktop Entry files.
| Test | Purpose |
|------|---------|
| `TestLinuxAutostartStartsInTray` | Verifies that the XDG Desktop Entry is created with the `--start-in-tray` argument in the `Exec=` field, so scheduled jobs run immediately after login without displaying the window. |
| `TestLinuxAutostartRemovesLegacyDesktopEntry` | Verifies that legacy autostart entries (from old PySentry implementation) are cleaned up when enabling autostart through the new system. |
---
## Test Design Principles
1. **Isolation** — Tests use `t.TempDir()` for file operations and `t.Setenv()` for environment variables to avoid affecting system state.
2. **Cross-platform** — Platform-specific tests use `//go:build` tags and `runtime.GOOS` checks to skip when not applicable.
3. **Exit Code Flexibility** — The `SuccessExitCodes` field allows jobs to treat nonzero exit codes as success, tested explicitly.
4. **Path Handling** — Extensive tests cover Windows path quoting, spaces in paths, and case-insensitive matching to avoid subtle shell escaping bugs.
5. **Start-Only Mode** — Special handling for long-running processes that should be launched but not waited on, tested separately from normal execution flow.
---
## Future Test Coverage Gaps
Potential areas for additional tests:
- Job group/folder filtering and persistence
- Log cleanup (max file count and max age)
- Settings persistence and migration
- GUI integration tests (currently untested)
- Concurrent job execution
- Job history and run record storage
+3 -2
View File
@@ -1,11 +1,11 @@
module github.com/pysentry/pysentry module gitea.mixdep.ru/mix/gosentry
go 1.22 go 1.22
require ( require (
fyne.io/fyne/v2 v2.5.3 fyne.io/fyne/v2 v2.5.3
github.com/robfig/cron/v3 v3.0.1 github.com/robfig/cron/v3 v3.0.1
gopkg.in/yaml.v3 v3.0.1 go.yaml.in/yaml/v4 v4.0.0-rc.5
) )
require ( require (
@@ -37,4 +37,5 @@ require (
golang.org/x/net v0.25.0 // indirect golang.org/x/net v0.25.0 // indirect
golang.org/x/sys v0.20.0 // indirect golang.org/x/sys v0.20.0 // indirect
golang.org/x/text v0.16.0 // indirect golang.org/x/text v0.16.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
) )
+2
View File
@@ -300,6 +300,8 @@ go.opencensus.io v0.23.0/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E=
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU=
go.uber.org/zap v1.17.0/go.mod h1:MXVU+bhUf/A7Xi2HNOnopQOrmycQ5Ih87HtOu4q5SSo= go.uber.org/zap v1.17.0/go.mod h1:MXVU+bhUf/A7Xi2HNOnopQOrmycQ5Ih87HtOu4q5SSo=
go.yaml.in/yaml/v4 v4.0.0-rc.5 h1:JVliQq9EGOYaTgMi+k8BhUJyqcGk4ZqeuiN1Cirba9c=
go.yaml.in/yaml/v4 v4.0.0-rc.5/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
golang.org/x/crypto v0.0.0-20181029021203-45a5f77698d3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20181029021203-45a5f77698d3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
+1
View File
@@ -0,0 +1 @@
IDI_ICON1 ICON "assets/gosentry.ico"
-1
View File
@@ -1 +0,0 @@
IDI_ICON1 ICON "assets/pysentry.ico"
+11 -4
View File
@@ -6,20 +6,27 @@ set -euo pipefail
# default includes the application version and target platform. # default includes the application version and target platform.
version="$(sed -n 's/^var Version = "\(.*\)"/\1/p' src/core/version.go)" version="$(sed -n 's/^var Version = "\(.*\)"/\1/p' src/core/version.go)"
version="${version:-0.0.0-dev}" version="${version:-0.0.0-dev}"
output="${1:-dist/linux/pysentry-${version}-linux-amd64}" tag="gitea.mixdep.ru/mix/gosentry-builder:${version}"
output="${1:-dist/linux/gosentry-${version}-linux-amd64}"
docker_user_args=()
if command -v id >/dev/null 2>&1; then
docker_user_args=(--user "$(id -u):$(id -g)")
fi
# Dockerfile contains the native packages required by Fyne. Keeping that # Dockerfile contains the native packages required by Fyne. Keeping that
# environment in Docker makes Linux builds repeatable from Windows hosts and CI. # environment in Docker makes Linux builds repeatable from Windows hosts and CI.
docker build -f Dockerfile -t gitea.mixdep.ru/mix/pysentry-builder . docker build -f Dockerfile -t "$tag" .
mkdir -p "$(dirname "$output")" mkdir -p "$(dirname "$output")"
docker run --rm \ docker run --rm \
"${docker_user_args[@]}" \
-e "VERSION=${version}" \ -e "VERSION=${version}" \
-e "OUTPUT=${output}" \ -e "OUTPUT=${output}" \
-e "GOCACHE=/tmp/go-build-cache" \
-v "$(pwd):/src" \ -v "$(pwd):/src" \
-w /src \ -w /src \
gitea.mixdep.ru/mix/pysentry-builder \ "$tag" \
bash -lc 'CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags "-s -w -X github.com/pysentry/pysentry/src/core.Version=${VERSION}" -o "${OUTPUT}" ./cmd/pysentry' bash -c 'CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build -buildvcs=false -trimpath -ldflags "-s -w -X gitea.mixdep.ru/mix/gosentry/src/core.Version=${VERSION}" -o "${OUTPUT}" ./cmd/gosentry'
# Icons are embedded in the Go binary, so there is no assets directory to copy # Icons are embedded in the Go binary, so there is no assets directory to copy
# after extracting the Linux executable. # after extracting the Linux executable.
+2 -2
View File
@@ -5,7 +5,7 @@ set -euo pipefail
# default includes the application version and target platform. # default includes the application version and target platform.
version="$(sed -n 's/^var Version = "\(.*\)"/\1/p' src/core/version.go)" version="$(sed -n 's/^var Version = "\(.*\)"/\1/p' src/core/version.go)"
version="${version:-0.0.0-dev}" version="${version:-0.0.0-dev}"
output="${1:-dist/linux/pysentry-${version}-linux-amd64}" output="${1:-dist/linux/gosentry-${version}-linux-amd64}"
mkdir -p "$(dirname "$output")" mkdir -p "$(dirname "$output")"
# Fyne needs CGO for its native desktop backend. The script pins the target to # Fyne needs CGO for its native desktop backend. The script pins the target to
@@ -17,7 +17,7 @@ export GOARCH=amd64
# -trimpath removes local machine paths from debug/build metadata. -s -w strips # -trimpath removes local machine paths from debug/build metadata. -s -w strips
# symbol/debug tables to keep the desktop binary smaller. # symbol/debug tables to keep the desktop binary smaller.
go build -trimpath -ldflags "-s -w -X github.com/pysentry/pysentry/src/core.Version=${version}" -o "$output" ./cmd/pysentry go build -trimpath -ldflags "-s -w -X gitea.mixdep.ru/mix/gosentry/src/core.Version=${version}" -o "$output" ./cmd/gosentry
# The application icon is embedded by Go, so the Linux build does not need a # The application icon is embedded by Go, so the Linux build does not need a
# sidecar assets directory beside the executable. # sidecar assets directory beside the executable.
+15 -10
View File
@@ -5,15 +5,18 @@ set -euo pipefail
# image contains Linux/Fyne dependencies for amd64 and arm64, plus the MinGW # image contains Linux/Fyne dependencies for amd64 and arm64, plus the MinGW
# cross-compiler used for the Windows GUI executable. Actual build commands live # cross-compiler used for the Windows GUI executable. Actual build commands live
# here rather than in Dockerfile so target selection does not require rebuilding # here rather than in Dockerfile so target selection does not require rebuilding
# the image.
tag="gitea.mixdep.ru/mix/pysentry-builder"
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
repo_root="$(cd "${script_dir}/.." && pwd)" repo_root="$(cd "${script_dir}/.." && pwd)"
cd "$repo_root" cd "$repo_root"
version="$(sed -n 's/^var Version = "\(.*\)"/\1/p' src/core/version.go)" version="$(sed -n 's/^var Version = "\(.*\)"/\1/p' src/core/version.go)"
version="${version:-0.0.0-dev}" version="${version:-0.0.0-dev}"
tag="gitea.mixdep.ru/mix/gosentry-builder:${version}"
docker_user_args=()
if command -v id >/dev/null 2>&1; then
docker_user_args=(--user "$(id -u):$(id -g)")
fi
usage() { usage() {
cat <<EOF cat <<EOF
@@ -21,9 +24,9 @@ Usage: $0 [target...]
Targets: Targets:
all Build every release artifact. all Build every release artifact.
linux-amd64 Build dist/linux/pysentry-${version}-linux-amd64. linux-amd64 Build dist/linux/gosentry-${version}-linux-amd64.
linux-arm64 Build dist/linux/pysentry-${version}-linux-arm64. linux-arm64 Build dist/linux/gosentry-${version}-linux-arm64.
windows-amd64 Build dist/windows/pysentry-${version}-windows-amd64.exe. windows-amd64 Build dist/windows/gosentry-${version}-windows-amd64.exe.
When no target is passed and the script runs in a terminal, it asks what to build. When no target is passed and the script runs in a terminal, it asks what to build.
EOF EOF
@@ -86,23 +89,25 @@ normalize_targets() {
run_in_builder() { run_in_builder() {
docker run --rm \ docker run --rm \
"${docker_user_args[@]}" \
-e "VERSION=${version}" \ -e "VERSION=${version}" \
-e "GOCACHE=/tmp/go-build-cache" \
-v "${repo_root}:/src" \ -v "${repo_root}:/src" \
-w /src \ -w /src \
"$tag" \ "$tag" \
bash -lc "$1" bash -c "$1"
} }
build_linux_amd64() { build_linux_amd64() {
run_in_builder 'mkdir -p dist/linux && CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags "-s -w -X github.com/pysentry/pysentry/src/core.Version=${VERSION}" -o "dist/linux/pysentry-${VERSION}-linux-amd64" ./cmd/pysentry' run_in_builder 'mkdir -p dist/linux && CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build -buildvcs=false -trimpath -ldflags "-s -w -X gitea.mixdep.ru/mix/gosentry/src/core.Version=${VERSION}" -o "dist/linux/gosentry-${VERSION}-linux-amd64" ./cmd/gosentry'
} }
build_linux_arm64() { build_linux_arm64() {
run_in_builder 'mkdir -p dist/linux && CC=aarch64-linux-gnu-gcc CGO_ENABLED=1 GOOS=linux GOARCH=arm64 CGO_CFLAGS="--sysroot=/ -I/usr/include/aarch64-linux-gnu" CGO_LDFLAGS="--sysroot=/ -L/usr/lib/aarch64-linux-gnu" PKG_CONFIG_LIBDIR=/usr/lib/aarch64-linux-gnu/pkgconfig go build -trimpath -ldflags "-s -w -X github.com/pysentry/pysentry/src/core.Version=${VERSION}" -o "dist/linux/pysentry-${VERSION}-linux-arm64" ./cmd/pysentry' run_in_builder 'mkdir -p dist/linux && CC=aarch64-linux-gnu-gcc CGO_ENABLED=1 GOOS=linux GOARCH=arm64 CGO_CFLAGS="--sysroot=/ -I/usr/include/aarch64-linux-gnu" CGO_LDFLAGS="--sysroot=/ -L/usr/lib/aarch64-linux-gnu" PKG_CONFIG_LIBDIR=/usr/lib/aarch64-linux-gnu/pkgconfig go build -buildvcs=false -trimpath -ldflags "-s -w -X gitea.mixdep.ru/mix/gosentry/src/core.Version=${VERSION}" -o "dist/linux/gosentry-${VERSION}-linux-arm64" ./cmd/gosentry'
} }
build_windows_amd64() { build_windows_amd64() {
run_in_builder 'mkdir -p dist/windows && x86_64-w64-mingw32-windres -O coff -o cmd/pysentry/rsrc_windows_amd64.syso packaging/windows/pysentry.rc && CC=x86_64-w64-mingw32-gcc CGO_ENABLED=1 GOOS=windows GOARCH=amd64 go build -trimpath -ldflags "-s -w -H=windowsgui -X github.com/pysentry/pysentry/src/core.Version=${VERSION}" -o "dist/windows/pysentry-${VERSION}-windows-amd64.exe" ./cmd/pysentry' run_in_builder 'mkdir -p dist/windows && x86_64-w64-mingw32-windres -O coff -o cmd/gosentry/rsrc_windows_amd64.syso packaging/windows/gosentry.rc && CC=x86_64-w64-mingw32-gcc CGO_ENABLED=1 GOOS=windows GOARCH=amd64 go build -buildvcs=false -trimpath -ldflags "-s -w -H=windowsgui -X gitea.mixdep.ru/mix/gosentry/src/core.Version=${VERSION}" -o "dist/windows/gosentry-${VERSION}-windows-amd64.exe" ./cmd/gosentry'
} }
mapfile -t targets < <(choose_targets "$@" | normalize_targets | awk '!seen[$0]++') mapfile -t targets < <(choose_targets "$@" | normalize_targets | awk '!seen[$0]++')
+5 -5
View File
@@ -3,7 +3,7 @@ setlocal enabledelayedexpansion
REM Double-clicking a .bat file can start it with an arbitrary working REM Double-clicking a .bat file can start it with an arbitrary working
REM directory. Move to the repository root (the parent of scripts\) before using REM directory. Move to the repository root (the parent of scripts\) before using
REM relative paths such as .\cmd\pysentry and packaging\windows\pysentry.rc. REM relative paths such as .\cmd\gosentry and packaging\windows\gosentry.rc.
cd /d "%~dp0\.." cd /d "%~dp0\.."
for /f "tokens=4" %%V in ('findstr /C:"var Version" src\core\version.go') do set "VERSION=%%~V" for /f "tokens=4" %%V in ('findstr /C:"var Version" src\core\version.go') do set "VERSION=%%~V"
@@ -14,7 +14,7 @@ REM Optional first argument allows CI or a developer to choose another output
REM path. The default keeps all generated binaries under dist\ so the source tree REM path. The default keeps all generated binaries under dist\ so the source tree
REM stays clean and the old bin\ folder is no longer needed. REM stays clean and the old bin\ folder is no longer needed.
set "OUTPUT=%~1" set "OUTPUT=%~1"
if "%OUTPUT%"=="" set "OUTPUT=dist\windows\pysentry-%VERSION%-windows-amd64.exe" if "%OUTPUT%"=="" set "OUTPUT=dist\windows\gosentry-%VERSION%-windows-amd64.exe"
REM Prefer the standard Go installer path on Windows, but fall back to PATH for REM Prefer the standard Go installer path on Windows, but fall back to PATH for
REM machines where Go was installed by another package manager. REM machines where Go was installed by another package manager.
@@ -37,17 +37,17 @@ for %%I in ("%OUTPUT%") do set "OUTDIR=%%~dpI"
if not exist "%OUTDIR%" mkdir "%OUTDIR%" if not exist "%OUTDIR%" mkdir "%OUTDIR%"
REM windres embeds the .ico file into the PE executable so Windows Explorer, REM windres embeds the .ico file into the PE executable so Windows Explorer,
REM shortcuts, and the taskbar can show the PySentry icon. The Go embed package REM shortcuts, and the taskbar can show the GoSentry icon. The Go embed package
REM handles Fyne's runtime icon, but Explorer reads this Windows resource instead. REM handles Fyne's runtime icon, but Explorer reads this Windows resource instead.
where windres.exe >nul 2>nul where windres.exe >nul 2>nul
if %ERRORLEVEL%==0 ( if %ERRORLEVEL%==0 (
windres.exe -O coff -o cmd\pysentry\rsrc_windows_amd64.syso packaging\windows\pysentry.rc windres.exe -O coff -o cmd\gosentry\rsrc_windows_amd64.syso packaging\windows\gosentry.rc
) )
REM -trimpath removes local machine paths from the binary, -s -w reduce binary REM -trimpath removes local machine paths from the binary, -s -w reduce binary
REM size, and -H=windowsgui prevents a separate console window from opening when REM size, and -H=windowsgui prevents a separate console window from opening when
REM the GUI app starts from Explorer or a shortcut. REM the GUI app starts from Explorer or a shortcut.
"%GOEXE%" build -trimpath -ldflags "-s -w -H=windowsgui -X github.com/pysentry/pysentry/src/core.Version=%VERSION%" -o "%OUTPUT%" .\cmd\pysentry "%GOEXE%" build -trimpath -ldflags "-s -w -H=windowsgui -X gitea.mixdep.ru/mix/gosentry/src/core.Version=%VERSION%" -o "%OUTPUT%" .\cmd\gosentry
if errorlevel 1 exit /b 1 if errorlevel 1 exit /b 1
REM Icons are embedded into the executable, so no assets directory is copied next REM Icons are embedded into the executable, so no assets directory is copied next
+23
View File
@@ -0,0 +1,23 @@
@echo off
REM GoSentry test runner
REM Runs go vet and go test with race detection
echo Running go vet...
go vet ./...
if errorlevel 1 (
echo.
echo ✗ go vet failed
exit /b 1
)
echo.
echo Running go test with race detection...
go test -race ./...
if errorlevel 1 (
echo.
echo ✗ go test failed
exit /b 1
)
echo.
echo ✓ All tests passed
+16
View File
@@ -0,0 +1,16 @@
#!/bin/bash
# GoSentry test runner
# Runs go vet and go test with race detection
set -e
echo "Running go vet..."
go vet ./...
echo ""
echo "Running go test with race detection..."
go test -race ./...
echo ""
echo "✓ All tests passed"
+140 -46
View File
@@ -7,76 +7,82 @@ import (
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"strconv"
"strings" "strings"
) )
const autostartUnitName = "pysentry.service" const autostartDesktopFileName = "gosentry.desktop"
const legacyAutostartDesktopFileName = "pysentry.desktop"
func SetAutostart(enabled bool, executablePath string) error { func SetAutostart(enabled bool, executablePath string, iconPath string) error {
unitDir, err := userSystemdDir() desktopPath, err := autostartDesktopPath()
if err != nil { if err != nil {
return err return err
} }
unitPath := filepath.Join(unitDir, autostartUnitName) // A desktop scheduler with a tray icon belongs to the graphical session, so
// Linux autostart is implemented through XDG Autostart instead of a systemd
if enabled { // user service. systemd is tempting because it is explicit and scriptable,
if err := os.MkdirAll(unitDir, 0o755); err != nil { // but it is the wrong owner for a windowed app that should inherit the
return err // desktop session environment and appear in the tray predictably.
} if err := cleanupLegacySystemdAutostart(); err != nil {
unit := fmt.Sprintf(`[Unit]
Description=PySentry desktop scheduler
[Service]
ExecStart=%s
Restart=on-failure
[Install]
WantedBy=default.target
`, executablePath)
if err := os.WriteFile(unitPath, []byte(unit), 0o644); err != nil {
return err
}
if err := exec.Command("systemctl", "--user", "daemon-reload").Run(); err != nil {
return err
}
return exec.Command("systemctl", "--user", "enable", "--now", autostartUnitName).Run()
}
_ = exec.Command("systemctl", "--user", "disable", "--now", autostartUnitName).Run()
if err := os.Remove(unitPath); err != nil && !os.IsNotExist(err) {
return err return err
} }
return exec.Command("systemctl", "--user", "daemon-reload").Run() if err := cleanupLegacyDesktopAutostart(); err != nil {
return err
}
if enabled {
if err := os.MkdirAll(filepath.Dir(desktopPath), 0o755); err != nil {
return err
}
desktopFile := fmt.Sprintf(`[Desktop Entry]
Type=Application
Name=GoSentry
Comment=GoSentry desktop scheduler
Exec=%s %s
%s
Terminal=false
X-GNOME-Autostart-enabled=true
`, quoteDesktopExec(executablePath), StartInTrayArgument, desktopIconLine(iconPath))
return os.WriteFile(desktopPath, []byte(desktopFile), 0o644)
}
if err := os.Remove(desktopPath); err != nil && !os.IsNotExist(err) {
return err
}
return nil
} }
func AutostartStatus(expectedEnabled bool, executablePath string) (bool, string) { func AutostartStatus(expectedEnabled bool, executablePath string) (bool, string) {
unitDir, err := userSystemdDir() desktopPath, err := autostartDesktopPath()
if err != nil { if err != nil {
return false, "Cannot resolve user systemd directory" return false, "Cannot resolve XDG autostart directory"
} }
unitPath := filepath.Join(unitDir, autostartUnitName) if legacySystemdAutostartExists() {
data, readErr := os.ReadFile(unitPath) return false, "Legacy systemd autostart entry still exists"
enabledErr := exec.Command("systemctl", "--user", "is-enabled", "--quiet", autostartUnitName).Run() }
if legacyDesktopAutostartExists() {
return false, "Legacy desktop autostart entry still exists"
}
data, readErr := os.ReadFile(desktopPath)
if !expectedEnabled { if !expectedEnabled {
if os.IsNotExist(readErr) && enabledErr != nil { if os.IsNotExist(readErr) {
return true, "Autostart is off" return true, "Autostart is off"
} }
return false, "Autostart unit exists while setting is off" return false, "Autostart desktop entry exists while setting is off"
} }
if readErr != nil { if readErr != nil {
return false, "Autostart unit is missing" return false, "Autostart desktop entry is missing"
} }
if !strings.Contains(string(data), executablePath) { expectedExec := "Exec=" + quoteDesktopExec(executablePath) + " " + StartInTrayArgument
return false, "Autostart unit points to another executable" if !strings.Contains(string(data), expectedExec) {
} return false, "Autostart desktop entry points to another executable"
if enabledErr != nil {
return false, "Autostart unit is not enabled"
} }
return true, "Autostart is configured" return true, "Autostart is configured"
} }
func userSystemdDir() (string, error) { func autostartDesktopPath() (string, error) {
configHome := os.Getenv("XDG_CONFIG_HOME") configHome := os.Getenv("XDG_CONFIG_HOME")
if configHome == "" { if configHome == "" {
home, err := os.UserHomeDir() home, err := os.UserHomeDir()
@@ -85,5 +91,93 @@ func userSystemdDir() (string, error) {
} }
configHome = filepath.Join(home, ".config") configHome = filepath.Join(home, ".config")
} }
return filepath.Join(configHome, "systemd", "user"), nil return filepath.Join(configHome, "autostart", autostartDesktopFileName), nil
}
func legacyAutostartDesktopPath() (string, error) {
configHome := os.Getenv("XDG_CONFIG_HOME")
if configHome == "" {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
configHome = filepath.Join(home, ".config")
}
return filepath.Join(configHome, "autostart", legacyAutostartDesktopFileName), nil
}
func quoteDesktopExec(path string) string {
return strconv.Quote(path)
}
func desktopIconLine(iconPath string) string {
if strings.TrimSpace(iconPath) == "" {
return ""
}
return "Icon=" + iconPath
}
func cleanupLegacySystemdAutostart() error {
unitPath, err := legacySystemdUnitPath()
if err != nil {
return err
}
if _, err := os.Stat(unitPath); os.IsNotExist(err) {
return nil
}
// Older PySentry builds used a systemd user unit for autostart. The current
// GoSentry implementation uses XDG Autostart because it is a GUI/tray
// application and should be launched by the desktop session. Disable and
// remove the old unit so the two mechanisms do not fight or start duplicates.
_ = exec.Command("systemctl", "--user", "disable", "pysentry.service").Run()
if err := os.Remove(unitPath); err != nil && !os.IsNotExist(err) {
return err
}
_ = exec.Command("systemctl", "--user", "daemon-reload").Run()
return nil
}
func cleanupLegacyDesktopAutostart() error {
desktopPath, err := legacyAutostartDesktopPath()
if err != nil {
return err
}
// The old PySentry desktop file is removed proactively instead of tolerated
// alongside the new one. Leaving both files in place would risk duplicate
// launches or confusing status diagnostics after the rename.
if err := os.Remove(desktopPath); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
func legacyDesktopAutostartExists() bool {
desktopPath, err := legacyAutostartDesktopPath()
if err != nil {
return false
}
_, err = os.Stat(desktopPath)
return err == nil
}
func legacySystemdAutostartExists() bool {
unitPath, err := legacySystemdUnitPath()
if err != nil {
return false
}
_, err = os.Stat(unitPath)
return err == nil
}
func legacySystemdUnitPath() (string, error) {
configHome := os.Getenv("XDG_CONFIG_HOME")
if configHome == "" {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
configHome = filepath.Join(home, ".config")
}
return filepath.Join(configHome, "systemd", "user", "pysentry.service"), nil
} }
+55
View File
@@ -0,0 +1,55 @@
//go:build linux
package core
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestLinuxAutostartStartsInTray(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
executablePath := "/opt/Go Sentry/gosentry"
if err := SetAutostart(true, executablePath, "/opt/Go Sentry/gosentry.png"); 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) + " " + StartInTrayArgument
if !strings.Contains(string(data), expectedExec) {
t.Fatalf("desktop entry does not start in tray: %s", data)
}
}
func TestLinuxAutostartRemovesLegacyDesktopEntry(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
legacyPath, err := legacyAutostartDesktopPath()
if err != nil {
t.Fatalf("resolve legacy desktop path: %v", err)
}
if err := os.MkdirAll(filepath.Dir(legacyPath), 0o755); err != nil {
t.Fatalf("create legacy desktop directory: %v", err)
}
if err := os.WriteFile(legacyPath, []byte("[Desktop Entry]\nName=PySentry\n"), 0o644); err != nil {
t.Fatalf("write legacy desktop entry: %v", err)
}
if err := SetAutostart(true, "/opt/gosentry/gosentry", ""); err != nil {
t.Fatalf("enable autostart: %v", err)
}
if _, err := os.Stat(legacyPath); !os.IsNotExist(err) {
t.Fatalf("legacy desktop entry still exists or cannot be checked: %v", err)
}
}
+1 -1
View File
@@ -4,7 +4,7 @@ package core
import "fmt" import "fmt"
func SetAutostart(enabled bool, executablePath string) error { func SetAutostart(enabled bool, executablePath string, iconPath string) error {
if !enabled { if !enabled {
return nil return nil
} }
+196 -28
View File
@@ -2,48 +2,216 @@ package core
import ( import (
"fmt" "fmt"
"os"
"os/exec" "os/exec"
"path/filepath"
"strings" "strings"
) )
const autostartName = "PySentry" const autostartName = "GoSentry"
const legacyAutostartName = "PySentry"
const startupShortcutFile = autostartName + ".lnk"
func SetAutostart(enabled bool, executablePath string) error { func SetAutostart(enabled bool, executablePath string, iconPath string) error {
if enabled { // Windows autostart used to write HKCU\Run values, but that approach became
// Remove any stale entry first. This makes "uncheck, save, check, save" // brittle once paths with spaces and the "--start-in-tray" argument entered
// and even a plain "check, save" repair an old path after the executable // the picture. A Startup-folder shortcut stores target path and arguments as
// was moved or renamed for a new version. // separate structured fields, so it avoids quoting bugs and more closely
deleteCommand := exec.Command("reg.exe", "delete", `HKCU\Software\Microsoft\Windows\CurrentVersion\Run`, "/v", autostartName, "/f") // matches how a user would configure a GUI app by hand.
configureHiddenWindow(deleteCommand) if err := cleanupLegacyRegistryAutostart(); err != nil {
_ = deleteCommand.Run() return err
command := exec.Command("reg.exe", "add", `HKCU\Software\Microsoft\Windows\CurrentVersion\Run`, "/v", autostartName, "/t", "REG_SZ", "/d", fmt.Sprintf("%q", executablePath), "/f")
configureHiddenWindow(command)
return command.Run()
} }
command := exec.Command("reg.exe", "delete", `HKCU\Software\Microsoft\Windows\CurrentVersion\Run`, "/v", autostartName, "/f")
configureHiddenWindow(command) shortcutPath, err := startupShortcutPath()
_ = command.Run() if err != nil {
return nil return err
}
if enabled {
return createStartupShortcut(shortcutPath, executablePath, iconPath)
}
return removeIfExists(shortcutPath)
} }
func AutostartStatus(expectedEnabled bool, executablePath string) (bool, string) { func AutostartStatus(expectedEnabled bool, executablePath string) (bool, string) {
command := exec.Command("reg.exe", "query", `HKCU\Software\Microsoft\Windows\CurrentVersion\Run`, "/v", autostartName) shortcutPath, err := startupShortcutPath()
configureHiddenWindow(command) if err != nil {
output, err := command.Output() return false, "Startup folder cannot be resolved"
}
_, statErr := os.Stat(shortcutPath)
if !expectedEnabled { if !expectedEnabled {
if err != nil { if os.IsNotExist(statErr) {
if legacyRegistryAutostartExists() {
return false, "Legacy registry autostart exists; save settings to repair"
}
return true, "Autostart is off" return true, "Autostart is off"
} }
return false, "Autostart entry exists while setting is off" if statErr != nil {
} return false, "Autostart shortcut cannot be checked"
if err != nil { }
return false, "Autostart entry is missing" return false, "Autostart shortcut exists while setting is off"
} }
text := strings.ReplaceAll(string(output), `"`, "") if os.IsNotExist(statErr) {
if !strings.Contains(text, executablePath) { if legacyRegistryAutostartExists() {
return false, "Autostart points to another executable" return false, "Legacy registry autostart exists; save settings to repair"
}
return false, "Autostart shortcut is missing"
}
if statErr != nil {
return false, "Autostart shortcut cannot be checked"
}
actual, arguments, err := readShortcut(shortcutPath)
if err != nil {
return false, "Autostart shortcut cannot be read"
}
if !sameWindowsPath(actual, executablePath) {
return false, "Autostart shortcut points to another executable"
}
if strings.TrimSpace(arguments) != StartInTrayArgument {
return false, "Autostart shortcut does not start in tray"
} }
return true, "Autostart is configured" return true, "Autostart is configured"
} }
func startupShortcutPath() (string, error) {
appData := os.Getenv("APPDATA")
if appData == "" {
return "", fmt.Errorf("APPDATA is not set")
}
return filepath.Join(appData, "Microsoft", "Windows", "Start Menu", "Programs", "Startup", startupShortcutFile), nil
}
func createStartupShortcut(shortcutPath string, executablePath string, iconPath string) error {
if err := os.MkdirAll(filepath.Dir(shortcutPath), 0755); err != nil {
return err
}
workingDirectory := filepath.Dir(executablePath)
if iconPath == "" {
iconPath = executablePath
}
// WScript.Shell is used here deliberately instead of a third-party Go COM
// wrapper. The PowerShell bridge is not glamorous, but it is already present
// on supported Windows systems and keeps the dependency surface much smaller
// for a project that otherwise aims to stay light.
script := `$shell = New-Object -ComObject WScript.Shell; $shortcut = $shell.CreateShortcut($env:GOSENTRY_SHORTCUT_PATH); $shortcut.TargetPath = $env:GOSENTRY_TARGET_PATH; $shortcut.Arguments = $env:GOSENTRY_ARGUMENTS; $shortcut.WorkingDirectory = $env:GOSENTRY_WORKING_DIRECTORY; $shortcut.IconLocation = $env:GOSENTRY_ICON_PATH; $shortcut.Save()`
command := exec.Command("powershell.exe", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", script)
command.Env = append(os.Environ(),
"GOSENTRY_SHORTCUT_PATH="+shortcutPath,
"GOSENTRY_TARGET_PATH="+executablePath,
"GOSENTRY_ARGUMENTS="+StartInTrayArgument,
"GOSENTRY_WORKING_DIRECTORY="+workingDirectory,
"GOSENTRY_ICON_PATH="+iconPath,
)
configureHiddenWindow(command)
if output, err := command.CombinedOutput(); err != nil {
return fmt.Errorf("create startup shortcut: %w: %s", err, strings.TrimSpace(string(output)))
}
return nil
}
func readShortcut(shortcutPath string) (string, string, error) {
// Force UTF-8 before writing the path. PowerShell defaults to the system
// OEM code page (e.g. CP866 on Russian Windows). Without this override,
// [Console]::Out.Write encodes Cyrillic and other non-ASCII characters as
// OEM bytes; Go then reads them as UTF-8 and gets a different string from
// os.Executable, causing AutostartStatus to report "shortcut points to
// another executable" for any install path that contains non-ASCII chars.
// New-Object System.Text.UTF8Encoding($false) is UTF-8 without BOM.
script := `[Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false); $shell = New-Object -ComObject WScript.Shell; $shortcut = $shell.CreateShortcut($env:GOSENTRY_SHORTCUT_PATH); [Console]::Out.Write($shortcut.TargetPath + [Environment]::NewLine + $shortcut.Arguments)`
command := exec.Command("powershell.exe", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", script)
command.Env = append(os.Environ(), "GOSENTRY_SHORTCUT_PATH="+shortcutPath)
configureHiddenWindow(command)
output, err := command.CombinedOutput()
if err != nil {
return "", "", fmt.Errorf("read startup shortcut: %w: %s", err, strings.TrimSpace(string(output)))
}
lines := strings.SplitN(string(output), "\n", 2)
target := strings.TrimSpace(lines[0])
arguments := ""
if len(lines) > 1 {
arguments = strings.TrimSpace(lines[1])
}
return target, arguments, nil
}
func readShortcutTarget(shortcutPath string) (string, error) {
target, _, err := readShortcut(shortcutPath)
return target, err
}
func removeIfExists(path string) error {
err := os.Remove(path)
if err == nil || os.IsNotExist(err) {
return nil
}
return err
}
func cleanupLegacyRegistryAutostart() error {
for _, name := range []string{legacyAutostartName, autostartName} {
command := exec.Command("reg.exe", "delete", `HKCU\Software\Microsoft\Windows\CurrentVersion\Run`, "/v", name, "/f")
configureHiddenWindow(command)
_ = command.Run()
}
return nil
}
func legacyRegistryAutostartExists() bool {
for _, name := range []string{legacyAutostartName, autostartName} {
command := exec.Command("reg.exe", "query", `HKCU\Software\Microsoft\Windows\CurrentVersion\Run`, "/v", name)
configureHiddenWindow(command)
if command.Run() == nil {
return true
}
}
return false
}
func parseRegistryRunValue(output string) (string, bool) {
for _, line := range strings.Split(output, "\n") {
fields := strings.Fields(strings.TrimSpace(line))
for index, field := range fields {
if field == "REG_SZ" && index+1 < len(fields) {
value := strings.Join(fields[index+1:], " ")
value = strings.Trim(value, `"`)
return value, value != ""
}
}
}
return "", false
}
func sameWindowsPath(left string, right string) bool {
left = normalizeWindowsPath(left)
right = normalizeWindowsPath(right)
if strings.EqualFold(left, right) {
return true
}
// If the string comparison fails, compare by filesystem object identity.
// os.SameFile uses the volume serial number and file index on Windows, so
// it correctly handles cases where one path uses an NTFS 8.3 short name
// while the other uses the long name. Windows generates 8.3 names for
// directory entries that contain spaces; when the process is launched via
// a Startup-folder shortcut the OS may resolve the PIDL to the short-name
// form, so os.Executable can return a different string than WScript reads
// back from TargetPath even though both point to the same file. The same
// fallback also covers directory junction points.
leftInfo, leftErr := os.Lstat(left)
rightInfo, rightErr := os.Lstat(right)
if leftErr == nil && rightErr == nil {
return os.SameFile(leftInfo, rightInfo)
}
return false
}
func normalizeWindowsPath(p string) string {
p = strings.Trim(p, `"`)
// filepath.Clean preserves the \\?\ extended-length device path prefix that
// Windows adds for paths exceeding MAX_PATH. Strip it so the cleaned result
// compares equal to the same path without the prefix.
p = strings.TrimPrefix(p, `\\?\`)
return filepath.Clean(p)
}
+144
View File
@@ -0,0 +1,144 @@
//go:build windows
package core
import (
"os"
"path/filepath"
"syscall"
"testing"
)
func TestParseRegistryRunValue(t *testing.T) {
output := `
HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Run
GoSentry REG_SZ "D:\Apps\GoSentry\gosentry.exe"
`
value, ok := parseRegistryRunValue(output)
if !ok {
t.Fatal("expected registry value to parse")
}
if value != `D:\Apps\GoSentry\gosentry.exe` {
t.Fatalf("unexpected value: %q", value)
}
}
func TestSameWindowsPathIgnoresCaseAndQuotes(t *testing.T) {
if !sameWindowsPath(`"D:\Apps\GoSentry\gosentry.exe"`, `d:\apps\gosentry\gosentry.exe`) {
t.Fatal("expected paths to match")
}
}
func TestSameWindowsPathHandlesSpaces(t *testing.T) {
if !sameWindowsPath(`"D:\Local Git\GoSentry\gosentry.exe"`, `d:\local git\gosentry\gosentry.exe`) {
t.Fatal("expected paths with spaces to match")
}
}
func TestSameWindowsPathStripsExtendedLengthPrefix(t *testing.T) {
if !sameWindowsPath(`\\?\D:\Apps\GoSentry\gosentry.exe`, `D:\Apps\GoSentry\gosentry.exe`) {
t.Fatal("expected \\\\?\\-prefixed path to match plain path")
}
}
func TestSameWindowsPathMatchesShortNameViaFilesystem(t *testing.T) {
// Create a file inside a directory whose name contains a space. On NTFS
// systems that have 8.3 name generation enabled, Windows also assigns a
// short name to the directory (e.g. "Local~1"). WScript.Shell may return
// the long form while os.Executable returns the short form (or vice versa).
// Verify that sameWindowsPath treats both representations as equal.
tempDir := t.TempDir()
dirWithSpace := filepath.Join(tempDir, "Local Git")
if err := os.MkdirAll(dirWithSpace, 0755); err != nil {
t.Fatalf("create dir: %v", err)
}
longPath := filepath.Join(dirWithSpace, "gosentry.exe")
if err := os.WriteFile(longPath, []byte("test"), 0644); err != nil {
t.Fatalf("create file: %v", err)
}
// GetShortPathName converts the long path to its 8.3 equivalent when 8.3
// names are available; it returns the unchanged path otherwise.
p16, err := syscall.UTF16PtrFromString(longPath)
if err != nil {
t.Fatalf("UTF16PtrFromString: %v", err)
}
buf := make([]uint16, syscall.MAX_PATH)
n, err := syscall.GetShortPathName(p16, &buf[0], uint32(len(buf)))
if err != nil {
t.Skipf("GetShortPathName: %v", err)
}
shortPath := syscall.UTF16ToString(buf[:n])
if !sameWindowsPath(longPath, shortPath) {
t.Fatalf("sameWindowsPath(%q, %q) = false; want true", longPath, shortPath)
}
}
func TestStartupShortcutPathUsesUserStartupFolder(t *testing.T) {
t.Setenv("APPDATA", `C:\Users\mixem\AppData\Roaming`)
path, err := startupShortcutPath()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
expected := `C:\Users\mixem\AppData\Roaming\Microsoft\Windows\Start Menu\Programs\Startup\GoSentry.lnk`
if path != expected {
t.Fatalf("unexpected shortcut path: %q", path)
}
}
func TestCreateStartupShortcutHandlesCyrillicPath(t *testing.T) {
tempDir := t.TempDir()
shortcutPath := filepath.Join(tempDir, "GoSentry.lnk")
targetPath := filepath.Join(tempDir, "Программы и драйвера", "GoSentry", "gosentry.exe")
if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil {
t.Fatalf("create target directory: %v", err)
}
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)
}
actual, arguments, err := readShortcut(shortcutPath)
if err != nil {
t.Fatalf("read shortcut: %v", err)
}
if !sameWindowsPath(actual, targetPath) {
t.Fatalf("shortcut target mismatch: got %q want %q", actual, targetPath)
}
if arguments != StartInTrayArgument {
t.Fatalf("shortcut arguments mismatch: got %q want %q", arguments, StartInTrayArgument)
}
}
func TestCreateStartupShortcutHandlesSpaces(t *testing.T) {
tempDir := t.TempDir()
shortcutPath := filepath.Join(tempDir, "GoSentry test.lnk")
targetPath := filepath.Join(tempDir, "Program Files", "GoSentry", "gosentry.exe")
if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil {
t.Fatalf("create target directory: %v", err)
}
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)
}
actual, arguments, err := readShortcut(shortcutPath)
if err != nil {
t.Fatalf("read shortcut: %v", err)
}
if !sameWindowsPath(actual, targetPath) {
t.Fatalf("shortcut target mismatch: got %q want %q", actual, targetPath)
}
if arguments != StartInTrayArgument {
t.Fatalf("shortcut arguments mismatch: got %q want %q", arguments, StartInTrayArgument)
}
}
+60
View File
@@ -0,0 +1,60 @@
//go:build linux
package core
import (
"fmt"
"os"
"path/filepath"
)
func InstallDesktopIntegration(appID string, executablePath string, icon []byte) (string, error) {
dataHome, err := xdgDataHome()
if err != nil {
return "", err
}
// The taskbar can only show the application icon reliably when the desktop
// environment can match the window app id to an installed .desktop file and
// icon. Use the user's XDG data directory so portable builds do not need root
// access or a package manager install step.
iconPath := filepath.Join(dataHome, "icons", "hicolor", "256x256", "apps", "gosentry.png")
if err := writeUserFile(iconPath, icon, 0o644); err != nil {
return "", err
}
desktopPath := filepath.Join(dataHome, "applications", appID+".desktop")
desktopFile := fmt.Sprintf(`[Desktop Entry]
Type=Application
Name=GoSentry
Comment=GoSentry desktop scheduler
Exec=%s
Icon=%s
Terminal=false
Categories=Utility;
StartupWMClass=%s
`, quoteDesktopExec(executablePath), iconPath, appID)
if err := writeUserFile(desktopPath, []byte(desktopFile), 0o644); err != nil {
return "", err
}
return iconPath, nil
}
func xdgDataHome() (string, error) {
dataHome := os.Getenv("XDG_DATA_HOME")
if dataHome == "" {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
dataHome = filepath.Join(home, ".local", "share")
}
return dataHome, nil
}
func writeUserFile(path string, data []byte, perm os.FileMode) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
return os.WriteFile(path, data, perm)
}
+7
View File
@@ -0,0 +1,7 @@
//go:build !linux
package core
func InstallDesktopIntegration(appID string, executablePath string, icon []byte) (string, error) {
return "", nil
}
+21 -13
View File
@@ -2,7 +2,12 @@ package core
import "time" import "time"
// Config is stored in pysentry.yaml next to the program. It contains only // StartInTrayArgument is written to the Windows Startup shortcut so autostart
// can keep the scheduler running without flashing the main window. Manual
// launches omit this flag and open the normal window.
const StartInTrayArgument = "--start-in-tray"
// Config is stored in gosentry.yaml next to the program. It contains only
// application-level choices: where to read jobs from, where to write logs, and // application-level choices: where to read jobs from, where to write logs, and
// how the desktop shell should behave. // how the desktop shell should behave.
type Config struct { type Config struct {
@@ -24,20 +29,23 @@ type JobsFile struct {
// Job is the user-visible scheduled command. // Job is the user-visible scheduled command.
// //
// Fields with yaml:"-" are deliberately runtime-only. They are useful in the GUI // Fields with yaml:"-" are deliberately runtime-only. They are useful in the GUI
// while PySentry is running, but writing them to jobs.yaml would make the jobs // while GoSentry is running, but writing them to jobs.yaml would make the jobs
// file noisy and would mix durable configuration with transient execution state. // file noisy and would mix durable configuration with transient execution state.
type Job struct { type Job struct {
ID int `yaml:"id"` ID int `yaml:"id"`
Name string `yaml:"name"` Name string `yaml:"name"`
Folder string `yaml:"folder,omitempty"` Folder string `yaml:"folder,omitempty"`
Schedule string `yaml:"schedule"` Schedule string `yaml:"schedule"`
Command string `yaml:"command"` Command string `yaml:"command"`
Enabled bool `yaml:"enabled"` Arguments string `yaml:"arguments,omitempty"`
LastRun string `yaml:"-"` SuccessExitCodes string `yaml:"success_exit_codes,omitempty"`
NextRun string `yaml:"-"` StartOnly bool `yaml:"start_only,omitempty"`
LastState string `yaml:"-"` Enabled bool `yaml:"enabled"`
Logs []RunRecord `yaml:"-"` LastRun string `yaml:"-"`
Output string `yaml:"-"` NextRun string `yaml:"-"`
LastState string `yaml:"-"`
Logs []RunRecord `yaml:"-"`
Output string `yaml:"-"`
// nextDue is kept as time.Time for scheduler comparisons. The formatted // nextDue is kept as time.Time for scheduler comparisons. The formatted
// NextRun string above exists only for display in the GUI and YAML rewriting // NextRun string above exists only for display in the GUI and YAML rewriting
+6 -1
View File
@@ -8,7 +8,11 @@ import (
const ( const (
// The config file stays beside the executable so the portable build behaves // The config file stays beside the executable so the portable build behaves
// predictably: moving the program folder moves its settings with it. // predictably: moving the program folder moves its settings with it.
ConfigFileName = "pysentry.yaml" ConfigFileName = "gosentry.yaml"
// Older builds were named PySentry. Keep the old config name readable during
// the rename window so portable installations can start once and rewrite the
// settings to gosentry.yaml without manual file copying.
LegacyConfigFileName = "pysentry.yaml"
// Jobs are kept in a separate YAML file because the user can choose a // Jobs are kept in a separate YAML file because the user can choose a
// different jobs directory, while application settings remain local to the // different jobs directory, while application settings remain local to the
// installed/copied program. // installed/copied program.
@@ -25,6 +29,7 @@ type Paths struct {
JobsDir string JobsDir string
JobsPath string JobsPath string
LogsDir string LogsDir string
DesktopIcon string
} }
func ResolvePaths() (Paths, error) { func ResolvePaths() (Paths, error) {
+174 -33
View File
@@ -8,14 +8,15 @@ import (
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"runtime"
"sort" "sort"
"strconv"
"strings" "strings"
"time" "time"
"unicode" "unicode"
) )
const commandTimeout = 30 * time.Second const commandTimeout = 30 * time.Second
const commandWaitDelay = 2 * time.Second
func RunJob(ctx context.Context, job *Job, trigger string, logsDir string) RunRecord { func RunJob(ctx context.Context, job *Job, trigger string, logsDir string) RunRecord {
started := time.Now() started := time.Now()
@@ -26,30 +27,28 @@ func RunJob(ctx context.Context, job *Job, trigger string, logsDir string) RunRe
runCtx, cancel := context.WithTimeout(ctx, commandTimeout) runCtx, cancel := context.WithTimeout(ctx, commandTimeout)
defer cancel() defer cancel()
// The command is executed through the platform shell so users can type the
// same command they would test manually in cmd.exe or sh. This is less strict
// than argv-based execution, but it is the expected behavior for a cron-like
// tool that supports redirection, environment expansion, and shell builtins.
command := shellCommand(runCtx, job.Command)
configureHiddenWindow(command)
var stdout bytes.Buffer var stdout bytes.Buffer
var stderr bytes.Buffer var stderr bytes.Buffer
command.Stdout = &stdout var output string
command.Stderr = &stderr var state string
var detail string
err := command.Run() if job.StartOnly {
duration := time.Since(started).Round(time.Millisecond) invocation := jobInvocation(context.Background(), *job)
output := formatOutput(stdout.String(), stderr.String()) state, detail, output = startJobOnly(invocation, *job, started)
} else {
state := "OK" invocation := jobInvocation(runCtx, *job)
detail := fmt.Sprintf("Completed in %s", duration) command := invocation.command
if err != nil { command.WaitDelay = commandWaitDelay
state = "Failed" if invocation.hideWindow {
if errors.Is(runCtx.Err(), context.DeadlineExceeded) { configureHiddenWindow(command)
detail = fmt.Sprintf("Timed out after %s", commandTimeout)
} else {
detail = err.Error()
} }
command.Stdout = &stdout
command.Stderr = &stderr
err := command.Run()
duration := time.Since(started).Round(time.Millisecond)
output = formatOutput(stdout.String(), stderr.String())
state, detail = runStateDetail(err, runCtx.Err(), duration, *job)
} }
now := time.Now() now := time.Now()
@@ -94,7 +93,7 @@ func CleanupLogs(logsDir string, maxFiles int, maxAgeDays int) error {
var logs []logFile var logs []logFile
cutoff := time.Now().AddDate(0, 0, -maxAgeDays) cutoff := time.Now().AddDate(0, 0, -maxAgeDays)
for _, entry := range entries { for _, entry := range entries {
// Only PySentry run logs are managed here. Directories and non-.log files // Only GoSentry run logs are managed here. Directories and non-.log files
// are intentionally ignored so the user can keep notes or other artifacts // are intentionally ignored so the user can keep notes or other artifacts
// in the same folder without the cleanup policy deleting them. // in the same folder without the cleanup policy deleting them.
if entry.IsDir() || !strings.HasSuffix(strings.ToLower(entry.Name()), ".log") { if entry.IsDir() || !strings.HasSuffix(strings.ToLower(entry.Name()), ".log") {
@@ -141,8 +140,8 @@ func writeRunLog(logsDir string, job Job, trigger string, state string, detail s
// avoid characters that are invalid on Windows or awkward on shells. // avoid characters that are invalid on Windows or awkward on shells.
fileName := started.Format("20060102-150405") + "_" + sanitizeFileName(job.Name) + ".log" fileName := started.Format("20060102-150405") + "_" + sanitizeFileName(job.Name) + ".log"
path := filepath.Join(logsDir, fileName) path := filepath.Join(logsDir, fileName)
content := fmt.Sprintf("time: %s\njob_id: %d\njob_name: %s\ntrigger: %s\nstate: %s\ndetail: %s\ncommand: %s\n\n%s\n", content := fmt.Sprintf("time: %s\njob_id: %d\njob_name: %s\ntrigger: %s\nstate: %s\ndetail: %s\ncommand: %s\narguments: %s\nsuccess_exit_codes: %s\nstart_only: %t\n\n%s\n",
started.Format("2006-01-02 15:04:05"), job.ID, job.Name, trigger, state, detail, job.Command, output) started.Format("2006-01-02 15:04:05"), job.ID, job.Name, trigger, state, detail, job.Command, logArguments(job.Arguments), successExitCodesText(job), job.StartOnly, output)
if err := os.WriteFile(path, []byte(content), 0o644); err != nil { if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
return "" return ""
} }
@@ -172,15 +171,157 @@ func sanitizeFileName(name string) string {
return result return result
} }
func shellCommand(ctx context.Context, command string) *exec.Cmd { func startJobOnly(invocation commandInvocation, job Job, started time.Time) (string, string, string) {
if runtime.GOOS == "windows" { command := invocation.command
// cmd.exe /C preserves Windows users' expectations for commands such as if invocation.hideWindow {
// "dir", "copy", variable expansion, and .bat/.cmd wrappers. configureHiddenWindow(command)
return exec.CommandContext(ctx, "cmd.exe", "/C", command)
} }
// sh -c is the portable baseline for Linux builds. It keeps the runner small err := command.Start()
// and avoids a hard dependency on a larger shell such as bash. duration := time.Since(started).Round(time.Millisecond)
return exec.CommandContext(ctx, "sh", "-c", command) if err != nil {
return "Failed", fmt.Sprintf("%T: %v", err, err), startOnlyOutput(job, 0)
}
pid := command.Process.Pid
if releaseErr := command.Process.Release(); releaseErr != nil {
return "Failed", fmt.Sprintf("process started with pid %d, but release failed: %T: %v", pid, releaseErr, releaseErr), startOnlyOutput(job, pid)
}
return "OK", fmt.Sprintf("Started in %s (pid %d); not waiting for process exit", duration, pid), startOnlyOutput(job, pid)
}
func startOnlyOutput(job Job, pid int) string {
var builder strings.Builder
builder.WriteString("status:\n")
if pid > 0 {
builder.WriteString(fmt.Sprintf("Started process pid %d. GoSentry is not waiting for it to exit.\n\n", pid))
} else {
builder.WriteString("Process did not start.\n\n")
}
builder.WriteString("command:\n")
builder.WriteString(job.Command + "\n\n")
builder.WriteString("arguments:\n")
builder.WriteString(logArguments(job.Arguments))
builder.WriteString("\n\nstart_only:\ntrue")
return builder.String()
}
func runStateDetail(err error, runErr error, duration time.Duration, job Job) (string, string) {
if err == nil {
return "OK", fmt.Sprintf("Completed in %s (exit code 0)", duration)
}
if errors.Is(runErr, context.DeadlineExceeded) {
return "Failed", fmt.Sprintf("Timed out after %s", commandTimeout)
}
if errors.Is(err, exec.ErrWaitDelay) {
return "OK", fmt.Sprintf("Completed; output capture stopped after %s because a child process kept the stream open", commandWaitDelay)
}
var exitError *exec.ExitError
if errors.As(err, &exitError) {
exitCode := exitError.ExitCode()
if acceptedExitCode(exitCode, job.SuccessExitCodes) {
return "OK", fmt.Sprintf("Completed in %s with accepted exit code %d", duration, exitCode)
}
return "Failed", fmt.Sprintf("Exit code %d is not in success_exit_codes (%s)", exitCode, successExitCodesText(job))
}
return "Failed", fmt.Sprintf("%T: %v", err, err)
}
func acceptedExitCode(exitCode int, successExitCodes string) bool {
for _, accepted := range parseExitCodes(successExitCodes) {
if exitCode == accepted {
return true
}
}
return false
}
func parseExitCodes(value string) []int {
value = strings.TrimSpace(value)
if value == "" {
return []int{0}
}
fields := strings.FieldsFunc(value, func(r rune) bool {
return r == ',' || r == ';' || r == ' ' || r == '\t' || r == '\n' || r == '\r'
})
result := make([]int, 0, len(fields))
seen := map[int]bool{}
for _, field := range fields {
code, err := strconv.Atoi(strings.TrimSpace(field))
if err != nil || seen[code] {
continue
}
seen[code] = true
result = append(result, code)
}
if len(result) == 0 {
return []int{0}
}
return result
}
func successExitCodesText(job Job) string {
codes := parseExitCodes(job.SuccessExitCodes)
parts := make([]string, 0, len(codes))
for _, code := range codes {
parts = append(parts, strconv.Itoa(code))
}
return strings.Join(parts, ",")
}
type commandInvocation struct {
command *exec.Cmd
hideWindow bool
}
func jobInvocation(ctx context.Context, job Job) commandInvocation {
command := strings.TrimSpace(job.Command)
arguments := commandArguments(job.Arguments)
if len(arguments) > 0 || commandPathExists(command) {
return commandInvocation{
command: exec.CommandContext(ctx, unquoteCommandPath(command), arguments...),
hideWindow: false,
}
}
// Shell mode remains for existing jobs and for commands that intentionally
// use builtins, redirection, variables, or chained command syntax.
return commandInvocation{
command: shellCommand(ctx, command),
hideWindow: true,
}
}
func commandArguments(arguments string) []string {
var result []string
for _, line := range strings.FieldsFunc(arguments, func(r rune) bool {
return r == '\n' || r == '\r'
}) {
line = strings.TrimSpace(line)
if line != "" {
result = append(result, line)
}
}
return result
}
func commandPathExists(command string) bool {
command = unquoteCommandPath(strings.TrimSpace(command))
if command == "" {
return false
}
info, err := os.Stat(command)
return err == nil && !info.IsDir()
}
func unquoteCommandPath(command string) string {
return strings.Trim(strings.TrimSpace(command), `"`)
}
func logArguments(arguments string) string {
if strings.TrimSpace(arguments) == "" {
return "<empty>"
}
return strings.ReplaceAll(strings.TrimSpace(arguments), "\r\n", "\n")
} }
func formatOutput(stdout string, stderr string) string { func formatOutput(stdout string, stderr string) string {
+10 -1
View File
@@ -2,7 +2,16 @@
package core package core
import "os/exec" import (
"context"
"os/exec"
)
func shellCommand(ctx context.Context, command string) *exec.Cmd {
// sh -c is the portable baseline for Linux builds. It keeps the runner small
// and avoids a hard dependency on a larger shell such as bash.
return exec.CommandContext(ctx, "sh", "-c", command)
}
func configureHiddenWindow(command *exec.Cmd) { func configureHiddenWindow(command *exec.Cmd) {
// Non-Windows platforms do not create a new console window for sh -c from a // Non-Windows platforms do not create a new console window for sh -c from a
+371
View File
@@ -4,10 +4,145 @@ import (
"context" "context"
"os" "os"
"path/filepath" "path/filepath"
"runtime"
"strings" "strings"
"testing" "testing"
"time"
) )
func TestRunJobLogFileAllHeaders(t *testing.T) {
logsDir := t.TempDir()
job := Job{
ID: 99,
Name: "Log Header Test",
Command: echoCommand("header test output"),
SuccessExitCodes: "0,1",
}
record := RunJob(context.Background(), &job, "Schedule", logsDir)
if record.LogFile == "" {
t.Fatal("expected log file to be written")
}
data, err := os.ReadFile(record.LogFile)
if err != nil {
t.Fatal(err)
}
content := string(data)
for _, want := range []string{
"job_id: 99",
"job_name: Log Header Test",
"trigger: Schedule",
"state: OK",
"detail: ",
"command: " + job.Command,
"arguments: <empty>",
"success_exit_codes: 0,1",
"start_only: false",
"stdout:",
"stderr:",
} {
if !strings.Contains(content, want) {
t.Errorf("log file missing %q:\n%s", want, content)
}
}
// The time header must use the documented format.
for _, line := range strings.Split(content, "\n") {
if strings.HasPrefix(line, "time: ") {
ts := strings.TrimPrefix(line, "time: ")
if _, err := time.Parse("2006-01-02 15:04:05", ts); err != nil {
t.Errorf("time header %q does not match format 2006-01-02 15:04:05: %v", ts, err)
}
break
}
}
}
func TestRunJobRecordFields(t *testing.T) {
job := Job{
ID: 55,
Name: "Record Fields Test",
Command: echoCommand("record field check"),
}
record := RunJob(context.Background(), &job, "Schedule", t.TempDir())
if record.JobID != job.ID {
t.Errorf("JobID: got %d, want %d", record.JobID, job.ID)
}
if record.JobName != job.Name {
t.Errorf("JobName: got %q, want %q", record.JobName, job.Name)
}
if record.Trigger != "Schedule" {
t.Errorf("Trigger: got %q, want 'Schedule'", record.Trigger)
}
if record.State != "OK" {
t.Errorf("State: got %q, want 'OK' (detail: %q)", record.State, record.Detail)
}
if record.LogFile == "" {
t.Error("LogFile should be a non-empty path")
}
if _, err := time.Parse("2006-01-02 15:04:05", record.Time); err != nil {
t.Errorf("Time format wrong, got %q: %v", record.Time, err)
}
if !strings.Contains(record.Output, "stdout:") {
t.Errorf("Output missing 'stdout:', got:\n%s", record.Output)
}
if !strings.Contains(record.Output, "stderr:") {
t.Errorf("Output missing 'stderr:', got:\n%s", record.Output)
}
}
func TestFormatOutput(t *testing.T) {
got := formatOutput("hello world", "some error")
want := "stdout:\nhello world\n\nstderr:\nsome error"
if got != want {
t.Errorf("formatOutput:\ngot: %q\nwant: %q", got, want)
}
}
func TestFormatOutputEmptyStreams(t *testing.T) {
got := formatOutput("", "")
if !strings.Contains(got, "stdout:\n<empty>") {
t.Errorf("empty stdout should show <empty>, got:\n%s", got)
}
if !strings.Contains(got, "stderr:\n<empty>") {
t.Errorf("empty stderr should show <empty>, got:\n%s", got)
}
}
func TestLogArguments(t *testing.T) {
cases := []struct{ input, want string }{
{"", "<empty>"},
{" ", "<empty>"},
{"--flag", "--flag"},
{"--flag\r\n--value", "--flag\n--value"},
{"--flag\n--value", "--flag\n--value"},
}
for _, tc := range cases {
if got := logArguments(tc.input); got != tc.want {
t.Errorf("logArguments(%q) = %q, want %q", tc.input, got, tc.want)
}
}
}
func TestSanitizeFileName(t *testing.T) {
cases := []struct{ input, want string }{
{"Hello Test", "Hello_Test"},
{"job-1_ok", "job-1_ok"},
{"!!!", "job"},
{"", "job"},
{"A/B:C", "A_B_C"},
}
for _, tc := range cases {
if got := sanitizeFileName(tc.input); got != tc.want {
t.Errorf("sanitizeFileName(%q) = %q, want %q", tc.input, got, tc.want)
}
}
}
func TestRunJobWritesLogFile(t *testing.T) { func TestRunJobWritesLogFile(t *testing.T) {
logsDir := t.TempDir() logsDir := t.TempDir()
job := Job{ job := Job{
@@ -38,3 +173,239 @@ func TestRunJobWritesLogFile(t *testing.T) {
} }
} }
} }
func TestRunJobRunsQuotedWindowsExecutable(t *testing.T) {
if runtime.GOOS != "windows" {
t.Skip("Windows cmd.exe quoting only")
}
logsDir := t.TempDir()
job := Job{
ID: 43,
Name: "Quoted Windows Command",
Command: `"C:\Windows\System32\cmd.exe" /C echo quoted command ok`,
}
record := RunJob(context.Background(), &job, "Manual", logsDir)
if record.State != "OK" {
t.Fatalf("expected quoted command to run, got state %q detail %q output:\n%s", record.State, record.Detail, record.Output)
}
if !strings.Contains(record.Output, "quoted command ok") {
t.Fatalf("expected command output, got:\n%s", record.Output)
}
}
func TestRunJobRunsUnquotedWindowsProgramPathWithSpaces(t *testing.T) {
if runtime.GOOS != "windows" {
t.Skip("Windows cmd.exe quoting only")
}
logsDir := t.TempDir()
scriptDir := filepath.Join(t.TempDir(), "Program Files", "GoSentry Test")
if err := os.MkdirAll(scriptDir, 0o755); err != nil {
t.Fatal(err)
}
scriptPath := filepath.Join(scriptDir, "hello.cmd")
if err := os.WriteFile(scriptPath, []byte("@echo off\r\necho unquoted command ok\r\n"), 0o755); err != nil {
t.Fatal(err)
}
job := Job{
ID: 44,
Name: "Unquoted Windows Command",
Command: scriptPath,
}
record := RunJob(context.Background(), &job, "Manual", logsDir)
if record.State != "OK" {
t.Fatalf("expected unquoted command path to run, got state %q detail %q output:\n%s", record.State, record.Detail, record.Output)
}
if !strings.Contains(record.Output, "unquoted command ok") {
t.Fatalf("expected command output, got:\n%s", record.Output)
}
}
func TestRunJobRunsWindowsCommandWithSeparateArguments(t *testing.T) {
if runtime.GOOS != "windows" {
t.Skip("Windows command arguments only")
}
logsDir := t.TempDir()
job := Job{
ID: 45,
Name: "Separate Arguments",
Command: `C:\Windows\System32\cmd.exe`,
Arguments: "/C\necho separate arguments ok",
}
record := RunJob(context.Background(), &job, "Manual", logsDir)
if record.State != "OK" {
t.Fatalf("expected separate arguments to run, got state %q detail %q output:\n%s", record.State, record.Detail, record.Output)
}
if !strings.Contains(record.Output, "separate arguments ok") {
t.Fatalf("expected command output, got:\n%s", record.Output)
}
}
func TestRunJobAcceptsConfiguredExitCode(t *testing.T) {
command := `sh -c 'exit 1'`
if runtime.GOOS == "windows" {
command = `C:\Windows\System32\cmd.exe`
}
job := Job{
ID: 46,
Name: "Accepted Exit Code",
Command: command,
SuccessExitCodes: "0,1",
}
if runtime.GOOS == "windows" {
job.Arguments = "/C\nexit /b 1"
}
record := RunJob(context.Background(), &job, "Manual", t.TempDir())
if record.State != "OK" {
t.Fatalf("expected accepted exit code to be OK, got state %q detail %q", record.State, record.Detail)
}
if !strings.Contains(record.Detail, "accepted exit code 1") {
t.Fatalf("expected accepted exit code detail, got %q", record.Detail)
}
}
func TestRunJobRejectsUnconfiguredExitCode(t *testing.T) {
command := `sh -c 'exit 1'`
if runtime.GOOS == "windows" {
command = `C:\Windows\System32\cmd.exe`
}
job := Job{
ID: 47,
Name: "Rejected Exit Code",
Command: command,
SuccessExitCodes: "0",
}
if runtime.GOOS == "windows" {
job.Arguments = "/C\nexit /b 1"
}
record := RunJob(context.Background(), &job, "Manual", t.TempDir())
if record.State != "Failed" {
t.Fatalf("expected rejected exit code to fail, got state %q detail %q", record.State, record.Detail)
}
if !strings.Contains(record.Detail, "Exit code 1") {
t.Fatalf("expected exit code detail, got %q", record.Detail)
}
}
func TestRunJobStartOnlyDoesNotWaitForExitCode(t *testing.T) {
command := "sh"
arguments := "-c\nexit 7"
if runtime.GOOS == "windows" {
command = `C:\Windows\System32\cmd.exe`
arguments = "/C\nexit /b 7"
}
job := Job{
ID: 48,
Name: "Start Only",
Command: command,
Arguments: arguments,
StartOnly: true,
}
record := RunJob(context.Background(), &job, "Manual", t.TempDir())
if record.State != "OK" {
t.Fatalf("expected start-only job to be OK after launch, got state %q detail %q", record.State, record.Detail)
}
if !strings.Contains(record.Detail, "not waiting for process exit") {
t.Fatalf("expected start-only detail, got %q", record.Detail)
}
if !strings.Contains(record.Output, "start_only:\ntrue") {
t.Fatalf("expected start-only output, got:\n%s", record.Output)
}
}
func TestRunJobStartOnlyReportsStartFailure(t *testing.T) {
job := Job{
ID: 49,
Name: "Missing Start Only",
Command: "definitely-missing-gosentry-command",
Arguments: "--force-direct-start",
StartOnly: true,
}
record := RunJob(context.Background(), &job, "Manual", t.TempDir())
if record.State != "Failed" {
t.Fatalf("expected missing start-only command to fail, got state %q detail %q", record.State, record.Detail)
}
if !strings.Contains(record.Output, "Process did not start") {
t.Fatalf("expected start failure output, got:\n%s", record.Output)
}
}
func TestParseExitCodes(t *testing.T) {
got := parseExitCodes("0, 1;2\n3")
want := []int{0, 1, 2, 3}
if len(got) != len(want) {
t.Fatalf("expected %v, got %v", want, got)
}
for index := range want {
if got[index] != want[index] {
t.Fatalf("expected %v, got %v", want, got)
}
}
}
func TestDirectCommandDoesNotHideWindow(t *testing.T) {
if runtime.GOOS != "windows" {
t.Skip("Windows window visibility only")
}
invocation := jobInvocation(context.Background(), Job{
Command: `C:\Windows\System32\cmd.exe`,
Arguments: "/C\necho visible direct process",
})
if invocation.hideWindow {
t.Fatal("direct command should not request hidden startup window")
}
}
func TestShellCommandHidesWindow(t *testing.T) {
if runtime.GOOS != "windows" {
t.Skip("Windows window visibility only")
}
invocation := jobInvocation(context.Background(), Job{Command: "echo hidden shell process"})
if !invocation.hideWindow {
t.Fatal("shell command should request hidden startup window")
}
configureHiddenWindow(invocation.command)
if invocation.command.SysProcAttr == nil || !invocation.command.SysProcAttr.HideWindow {
t.Fatal("expected shell command to be hidden")
}
}
func TestShellCommandUsesWindowsSafeQuoting(t *testing.T) {
if runtime.GOOS != "windows" {
t.Skip("Windows cmd.exe quoting only")
}
command := shellCommand(context.Background(), `"C:\Program Files\FreeFileSync\FreeFileSync.exe" "D:\Local\Programs\FreeFileSync\Jobs\Auto.ffs_batch"`)
configureHiddenWindow(command)
want := `cmd.exe /S /C ""C:\Program Files\FreeFileSync\FreeFileSync.exe" "D:\Local\Programs\FreeFileSync\Jobs\Auto.ffs_batch""`
if command.SysProcAttr == nil {
t.Fatal("expected SysProcAttr")
}
if command.SysProcAttr.CmdLine != want {
t.Fatalf("expected command line %q, got %q", want, command.SysProcAttr.CmdLine)
}
}
func TestWindowsShellCommandLineQuotesUnquotedProgramPath(t *testing.T) {
if runtime.GOOS != "windows" {
t.Skip("Windows cmd.exe quoting only")
}
got := windowsShellCommandLine(`C:\Program Files\Joplin\Joplin.exe --profile "D:\Joplin Profile"`)
want := `cmd.exe /S /C ""C:\Program Files\Joplin\Joplin.exe" --profile "D:\Joplin Profile""`
if got != want {
t.Fatalf("expected command line %q, got %q", want, got)
}
}
+57 -4
View File
@@ -1,16 +1,69 @@
package core package core
import ( import (
"context"
"os/exec" "os/exec"
"strings"
"syscall" "syscall"
"unicode"
) )
func shellCommand(ctx context.Context, command string) *exec.Cmd {
// cmd.exe keeps Windows users' expectations for commands such as "dir",
// "copy", variable expansion, redirection, and .bat/.cmd wrappers.
//
// Go's normal Windows argument escaping turns embedded quotes into literal
// backslash-quote sequences for cmd.exe. Supplying the raw command line keeps
// commands like `"C:\Program Files\App\App.exe" "D:\file.txt"` executable.
result := exec.CommandContext(ctx, "cmd.exe")
result.SysProcAttr = &syscall.SysProcAttr{CmdLine: windowsShellCommandLine(command)}
return result
}
func windowsShellCommandLine(command string) string {
return `cmd.exe /S /C "` + quoteLeadingWindowsProgramPath(command) + `"`
}
func quoteLeadingWindowsProgramPath(command string) string {
trimmed := strings.TrimLeftFunc(command, unicode.IsSpace)
leadingWhitespace := command[:len(command)-len(trimmed)]
if trimmed == "" || strings.HasPrefix(trimmed, `"`) || !startsWithWindowsRootedPath(trimmed) {
return command
}
lower := strings.ToLower(trimmed)
for _, extension := range []string{".exe", ".cmd", ".bat", ".com"} {
index := strings.Index(lower, extension)
if index < 0 {
continue
}
pathEnd := index + len(extension)
programPath := trimmed[:pathEnd]
if !strings.ContainsFunc(programPath, unicode.IsSpace) {
return command
}
return leadingWhitespace + `"` + programPath + `"` + trimmed[pathEnd:]
}
return command
}
func startsWithWindowsRootedPath(command string) bool {
if strings.HasPrefix(command, `\\`) {
return true
}
return len(command) >= 3 &&
((command[0] >= 'A' && command[0] <= 'Z') || (command[0] >= 'a' && command[0] <= 'z')) &&
command[1] == ':' &&
(command[2] == '\\' || command[2] == '/')
}
func configureHiddenWindow(command *exec.Cmd) { func configureHiddenWindow(command *exec.Cmd) {
// PySentry is a GUI scheduler, so child commands should not flash a console // GoSentry is a GUI scheduler, so child commands should not flash a console
// window on Windows. CREATE_NO_WINDOW keeps cmd.exe and simple console tools // window on Windows. CREATE_NO_WINDOW keeps cmd.exe and simple console tools
// quiet while stdout/stderr are still captured through pipes. // quiet while stdout/stderr are still captured through pipes.
command.SysProcAttr = &syscall.SysProcAttr{ if command.SysProcAttr == nil {
CreationFlags: 0x08000000, command.SysProcAttr = &syscall.SysProcAttr{}
HideWindow: true,
} }
command.SysProcAttr.CreationFlags |= 0x08000000
command.SysProcAttr.HideWindow = true
} }
+20 -1
View File
@@ -2,6 +2,7 @@ package core
import ( import (
"context" "context"
"fmt"
"strings" "strings"
"sync" "sync"
"time" "time"
@@ -148,6 +149,7 @@ func (s *Scheduler) startRunLocked(index int, trigger string) bool {
jobCopy := *job jobCopy := *job
job.LastState = "Running" job.LastState = "Running"
job.NextRun = "Running" job.NextRun = "Running"
job.Output = runningOutput(jobCopy, trigger, time.Now())
job.nextDue = time.Time{} job.nextDue = time.Time{}
_ = s.store.SaveJobs(*s.jobs) _ = s.store.SaveJobs(*s.jobs)
@@ -185,6 +187,23 @@ func (s *Scheduler) findJobByIDLocked(id int) *Job {
return nil return nil
} }
func runningOutput(job Job, trigger string, started time.Time) string {
var builder strings.Builder
builder.WriteString("status:\n")
builder.WriteString("Running since " + started.Format("2006-01-02 15:04:05") + "\n\n")
builder.WriteString("trigger:\n")
builder.WriteString(trigger + "\n\n")
builder.WriteString("command:\n")
builder.WriteString(job.Command + "\n\n")
builder.WriteString("arguments:\n")
builder.WriteString(logArguments(job.Arguments))
builder.WriteString("\n\nsuccess_exit_codes:\n")
builder.WriteString(successExitCodesText(job))
builder.WriteString("\n\nstart_only:\n")
builder.WriteString(fmt.Sprintf("%t", job.StartOnly))
return builder.String()
}
func (s *Scheduler) resetNextRuns(now time.Time) { func (s *Scheduler) resetNextRuns(now time.Time) {
for index := range *s.jobs { for index := range *s.jobs {
job := &(*s.jobs)[index] job := &(*s.jobs)[index]
@@ -222,7 +241,7 @@ func nextRunTime(schedule string, from time.Time) (time.Time, bool) {
} }
return from.Add(interval), true return from.Add(interval), true
} }
// Standard five-field cron keeps PySentry compatible with the mental model // Standard five-field cron keeps GoSentry compatible with the mental model
// users already know from Unix cron, while robfig/cron handles edge cases // users already know from Unix cron, while robfig/cron handles edge cases
// such as ranges, steps, and day-of-week names. // such as ranges, steps, and day-of-week names.
parsed, err := cronParser.Parse(schedule) parsed, err := cronParser.Parse(schedule)
+79
View File
@@ -1,10 +1,65 @@
package core package core
import ( import (
"strings"
"testing" "testing"
"time" "time"
) )
func TestNextRunTimeRejectsInvalidSchedules(t *testing.T) {
from := time.Date(2026, 6, 14, 12, 0, 0, 0, time.UTC)
cases := []struct {
schedule string
desc string
}{
{"", "empty string"},
{" ", "whitespace only"},
{"@every", "bare @every without duration"},
{"@every xyz", "invalid @every duration string"},
{"@every -1s", "negative @every duration"},
{"@every 0s", "zero @every duration"},
{"not-a-cron", "invalid cron expression"},
{"60 * * * *", "cron minute out of range"},
}
for _, tc := range cases {
_, ok := nextRunTime(tc.schedule, from)
if ok {
t.Errorf("nextRunTime(%q) [%s]: expected false, got true", tc.schedule, tc.desc)
}
}
}
func TestPrepareNextRunSetsDisplayString(t *testing.T) {
jobs := []Job{{Schedule: "*/5 * * * *", Enabled: true}}
s := &Scheduler{jobs: &jobs}
from := time.Date(2026, 6, 14, 12, 3, 0, 0, time.UTC)
s.prepareNextRun(&jobs[0], from)
want := "2026-06-14 12:05:00"
if jobs[0].NextRun != want {
t.Errorf("NextRun: got %q, want %q", jobs[0].NextRun, want)
}
wantDue := time.Date(2026, 6, 14, 12, 5, 0, 0, time.UTC)
if !jobs[0].nextDue.Equal(wantDue) {
t.Errorf("nextDue: got %v, want %v", jobs[0].nextDue, wantDue)
}
}
func TestPrepareNextRunSetsInvalidScheduleLabel(t *testing.T) {
jobs := []Job{{Schedule: "not-a-cron", Enabled: true}}
s := &Scheduler{jobs: &jobs}
s.prepareNextRun(&jobs[0], time.Now())
if jobs[0].NextRun != "Invalid schedule" {
t.Errorf("NextRun: got %q, want 'Invalid schedule'", jobs[0].NextRun)
}
if !jobs[0].nextDue.IsZero() {
t.Errorf("nextDue should be zero for invalid schedule, got %v", jobs[0].nextDue)
}
}
func TestNextRunTimeSupportsEvery(t *testing.T) { func TestNextRunTimeSupportsEvery(t *testing.T) {
from := time.Date(2026, 6, 14, 12, 0, 0, 0, time.UTC) from := time.Date(2026, 6, 14, 12, 0, 0, 0, time.UTC)
next, ok := nextRunTime("@every 10s", from) next, ok := nextRunTime("@every 10s", from)
@@ -27,3 +82,27 @@ func TestNextRunTimeSupportsCron(t *testing.T) {
t.Fatalf("expected %s, got %s", want, next) t.Fatalf("expected %s, got %s", want, next)
} }
} }
func TestRunningOutputIncludesInvocation(t *testing.T) {
started := time.Date(2026, 6, 17, 23, 40, 0, 0, time.Local)
job := Job{
Name: "Backup",
Command: `C:\Program Files\FreeFileSync\FreeFileSync.exe`,
Arguments: `D:\Local\Jobs\Auto.ffs_batch`,
SuccessExitCodes: "0,1",
}
output := runningOutput(job, "Manual", started)
for _, want := range []string{
"Running since 2026-06-17 23:40:00",
"Manual",
job.Command,
job.Arguments,
"0,1",
"start_only",
} {
if !strings.Contains(output, want) {
t.Fatalf("expected running output to contain %q, got:\n%s", want, output)
}
}
}
+26 -6
View File
@@ -7,7 +7,7 @@ import (
"runtime" "runtime"
"strings" "strings"
"gopkg.in/yaml.v3" "go.yaml.in/yaml/v4"
) )
type Store struct { type Store struct {
@@ -77,11 +77,26 @@ func loadOrCreateConfig(paths Paths) (Config, error) {
NotifyOnFailure: true, NotifyOnFailure: true,
} }
if _, err := os.Stat(paths.ConfigPath); errors.Is(err, os.ErrNotExist) { configPath := paths.ConfigPath
if _, err := os.Stat(configPath); errors.Is(err, os.ErrNotExist) {
legacyPath := filepath.Join(paths.AppDir, LegacyConfigFileName)
if _, legacyErr := os.Stat(legacyPath); legacyErr == nil {
// The rename from PySentry to GoSentry changed the preferred config
// filename. Read the old file once if it is still present so portable
// installs continue to start without a manual migration step. The
// caller later saves the loaded config back through SaveConfig, which
// naturally rewrites it under gosentry.yaml.
configPath = legacyPath
} else {
return config, writeYAML(paths.ConfigPath, config)
}
}
if _, err := os.Stat(configPath); errors.Is(err, os.ErrNotExist) {
return config, writeYAML(paths.ConfigPath, config) return config, writeYAML(paths.ConfigPath, config)
} }
data, err := os.ReadFile(paths.ConfigPath) data, err := os.ReadFile(configPath)
if err != nil { if err != nil {
return Config{}, err return Config{}, err
} }
@@ -146,7 +161,12 @@ func normalizeJobs(jobs []Job) {
if strings.TrimSpace(job.Command) == "" { if strings.TrimSpace(job.Command) == "" {
// An empty command would fail in a confusing way. A safe echo command // An empty command would fail in a confusing way. A safe echo command
// gives the user something observable and harmless instead. // gives the user something observable and harmless instead.
job.Command = echoCommand("PySentry job ran") job.Command = echoCommand("GoSentry job ran")
}
job.Arguments = strings.TrimSpace(job.Arguments)
job.SuccessExitCodes = strings.TrimSpace(job.SuccessExitCodes)
if job.SuccessExitCodes == "" {
job.SuccessExitCodes = "0"
} }
if job.LastRun == "" { if job.LastRun == "" {
job.LastRun = "Never" job.LastRun = "Never"
@@ -209,7 +229,7 @@ func defaultJobs() []Job {
Name: "Hello scheduler", Name: "Hello scheduler",
Folder: "Examples", Folder: "Examples",
Schedule: "@every 1m", Schedule: "@every 1m",
Command: echoCommand("PySentry test job: scheduler is alive"), Command: echoCommand("GoSentry test job: scheduler is alive"),
Enabled: true, Enabled: true,
}, },
{ {
@@ -217,7 +237,7 @@ func defaultJobs() []Job {
Name: "Write timestamp", Name: "Write timestamp",
Folder: "Examples", Folder: "Examples",
Schedule: "*/1 * * * *", Schedule: "*/1 * * * *",
Command: echoCommand("PySentry test job: timestamp command ran"), Command: echoCommand("GoSentry test job: timestamp command ran"),
Enabled: true, Enabled: true,
}, },
{ {
+167 -1
View File
@@ -1,12 +1,178 @@
package core package core
import ( import (
"path/filepath"
"strings" "strings"
"testing" "testing"
"gopkg.in/yaml.v3" "go.yaml.in/yaml/v4"
) )
func TestJobsRoundTrip(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "jobs.yaml")
original := []Job{
{
ID: 7,
Name: "Backup data",
Folder: "Maintenance",
Schedule: "0 2 * * *",
Command: "/usr/bin/backup",
Arguments: "--compress\n--verbose",
SuccessExitCodes: "0,1",
StartOnly: true,
Enabled: true,
},
}
if err := writeYAML(path, JobsFile{Jobs: original}); err != nil {
t.Fatal(err)
}
got, err := loadOrCreateJobs(path)
if err != nil {
t.Fatal(err)
}
if len(got) != 1 {
t.Fatalf("expected 1 job, got %d", len(got))
}
g, w := got[0], original[0]
if g.ID != w.ID {
t.Errorf("ID: got %d, want %d", g.ID, w.ID)
}
if g.Name != w.Name {
t.Errorf("Name: got %q, want %q", g.Name, w.Name)
}
if g.Folder != w.Folder {
t.Errorf("Folder: got %q, want %q", g.Folder, w.Folder)
}
if g.Schedule != w.Schedule {
t.Errorf("Schedule: got %q, want %q", g.Schedule, w.Schedule)
}
if g.Command != w.Command {
t.Errorf("Command: got %q, want %q", g.Command, w.Command)
}
if g.Arguments != w.Arguments {
t.Errorf("Arguments: got %q, want %q", g.Arguments, w.Arguments)
}
if g.SuccessExitCodes != w.SuccessExitCodes {
t.Errorf("SuccessExitCodes: got %q, want %q", g.SuccessExitCodes, w.SuccessExitCodes)
}
if g.StartOnly != w.StartOnly {
t.Errorf("StartOnly: got %v, want %v", g.StartOnly, w.StartOnly)
}
if g.Enabled != w.Enabled {
t.Errorf("Enabled: got %v, want %v", g.Enabled, w.Enabled)
}
// Runtime fields must not survive the save→load round-trip.
if g.LastRun != "" {
t.Errorf("LastRun should be empty after load, got %q", g.LastRun)
}
if g.LastState != "" {
t.Errorf("LastState should be empty after load, got %q", g.LastState)
}
if g.Logs != nil {
t.Errorf("Logs should be nil after load, got %v", g.Logs)
}
}
func TestConfigRoundTrip(t *testing.T) {
dir := t.TempDir()
paths := Paths{
AppDir: dir,
ConfigPath: filepath.Join(dir, ConfigFileName),
}
want := Config{
JobsDir: "/custom/jobs",
LogsDir: "/custom/logs",
MaxLogFiles: 50,
MaxLogAgeDays: 14,
StartOnLogin: true,
KeepRunningInTray: false,
NotifyOnFailure: false,
}
if err := writeYAML(paths.ConfigPath, want); err != nil {
t.Fatal(err)
}
got, err := loadOrCreateConfig(paths)
if err != nil {
t.Fatal(err)
}
if got.JobsDir != want.JobsDir {
t.Errorf("JobsDir: got %q, want %q", got.JobsDir, want.JobsDir)
}
if got.LogsDir != want.LogsDir {
t.Errorf("LogsDir: got %q, want %q", got.LogsDir, want.LogsDir)
}
if got.MaxLogFiles != want.MaxLogFiles {
t.Errorf("MaxLogFiles: got %d, want %d", got.MaxLogFiles, want.MaxLogFiles)
}
if got.MaxLogAgeDays != want.MaxLogAgeDays {
t.Errorf("MaxLogAgeDays: got %d, want %d", got.MaxLogAgeDays, want.MaxLogAgeDays)
}
if got.StartOnLogin != want.StartOnLogin {
t.Errorf("StartOnLogin: got %v, want %v", got.StartOnLogin, want.StartOnLogin)
}
if got.KeepRunningInTray != want.KeepRunningInTray {
t.Errorf("KeepRunningInTray: got %v, want %v", got.KeepRunningInTray, want.KeepRunningInTray)
}
if got.NotifyOnFailure != want.NotifyOnFailure {
t.Errorf("NotifyOnFailure: got %v, want %v", got.NotifyOnFailure, want.NotifyOnFailure)
}
}
func TestNormalizeJobsFillsDefaults(t *testing.T) {
jobs := []Job{
{Enabled: true},
{Enabled: false},
{ID: 5, Name: "Kept", Schedule: "*/10 * * * *", SuccessExitCodes: "0,1", Enabled: true},
}
normalizeJobs(jobs)
// Blank enabled job gets default name, schedule, command, exit codes, and runtime state.
if jobs[0].ID != 1 {
t.Errorf("first auto ID: got %d, want 1", jobs[0].ID)
}
if jobs[0].Name != "Untitled job" {
t.Errorf("default name: got %q, want 'Untitled job'", jobs[0].Name)
}
if jobs[0].Schedule != "@every 1m" {
t.Errorf("default schedule: got %q, want '@every 1m'", jobs[0].Schedule)
}
if jobs[0].SuccessExitCodes != "0" {
t.Errorf("default exit codes: got %q, want '0'", jobs[0].SuccessExitCodes)
}
if jobs[0].LastState != "Ready" {
t.Errorf("enabled job state: got %q, want 'Ready'", jobs[0].LastState)
}
if jobs[0].NextRun != "After start" {
t.Errorf("enabled job next run: got %q, want 'After start'", jobs[0].NextRun)
}
// Disabled job is marked Paused.
if jobs[1].LastState != "Paused" {
t.Errorf("disabled job state: got %q, want 'Paused'", jobs[1].LastState)
}
if jobs[1].NextRun != "Paused" {
t.Errorf("disabled job next run: got %q, want 'Paused'", jobs[1].NextRun)
}
// Pre-set fields survive normalization unchanged.
if jobs[2].ID != 5 {
t.Errorf("pre-set ID should be preserved: got %d, want 5", jobs[2].ID)
}
if jobs[2].SuccessExitCodes != "0,1" {
t.Errorf("pre-set exit codes should be preserved: got %q, want '0,1'", jobs[2].SuccessExitCodes)
}
}
func TestJobsYAMLDoesNotPersistRuntimeNoise(t *testing.T) { func TestJobsYAMLDoesNotPersistRuntimeNoise(t *testing.T) {
jobs := []Job{ jobs := []Job{
{ {
+1 -1
View File
@@ -3,4 +3,4 @@ package core
// Version is the application version shown in the GUI and used by build // Version is the application version shown in the GUI and used by build
// scripts in artifact names. It is a var rather than a const so release builds // scripts in artifact names. It is a var rather than a const so release builds
// can override it with Go ldflags when CI tags a build. // can override it with Go ldflags when CI tags a build.
var Version = "0.1.0" var Version = "0.3.2"
+401 -53
View File
@@ -2,12 +2,18 @@ package gui
import ( import (
"fmt" "fmt"
"io"
"net"
"net/url"
"runtime"
"runtime/debug"
"sort"
"strconv" "strconv"
"strings" "strings"
"time" "time"
"github.com/pysentry/pysentry/assets" "gitea.mixdep.ru/mix/gosentry/assets"
"github.com/pysentry/pysentry/src/core" "gitea.mixdep.ru/mix/gosentry/src/core"
"fyne.io/fyne/v2" "fyne.io/fyne/v2"
"fyne.io/fyne/v2/app" "fyne.io/fyne/v2/app"
@@ -19,9 +25,16 @@ import (
"fyne.io/fyne/v2/widget" "fyne.io/fyne/v2/widget"
) )
const appID = "io.github.pysentry.desktop" const appID = "ru.mixdep.gosentry.desktop"
const allFolders = "All" const allFolders = "All"
const noFolder = "No folder" const noFolder = "No folder"
const minJobsSidebarWidth float32 = 480
const settingsLabelWidth float32 = 140
const settingsControlWidth float32 = 330
const settingsStatusWidth float32 = 280
const projectRepositoryURL = "https://gitea.mixdep.ru/mix/gosentry"
const singleInstanceAddress = "127.0.0.1:37653"
const singleInstanceShowCommand = "show"
// The GUI package aliases core types to keep widget callbacks short. The actual // The GUI package aliases core types to keep widget callbacks short. The actual
// durable model still lives in src/core, so GUI code does not define a second // durable model still lives in src/core, so GUI code does not define a second
@@ -29,17 +42,42 @@ const noFolder = "No folder"
type job = core.Job type job = core.Job
type event = core.RunRecord type event = core.RunRecord
func Run() { func Run(startInTray bool) {
started := time.Now()
instanceListener, primary := acquireSingleInstance(!startInTray)
if !primary {
return
}
if instanceListener != nil {
defer instanceListener.Close()
}
// A stable app ID lets Fyne persist desktop preferences consistently across // A stable app ID lets Fyne persist desktop preferences consistently across
// launches and gives tray/window integration a predictable identity. // launches and gives tray/window integration a predictable identity.
a := app.NewWithID(appID) a := app.NewWithID(appID)
a.SetIcon(loadAppIcon()) a.SetIcon(loadAppIcon())
w := a.NewWindow("PySentry " + core.Version) w := a.NewWindow("GoSentry " + core.Version)
configureSystemTray(a, w) configureSystemTray(a, w)
w.Resize(fyne.NewSize(1120, 720)) w.Resize(fyne.NewSize(1120, 720))
w.SetContent(newMainView(w)) content, recordStartup := newMainView(w)
w.ShowAndRun() w.SetContent(content)
serveSingleInstance(instanceListener, w)
if startInTray {
// Autostart launches intentionally stay hidden, so "window shown" would be
// a misleading metric. Record a separate startup event for the tray path
// instead of forcing one timing definition onto two different UX flows.
recordStartup(time.Since(started), false)
a.Run()
return
}
// Show the window before recording startup time. Measuring earlier, during
// widget construction, looked cheaper in History than the user-perceived
// startup really was. The current point is less abstract: it ends when the
// window has actually been handed to the desktop for display.
w.Show()
recordStartup(time.Since(started), true)
a.Run()
} }
func loadAppIcon() fyne.Resource { func loadAppIcon() fyne.Resource {
@@ -54,7 +92,7 @@ func configureSystemTray(a fyne.App, w fyne.Window) {
return return
} }
menu := fyne.NewMenu("PySentry", menu := fyne.NewMenu("GoSentry",
fyne.NewMenuItem("Show", func() { fyne.NewMenuItem("Show", func() {
w.Show() w.Show()
w.RequestFocus() w.RequestFocus()
@@ -73,10 +111,60 @@ func configureSystemTray(a fyne.App, w fyne.Window) {
}) })
} }
func newMainView(w fyne.Window) fyne.CanvasObject { func acquireSingleInstance(showExisting bool) (net.Listener, bool) {
listener, err := net.Listen("tcp", singleInstanceAddress)
if err == nil {
return listener, true
}
connection, dialErr := net.DialTimeout("tcp", singleInstanceAddress, time.Second)
if dialErr == nil {
// The first instance listens only on localhost and understands one tiny
// command: "show". That keeps the implementation dependency-free and easy
// to inspect, which matters more here than introducing a named-pipe or
// platform-specific IPC abstraction just to focus an existing window.
if showExisting {
_, _ = io.WriteString(connection, singleInstanceShowCommand)
}
_ = connection.Close()
return nil, false
}
// If the port is unavailable but does not answer as GoSentry, continue
// startup instead of making the application impossible to open because of an
// unrelated local listener. In the normal duplicate-start case the dial above
// succeeds and this process exits after waking the first instance.
return nil, true
}
func serveSingleInstance(listener net.Listener, w fyne.Window) {
if listener == nil {
return
}
go func() {
for {
connection, err := listener.Accept()
if err != nil {
return
}
command, _ := io.ReadAll(io.LimitReader(connection, 32))
_ = connection.Close()
if strings.TrimSpace(string(command)) != singleInstanceShowCommand {
continue
}
w.Show()
w.RequestFocus()
}
}()
}
func newMainView(w fyne.Window) (fyne.CanvasObject, func(time.Duration, bool)) {
store, jobs, err := core.OpenStore() store, jobs, err := core.OpenStore()
if err != nil { if err != nil {
return container.NewPadded(widget.NewLabel("Failed to load PySentry configuration: " + err.Error())) return container.NewPadded(widget.NewLabel("Failed to load GoSentry configuration: " + err.Error())), func(time.Duration, bool) {}
}
if iconPath, err := core.InstallDesktopIntegration(appID, store.Paths.ExecutablePath, assets.IconBytes()); err == nil {
store.Paths.DesktopIcon = iconPath
} }
events := collectActivity(jobs) events := collectActivity(jobs)
@@ -89,12 +177,16 @@ func newMainView(w fyne.Window) fyne.CanvasObject {
schedulerPaused := false schedulerPaused := false
filteredJobs := filteredJobIndexes(jobs, selectedFolder) filteredJobs := filteredJobIndexes(jobs, selectedFolder)
title := widget.NewLabelWithStyle(jobs[selected].Name, fyne.TextAlignLeading, fyne.TextStyle{Bold: true}) title := widget.NewLabelWithStyle(jobs[selected].Name, fyne.TextAlignLeading, fyne.TextStyle{Bold: true})
folder := widget.NewLabel(jobs[selected].Folder) title.Wrapping = fyne.TextWrapBreak
schedule := widget.NewLabel(jobs[selected].Schedule) folder := newJobDetailLabel(jobs[selected].Folder)
command := widget.NewLabel(jobs[selected].Command) schedule := newJobDetailLabel(jobs[selected].Schedule)
lastRun := widget.NewLabel(jobs[selected].LastRun) command := newJobDetailLabel(jobs[selected].Command)
nextRun := widget.NewLabel(jobs[selected].NextRun) arguments := newJobDetailLabel(jobs[selected].Arguments)
state := widget.NewLabel(jobs[selected].LastState) successExitCodes := newJobDetailLabel(displaySuccessExitCodes(jobs[selected].SuccessExitCodes))
runMode := newJobDetailLabel(displayRunMode(jobs[selected]))
lastRun := newJobDetailLabel(jobs[selected].LastRun)
nextRun := newJobDetailLabel(jobs[selected].NextRun)
state := newJobDetailLabel(jobs[selected].LastState)
schedulerState := widget.NewLabel("Scheduler running") schedulerState := widget.NewLabel("Scheduler running")
commandOutput := widget.NewTextGrid() commandOutput := widget.NewTextGrid()
commandOutput.SetText(jobs[selected].Output) commandOutput.SetText(jobs[selected].Output)
@@ -104,6 +196,18 @@ func newMainView(w fyne.Window) fyne.CanvasObject {
// against the theme when it is placed inside a scroll container. // against the theme when it is placed inside a scroll container.
commandOutputScroll.SetMinSize(fyne.NewSize(520, 160)) commandOutputScroll.SetMinSize(fyne.NewSize(520, 160))
history := newHistoryView(&events) history := newHistoryView(&events)
recordStartup := func(duration time.Duration, windowShown bool) {
// Startup is recorded as an in-memory History event instead of being
// persisted into jobs.yaml. It is session diagnostics, not durable job
// state, and keeping it ephemeral avoids polluting the human-editable YAML
// file with process-lifetime bookkeeping.
detail := "Window shown in " + duration.Round(time.Millisecond).String()
if !windowShown {
detail = "Started in tray in " + duration.Round(time.Millisecond).String()
}
events = append(events, newEvent(0, "Application", "Started", detail))
history.Refresh()
}
selectedLogs := append([]event(nil), jobs[selected].Logs...) selectedLogs := append([]event(nil), jobs[selected].Logs...)
jobLogs := widget.NewList( jobLogs := widget.NewList(
func() int { func() int {
@@ -123,6 +227,9 @@ func newMainView(w fyne.Window) fyne.CanvasObject {
folder.SetText("") folder.SetText("")
schedule.SetText("") schedule.SetText("")
command.SetText("") command.SetText("")
arguments.SetText("")
successExitCodes.SetText("")
runMode.SetText("")
lastRun.SetText("") lastRun.SetText("")
nextRun.SetText("") nextRun.SetText("")
state.SetText("") state.SetText("")
@@ -136,6 +243,9 @@ func newMainView(w fyne.Window) fyne.CanvasObject {
folder.SetText(displayFolder(current.Folder)) folder.SetText(displayFolder(current.Folder))
schedule.SetText(current.Schedule) schedule.SetText(current.Schedule)
command.SetText(current.Command) command.SetText(current.Command)
arguments.SetText(displayArguments(current.Arguments))
successExitCodes.SetText(displaySuccessExitCodes(current.SuccessExitCodes))
runMode.SetText(displayRunMode(current))
lastRun.SetText(current.LastRun) lastRun.SetText(current.LastRun)
nextRun.SetText(current.NextRun) nextRun.SetText(current.NextRun)
state.SetText(current.LastState) state.SetText(current.LastState)
@@ -171,7 +281,7 @@ func newMainView(w fyne.Window) fyne.CanvasObject {
name.SetText(current.Name) name.SetText(current.Name)
// Keep each row compact: folder, schedule, and command are shown in one // Keep each row compact: folder, schedule, and command are shown in one
// metadata line so the left pane stays useful even with many jobs. // metadata line so the left pane stays useful even with many jobs.
meta.SetText(displayFolder(current.Folder) + " " + current.Schedule + " " + current.Command) meta.SetText(displayFolder(current.Folder) + " " + current.Schedule + " " + displayInvocation(current))
status.SetText(statusText(current)) status.SetText(statusText(current))
}, },
) )
@@ -205,7 +315,7 @@ func newMainView(w fyne.Window) fyne.CanvasObject {
folderSelect.SetSelected(selectedFolder) folderSelect.SetSelected(selectedFolder)
addButton := widget.NewButtonWithIcon("New job", theme.ContentAddIcon(), func() { addButton := widget.NewButtonWithIcon("New job", theme.ContentAddIcon(), func() {
showJobDialog(w, "New job", job{Schedule: "@every 1m", Command: "echo PySentry job ran", Enabled: true, LastRun: "Never", NextRun: "After save", LastState: "Ready"}, func(saved job) { showJobDialog(w, "New job", job{Schedule: "@every 1m", Command: "echo GoSentry job ran", Enabled: true, LastRun: "Never", NextRun: "After save", LastState: "Ready"}, func(saved job) {
saved.ID = nextJobID saved.ID = nextJobID
nextJobID++ nextJobID++
jobs = append(jobs, saved) jobs = append(jobs, saved)
@@ -214,7 +324,7 @@ func newMainView(w fyne.Window) fyne.CanvasObject {
// UI events are kept in memory for the current session. They explain // UI events are kept in memory for the current session. They explain
// user actions in History, while command output remains in log files. // user actions in History, while command output remains in log files.
jobs[selected].Logs = append([]event{created}, jobs[selected].Logs...) jobs[selected].Logs = append([]event{created}, jobs[selected].Logs...)
events = append([]event{created}, events...) events = append(events, created)
_ = store.SaveJobs(jobs) _ = store.SaveJobs(jobs)
folderSelect.Options = folderOptions(jobs) folderSelect.Options = folderOptions(jobs)
folderSelect.Refresh() folderSelect.Refresh()
@@ -240,7 +350,7 @@ func newMainView(w fyne.Window) fyne.CanvasObject {
jobs[selected] = saved jobs[selected] = saved
updated := newEvent(saved.ID, saved.Name, "Updated", "Job settings changed") updated := newEvent(saved.ID, saved.Name, "Updated", "Job settings changed")
jobs[selected].Logs = append([]event{updated}, jobs[selected].Logs...) jobs[selected].Logs = append([]event{updated}, jobs[selected].Logs...)
events = append([]event{updated}, events...) events = append(events, updated)
if scheduler != nil { if scheduler != nil {
scheduler.RefreshSchedule(selected) scheduler.RefreshSchedule(selected)
} }
@@ -282,7 +392,7 @@ func newMainView(w fyne.Window) fyne.CanvasObject {
if scheduler != nil { if scheduler != nil {
scheduler.SetPaused(true) scheduler.SetPaused(true)
} }
events = append([]event{newEvent(0, "Scheduler", "Paused", "All job execution paused")}, events...) events = append(events, newEvent(0, "Scheduler", "Paused", "All job execution paused"))
} else { } else {
schedulerState.SetText("Scheduler running") schedulerState.SetText("Scheduler running")
stopAllButton.SetText("Pause all") stopAllButton.SetText("Pause all")
@@ -297,7 +407,7 @@ func newMainView(w fyne.Window) fyne.CanvasObject {
if scheduler != nil { if scheduler != nil {
scheduler.SetPaused(false) scheduler.SetPaused(false)
} }
events = append([]event{newEvent(0, "Scheduler", "Resumed", "All job execution resumed")}, events...) events = append(events, newEvent(0, "Scheduler", "Resumed", "All job execution resumed"))
} }
list.Refresh() list.Refresh()
refresh() refresh()
@@ -313,7 +423,7 @@ func newMainView(w fyne.Window) fyne.CanvasObject {
current.NextRun = "Waiting for scheduler" current.NextRun = "Waiting for scheduler"
resumed := newEvent(current.ID, current.Name, "Resumed", "Job was enabled") resumed := newEvent(current.ID, current.Name, "Resumed", "Job was enabled")
current.Logs = append([]event{resumed}, current.Logs...) current.Logs = append([]event{resumed}, current.Logs...)
events = append([]event{resumed}, events...) events = append(events, resumed)
if scheduler != nil { if scheduler != nil {
scheduler.RefreshSchedule(selected) scheduler.RefreshSchedule(selected)
} }
@@ -322,7 +432,7 @@ func newMainView(w fyne.Window) fyne.CanvasObject {
current.NextRun = "Paused" current.NextRun = "Paused"
paused := newEvent(current.ID, current.Name, "Paused", "Job was disabled") paused := newEvent(current.ID, current.Name, "Paused", "Job was disabled")
current.Logs = append([]event{paused}, current.Logs...) current.Logs = append([]event{paused}, current.Logs...)
events = append([]event{paused}, events...) events = append(events, paused)
if scheduler != nil { if scheduler != nil {
scheduler.RefreshSchedule(selected) scheduler.RefreshSchedule(selected)
} }
@@ -356,7 +466,7 @@ func newMainView(w fyne.Window) fyne.CanvasObject {
} else { } else {
selected = filteredJobs[0] selected = filteredJobs[0]
} }
events = append([]event{newEvent(deleted.ID, deleted.Name, "Deleted", "Job was removed")}, events...) events = append(events, newEvent(deleted.ID, deleted.Name, "Deleted", "Job was removed"))
_ = store.SaveJobs(jobs) _ = store.SaveJobs(jobs)
list.Refresh() list.Refresh()
if selected >= 0 { if selected >= 0 {
@@ -377,6 +487,9 @@ func newMainView(w fyne.Window) fyne.CanvasObject {
detailRow("Folder", folder), detailRow("Folder", folder),
detailRow("Schedule", schedule), detailRow("Schedule", schedule),
detailRow("Command", command), detailRow("Command", command),
detailRow("Arguments", arguments),
detailRow("Success exit codes", successExitCodes),
detailRow("Run mode", runMode),
detailRow("Last run", lastRun), detailRow("Last run", lastRun),
detailRow("Next run", nextRun), detailRow("Next run", nextRun),
detailRow("State", state), detailRow("State", state),
@@ -391,19 +504,53 @@ func newMainView(w fyne.Window) fyne.CanvasObject {
scheduler = core.NewScheduler(store, &jobs, func(record core.RunRecord) { scheduler = core.NewScheduler(store, &jobs, func(record core.RunRecord) {
// Scheduled runs happen on the scheduler goroutine. The callback updates // Scheduled runs happen on the scheduler goroutine. The callback updates
// the shared in-memory event list so History reflects background activity. // the shared in-memory event list so History reflects background activity.
events = append([]event{record}, events...) events = append(events, record)
refresh() refresh()
}) })
scheduler.Start() scheduler.Start()
fixedSidebar := container.New(minWidthLayout{width: minJobsSidebarWidth}, sidebar)
jobsView := container.NewBorder(nil, nil, fixedSidebar, nil, container.NewPadded(details))
tabs := container.NewAppTabs( tabs := container.NewAppTabs(
container.NewTabItemWithIcon("Jobs", theme.ListIcon(), container.NewHSplit(sidebar, container.NewPadded(details))), container.NewTabItemWithIcon("Jobs", theme.ListIcon(), jobsView),
container.NewTabItemWithIcon("History", theme.HistoryIcon(), history), container.NewTabItemWithIcon("History", theme.HistoryIcon(), history),
container.NewTabItemWithIcon("Settings", theme.SettingsIcon(), settingsView(w, store, &jobs)), container.NewTabItemWithIcon("Settings", theme.SettingsIcon(), settingsView(w, store, &jobs)),
) )
tabs.SetTabLocation(container.TabLocationTop) tabs.SetTabLocation(container.TabLocationTop)
return tabs return tabs, recordStartup
}
type minWidthLayout struct {
width float32
}
func (layout minWidthLayout) MinSize(objects []fyne.CanvasObject) fyne.Size {
width := layout.width
var height float32
for _, object := range objects {
if !object.Visible() {
continue
}
min := object.MinSize()
if min.Width > width {
width = min.Width
}
if min.Height > height {
height = min.Height
}
}
return fyne.NewSize(width, height)
}
func (layout minWidthLayout) Layout(objects []fyne.CanvasObject, size fyne.Size) {
for _, object := range objects {
if !object.Visible() {
continue
}
object.Move(fyne.NewPos(0, 0))
object.Resize(size)
}
} }
func statusText(j job) string { func statusText(j job) string {
@@ -414,10 +561,10 @@ func statusText(j job) string {
} }
func newEvent(jobID int, jobName string, state string, detail string) event { func newEvent(jobID int, jobName string, state string, detail string) event {
// UI events use a short time because they are session-local activity markers. // Use the same timestamp shape as command run records so the History tab is
// Command runs use full timestamps from core.RunJob and have log files. // visually consistent across startup, UI actions, manual runs, and schedules.
return event{ return event{
Time: time.Now().Format("15:04:05"), Time: time.Now().Format("2006-01-02 15:04:05"),
JobID: jobID, JobID: jobID,
JobName: jobName, JobName: jobName,
Trigger: "UI", Trigger: "UI",
@@ -445,6 +592,9 @@ func collectActivity(jobs []job) []event {
// history loading from log metadata. // history loading from log metadata.
events = append(events, current.Logs...) events = append(events, current.Logs...)
} }
sort.SliceStable(events, func(left int, right int) bool {
return events[left].Time < events[right].Time
})
return events return events
} }
@@ -464,6 +614,28 @@ func detailRow(label string, value fyne.CanvasObject) fyne.CanvasObject {
return container.NewGridWithColumns(2, caption, value) return container.NewGridWithColumns(2, caption, value)
} }
func newJobDetailLabel(text string) *widget.Label {
label := widget.NewLabel(text)
// Job names, commands, and paths can be much wider than the details panel.
// Breaking long runs of text keeps Label.MinSize stable when the selection
// changes, so the right panel does not force the whole window to resize.
label.Wrapping = fyne.TextWrapBreak
return label
}
func settingsRow(label string, value fyne.CanvasObject) fyne.CanvasObject {
caption := widget.NewLabelWithStyle(label, fyne.TextAlignLeading, fyne.TextStyle{Bold: true})
caption.Wrapping = fyne.TextTruncate
captionBox := container.New(minWidthLayout{width: settingsLabelWidth}, caption)
return container.NewBorder(nil, nil, captionBox, nil, value)
}
func settingsRowWithStatus(label string, value fyne.CanvasObject, status fyne.CanvasObject) fyne.CanvasObject {
valueBox := container.New(minWidthLayout{width: settingsControlWidth}, value)
statusBox := container.New(minWidthLayout{width: settingsStatusWidth}, status)
return settingsRow(label, container.NewBorder(nil, nil, valueBox, nil, statusBox))
}
func filteredJobIndexes(jobs []job, folder string) []int { func filteredJobIndexes(jobs []job, folder string) []int {
indexes := make([]int, 0, len(jobs)) indexes := make([]int, 0, len(jobs))
for index, current := range jobs { for index, current := range jobs {
@@ -504,6 +676,34 @@ func displayFolder(folder string) string {
return strings.TrimSpace(folder) return strings.TrimSpace(folder)
} }
func displayArguments(arguments string) string {
if strings.TrimSpace(arguments) == "" {
return "(none)"
}
return strings.TrimSpace(arguments)
}
func displaySuccessExitCodes(codes string) string {
if strings.TrimSpace(codes) == "" {
return "0"
}
return strings.TrimSpace(codes)
}
func displayRunMode(current job) string {
if current.StartOnly {
return "Start only"
}
return "Wait for completion"
}
func displayInvocation(current job) string {
if strings.TrimSpace(current.Arguments) == "" {
return current.Command
}
return current.Command + " " + strings.ReplaceAll(strings.TrimSpace(current.Arguments), "\n", " ")
}
func displayIndex(indexes []int, jobIndex int) int { func displayIndex(indexes []int, jobIndex int) int {
for display, index := range indexes { for display, index := range indexes {
if index == jobIndex { if index == jobIndex {
@@ -524,8 +724,16 @@ func showJobDialog(w fyne.Window, title string, current job, onSave func(job)) {
schedule.SetPlaceHolder("@every 1m") schedule.SetPlaceHolder("@every 1m")
schedule.SetText(current.Schedule) schedule.SetText(current.Schedule)
command := widget.NewEntry() command := widget.NewEntry()
command.SetPlaceHolder("echo PySentry job ran") command.SetPlaceHolder(`C:\Program Files\App\App.exe`)
command.SetText(current.Command) command.SetText(current.Command)
arguments := widget.NewMultiLineEntry()
arguments.SetPlaceHolder(`D:\Local\Jobs\Auto.ffs_batch`)
arguments.SetText(current.Arguments)
successExitCodes := widget.NewEntry()
successExitCodes.SetPlaceHolder("0")
successExitCodes.SetText(displaySuccessExitCodes(current.SuccessExitCodes))
startOnly := widget.NewCheck("Start only, do not wait for exit", nil)
startOnly.SetChecked(current.StartOnly)
enabled := widget.NewCheck("Enabled", nil) enabled := widget.NewCheck("Enabled", nil)
enabled.SetChecked(current.Enabled) enabled.SetChecked(current.Enabled)
@@ -538,6 +746,9 @@ func showJobDialog(w fyne.Window, title string, current job, onSave func(job)) {
widget.NewFormItem("Folder", folder), widget.NewFormItem("Folder", folder),
widget.NewFormItem("Schedule", schedule), widget.NewFormItem("Schedule", schedule),
widget.NewFormItem("Command", command), widget.NewFormItem("Command", command),
widget.NewFormItem("Arguments", arguments),
widget.NewFormItem("Success exit codes", successExitCodes),
widget.NewFormItem("", startOnly),
widget.NewFormItem("", enabled), widget.NewFormItem("", enabled),
}, },
func(saved bool) { func(saved bool) {
@@ -554,6 +765,12 @@ func showJobDialog(w fyne.Window, title string, current job, onSave func(job)) {
current.Folder = strings.TrimSpace(folder.Text) current.Folder = strings.TrimSpace(folder.Text)
current.Schedule = strings.TrimSpace(schedule.Text) current.Schedule = strings.TrimSpace(schedule.Text)
current.Command = strings.TrimSpace(command.Text) current.Command = strings.TrimSpace(command.Text)
current.Arguments = strings.TrimSpace(arguments.Text)
current.SuccessExitCodes = strings.TrimSpace(successExitCodes.Text)
if current.SuccessExitCodes == "" {
current.SuccessExitCodes = "0"
}
current.StartOnly = startOnly.Checked
current.Enabled = enabled.Checked current.Enabled = enabled.Checked
if current.LastRun == "" { if current.LastRun == "" {
current.LastRun = "Never" current.LastRun = "Never"
@@ -571,27 +788,125 @@ func showJobDialog(w fyne.Window, title string, current job, onSave func(job)) {
}, },
w, w,
) )
form.Resize(fyne.NewSize(560, 280)) form.Resize(fyne.NewSize(640, 460))
form.Show() form.Show()
} }
func newHistoryView(events *[]event) *fyne.Container { func newHistoryView(events *[]event) *fyne.Container {
list := widget.NewList( descending := false
func() int { return len(*events) }, headerText := func(id widget.TableCellID) string {
func() fyne.CanvasObject { return widget.NewLabel("event") }, headers := []string{"Time", "Trigger", "Job", "State", "Detail", "Log"}
func(id widget.ListItemID, item fyne.CanvasObject) { if id.Row < 0 && id.Col == 0 {
item.(*widget.Label).SetText(eventText((*events)[id])) if descending {
return "Time desc"
}
return "Time asc"
}
if id.Row < 0 && id.Col >= 0 && id.Col < len(headers) {
return headers[id.Col]
}
return ""
}
sortedEvents := func() []event {
result := append([]event(nil), (*events)...)
sort.SliceStable(result, func(left int, right int) bool {
if descending {
return result[left].Time > result[right].Time
}
return result[left].Time < result[right].Time
})
return result
}
table := widget.NewTable(
func() (int, int) {
return len(*events), 6
},
func() fyne.CanvasObject {
label := widget.NewLabel("")
label.Wrapping = fyne.TextTruncate
return label
},
func(id widget.TableCellID, item fyne.CanvasObject) {
label := item.(*widget.Label)
label.SetText(historyCellText(id, sortedEvents()))
label.TextStyle = fyne.TextStyle{}
label.Refresh()
}, },
) )
return container.NewPadded(list) table.ShowHeaderRow = true
table.CreateHeader = func() fyne.CanvasObject {
label := widget.NewLabel("")
label.Wrapping = fyne.TextTruncate
return label
}
table.UpdateHeader = func(id widget.TableCellID, item fyne.CanvasObject) {
label := item.(*widget.Label)
label.SetText(headerText(id))
label.TextStyle = fyne.TextStyle{Bold: true}
label.Refresh()
}
table.OnSelected = func(id widget.TableCellID) {
if id.Row < 0 && id.Col == 0 {
descending = !descending
table.Refresh()
}
table.Unselect(id)
}
table.SetColumnWidth(0, 150)
table.SetColumnWidth(1, 90)
table.SetColumnWidth(2, 170)
table.SetColumnWidth(3, 90)
table.SetColumnWidth(4, 260)
table.SetColumnWidth(5, 240)
return container.NewPadded(table)
}
func historyCellText(id widget.TableCellID, events []event) string {
if id.Row < 0 || id.Row >= len(events) {
return ""
}
current := events[id.Row]
trigger := current.Trigger
if trigger == "" {
trigger = "Unknown"
}
switch id.Col {
case 0:
return current.Time
case 1:
return trigger
case 2:
return current.JobName
case 3:
return current.State
case 4:
return current.Detail
case 5:
return logFileName(current.LogFile)
default:
return ""
}
}
func logFileName(path string) string {
path = strings.TrimSpace(path)
if path == "" {
return ""
}
path = strings.ReplaceAll(path, "\\", "/")
if slash := strings.LastIndex(path, "/"); slash >= 0 {
return path[slash+1:]
}
return path
} }
func settingsView(w fyne.Window, store *core.Store, jobs *[]job) fyne.CanvasObject { func settingsView(w fyne.Window, store *core.Store, jobs *[]job) fyne.CanvasObject {
startOnLogin := widget.NewCheck("Start PySentry when I sign in", nil) startOnLogin := widget.NewCheck("Start on login", nil)
startOnLogin.SetChecked(store.Config.StartOnLogin) startOnLogin.SetChecked(store.Config.StartOnLogin)
autostartStatus := widget.NewLabel("") autostartStatus := widget.NewLabel("")
refreshAutostartStatus := func() { refreshAutostartStatus := func() {
ok, message := core.AutostartStatus(startOnLogin.Checked, store.Paths.ExecutablePath) ok, message := core.AutostartStatus(store.Config.StartOnLogin, store.Paths.ExecutablePath)
if ok { if ok {
autostartStatus.SetText("OK: " + message) autostartStatus.SetText("OK: " + message)
return return
@@ -599,6 +914,10 @@ func settingsView(w fyne.Window, store *core.Store, jobs *[]job) fyne.CanvasObje
autostartStatus.SetText("Problem: " + message) autostartStatus.SetText("Problem: " + message)
} }
startOnLogin.OnChanged = func(bool) { startOnLogin.OnChanged = func(bool) {
if startOnLogin.Checked != store.Config.StartOnLogin {
autostartStatus.SetText("Pending: save settings to apply")
return
}
refreshAutostartStatus() refreshAutostartStatus()
} }
refreshAutostartStatus() refreshAutostartStatus()
@@ -652,7 +971,7 @@ func settingsView(w fyne.Window, store *core.Store, jobs *[]job) fyne.CanvasObje
settingsStatus.SetText("Save failed: " + err.Error()) settingsStatus.SetText("Save failed: " + err.Error())
return return
} }
if err := core.SetAutostart(store.Config.StartOnLogin, store.Paths.ExecutablePath); err != nil { if err := core.SetAutostart(store.Config.StartOnLogin, store.Paths.ExecutablePath, store.Paths.DesktopIcon); err != nil {
refreshAutostartStatus() refreshAutostartStatus()
settingsStatus.SetText("Saved, autostart failed: " + err.Error()) settingsStatus.SetText("Saved, autostart failed: " + err.Error())
return return
@@ -676,25 +995,54 @@ func settingsView(w fyne.Window, store *core.Store, jobs *[]job) fyne.CanvasObje
return container.NewPadded(container.NewVBox( return container.NewPadded(container.NewVBox(
widget.NewLabelWithStyle("Application", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), widget.NewLabelWithStyle("Application", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
detailRow("Version", widget.NewLabel(core.Version)), settingsRowWithStatus("Autostart", startOnLogin, autostartStatus),
detailRow("Start on login", container.NewBorder(nil, nil, nil, autostartStatus, startOnLogin)), settingsRow("Tray", container.New(minWidthLayout{width: settingsControlWidth}, minimizeToTray)),
minimizeToTray, settingsRow("Notifications", container.New(minWidthLayout{width: settingsControlWidth}, notifications)),
notifications,
widget.NewSeparator(), widget.NewSeparator(),
widget.NewLabelWithStyle("Storage", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), widget.NewLabelWithStyle("Storage", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
detailRow("Config YAML", widget.NewLabel(store.Paths.ConfigPath)), settingsRow("Config YAML", widget.NewLabel(store.Paths.ConfigPath)),
detailRow("Jobs directory", container.NewBorder(nil, nil, nil, jobsDirBrowse, jobsDir)), settingsRow("Jobs directory", container.NewBorder(nil, nil, nil, jobsDirBrowse, jobsDir)),
detailRow("Logs directory", container.NewBorder(nil, nil, nil, logsDirBrowse, logsDir)), settingsRow("Logs directory", container.NewBorder(nil, nil, nil, logsDirBrowse, logsDir)),
detailRow("Max log files", maxLogFiles), settingsRow("Max log files", maxLogFiles),
detailRow("Max log age days", maxLogAgeDays), settingsRow("Max log age days", maxLogAgeDays),
saveSettings, saveSettings,
settingsStatus, settingsStatus,
widget.NewSeparator(), widget.NewSeparator(),
widget.NewLabelWithStyle("Scheduler", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), widget.NewLabelWithStyle("About", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
widget.NewLabel("Current core supports @every schedules and standard 5-field cron expressions."), settingsRow("GoSentry", widget.NewLabel(core.Version)),
settingsRow("Go", widget.NewLabel(runtime.Version())),
settingsRow("Fyne", widget.NewLabel(fyneVersion())),
settingsRow("Repository", widget.NewHyperlink(projectRepositoryURL, mustParseURL(projectRepositoryURL))),
)) ))
} }
func fyneVersion() string {
info, ok := debug.ReadBuildInfo()
if !ok {
return "unknown"
}
for _, dependency := range info.Deps {
if dependency.Path == "fyne.io/fyne/v2" {
if dependency.Replace != nil && dependency.Replace.Version != "" {
return dependency.Replace.Version
}
if dependency.Version != "" {
return dependency.Version
}
return "local"
}
}
return "unknown"
}
func mustParseURL(raw string) *url.URL {
parsed, err := url.Parse(raw)
if err != nil {
return &url.URL{}
}
return parsed
}
func chooseFolder(w fyne.Window, target *widget.Entry) { func chooseFolder(w fyne.Window, target *widget.Entry) {
folderDialog := dialog.NewFolderOpen(func(uri fyne.ListableURI, err error) { folderDialog := dialog.NewFolderOpen(func(uri fyne.ListableURI, err error) {
if err != nil || uri == nil { if err != nil || uri == nil {