35 Commits

Author SHA1 Message Date
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
mixeme 91080a7a9d Move release builds into selectable script 2026-06-15 08:02:38 +03:00
mixeme 81b04d3dff Fix Linux arm64 builder dependencies 2026-06-15 07:56:18 +03:00
44 changed files with 2327 additions and 496 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_/
+15 -22
View File
@@ -9,40 +9,33 @@ RUN apt-get update && \
apt-get install -y --no-install-recommends \ apt-get install -y --no-install-recommends \
ca-certificates \ ca-certificates \
gcc \ gcc \
libc6-dev \
gcc-aarch64-linux-gnu \ gcc-aarch64-linux-gnu \
libc6-dev-arm64-cross \
linux-libc-dev-arm64-cross \
gcc-mingw-w64-x86-64 \ gcc-mingw-w64-x86-64 \
binutils-mingw-w64-x86-64 \ binutils-mingw-w64-x86-64 \
pkg-config \ pkg-config \
libgl1-mesa-dev \ libgl1-mesa-dev \
xorg-dev \ xorg-dev \
libgl1-mesa-dev:arm64 \ libgl1-mesa-dev:arm64 \
xorg-dev:arm64 && \ libx11-dev:arm64 \
libxcursor-dev:arm64 \
libxrandr-dev:arm64 \
libxinerama-dev:arm64 \
libxi-dev:arm64 \
libxxf86vm-dev:arm64 && \
rm -rf /var/lib/apt/lists/* rm -rf /var/lib/apt/lists/*
WORKDIR /src WORKDIR /src
# Copy module files first so Docker can cache downloaded dependencies while the # Copy module files first so Docker can cache downloaded dependencies while the
# application source changes. This makes repeated local builds much faster. # application source changes. The release script later mounts the live repository
# over /src, but the module cache remains in the image and keeps repeated builds
# faster.
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
COPY . . # The image intentionally stops here. Artifact build commands live in
# scripts/build-release-linux.sh so a developer can choose targets interactively
# CGO is required by Fyne. This builder produces the release artifacts from # without rebuilding this environment image for every selection.
# Linux: Linux amd64, Linux arm64, and a Windows amd64 binary cross-compiled with
# MinGW. The Windows resource is generated inside the container so Explorer still
# sees the application icon.
RUN version="$(sed -n 's/^var Version = "\(.*\)"/\1/p' src/core/version.go)" && \
version="${version:-0.0.0-dev}" && \
mkdir -p /out/linux /out/windows && \
CGO_ENABLED=1 GOOS=linux GOARCH=amd64 \
go build -trimpath -ldflags "-s -w -X github.com/pysentry/pysentry/src/core.Version=${version}" \
-o "/out/linux/pysentry-${version}-linux-amd64" ./cmd/pysentry && \
CC=aarch64-linux-gnu-gcc CGO_ENABLED=1 GOOS=linux GOARCH=arm64 \
PKG_CONFIG_PATH=/usr/lib/aarch64-linux-gnu/pkgconfig \
go build -trimpath -ldflags "-s -w -X github.com/pysentry/pysentry/src/core.Version=${version}" \
-o "/out/linux/pysentry-${version}-linux-arm64" ./cmd/pysentry && \
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 "/out/windows/pysentry-${version}-windows-amd64.exe" ./cmd/pysentry
+140 -50
View File
@@ -1,12 +1,18 @@
# 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)
## Features ## Features
- Native desktop GUI built with Fyne. - Native desktop GUI built with [Fyne](https://fyne.io/).
- Job storage in one clean YAML file. - Job storage in one clean YAML file.
- App settings in a separate YAML file. - App settings in a separate YAML file.
- `@every` schedules and standard 5-field cron expressions. - `@every` schedules and standard 5-field cron expressions.
@@ -21,16 +27,37 @@ PySentry is being designed and implemented with assistance from OpenAI Codex.
Common: Common:
- Go 1.22 or newer. - [Go](https://go.dev/) 1.22 or newer.
Windows: Windows:
- MSYS2 with UCRT64 GCC in `C:\msys64\ucrt64\bin`. - MSYS2 with UCRT64 GCC in `C:\msys64\ucrt64\bin`.
Install these dependencies on Windows:
```powershell
# 1. Install Go 1.22 or newer from https://go.dev/dl/.
# The default installer path is C:\Program Files\Go.
go version
# 2. Install MSYS2 from https://www.msys2.org/.
# Use the default installation path so UCRT64 tools are placed under
# C:\msys64\ucrt64\bin.
# 3. Open "MSYS2 UCRT64" from the Start menu and install GCC plus windres.
pacman -Syu
pacman -S --needed mingw-w64-ucrt-x86_64-gcc mingw-w64-ucrt-x86_64-binutils
# 4. In PowerShell, check that the compiler is available where the build script
# expects it. build-windows.bat prepends this directory automatically.
Test-Path C:\msys64\ucrt64\bin\gcc.exe
Test-Path C:\msys64\ucrt64\bin\windres.exe
```
Linux: Linux:
- A C compiler. - A C compiler.
- Fyne native build dependencies, including OpenGL/X11 development packages. - [Fyne](https://fyne.io/) native build dependencies, including OpenGL/X11 development packages.
On Debian/Ubuntu, the Linux dependencies are typically: On Debian/Ubuntu, the Linux dependencies are typically:
@@ -45,7 +72,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
@@ -59,7 +86,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:
@@ -74,15 +101,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
``` ```
@@ -91,30 +118,38 @@ 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:
```bash ```bash
# Builds Linux amd64, Linux arm64, and Windows amd64 artifacts from one # Interactively choose Linux amd64, Linux arm64, Windows amd64, or all artifacts
# Linux/Docker workflow. The Dockerfile includes Linux Fyne dependencies plus # from one Linux/Docker workflow. The Dockerfile contains the builder
# cross-compilers for arm64 Linux and the Windows .exe. # 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
``` ```
Non-interactive release builds can pass target names:
```bash
# Build only Linux arm64 and Windows amd64 artifacts.
./scripts/build-release-linux.sh linux-arm64 windows-amd64
```
The binaries are copied to: 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
@@ -129,7 +164,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:
@@ -137,17 +172,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: .
@@ -161,7 +237,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.
@@ -192,7 +268,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
@@ -227,7 +303,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.
@@ -239,56 +315,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` 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.
+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.
+169
View File
@@ -0,0 +1,169 @@
# GoSentry Test Suite
All tests are located alongside source code in the `src/core/` package. Tests follow Go conventions with `*_test.go` filename patterns.
## 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. |
---
## Running Tests
### Run all tests in the package
```bash
cd D:\Local\Git\gosentry
go test ./src/core
```
### Run tests with verbose output
```bash
go test -v ./src/core
```
### Run specific test by name
```bash
go test -run TestRunJobWritesLogFile ./src/core
```
### Run Windows-only tests (on Windows)
```bash
go test -v ./src/core # Windows build tags are active
```
### Run Linux-only tests (on Linux)
```bash
go test -v ./src/core # Linux build tags are active
```
### Run with code coverage
```bash
go test -cover ./src/core
go test -coverprofile=coverage.out ./src/core
go tool cover -html=coverage.out
```
---
## 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"
+16 -12
View File
@@ -6,23 +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" .
# The image build produces /out/linux and /out/windows. This helper copies only
# the Linux binary for compatibility with the older Linux-only workflow; use
# build-release-linux.sh when both platform artifacts are needed.
container_id="$(docker create gitea.mixdep.ru/mix/pysentry-builder)"
cleanup() {
docker rm "$container_id" >/dev/null
}
trap cleanup EXIT
mkdir -p "$(dirname "$output")" mkdir -p "$(dirname "$output")"
docker cp "${container_id}:/out/linux/pysentry-${version}-linux-amd64" "$output" docker run --rm \
"${docker_user_args[@]}" \
-e "VERSION=${version}" \
-e "OUTPUT=${output}" \
-e "GOCACHE=/tmp/go-build-cache" \
-v "$(pwd):/src" \
-w /src \
"$tag" \
bash -c 'CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build -buildvcs=false -trimpath -ldflags "-s -w -X gitea.mixdep.ru/mix/gosentry/src/core.Version=${VERSION}" -o "${OUTPUT}" ./cmd/gosentry'
# 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.
+130 -14
View File
@@ -1,22 +1,138 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
# Build all release artifacts from a Linux host or CI runner. The Docker image # Build selected release artifacts from a Linux host or CI runner. The Docker
# 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. # cross-compiler used for the Windows GUI executable. Actual build commands live
tag="gitea.mixdep.ru/mix/pysentry-builder" # here rather than in Dockerfile so target selection does not require rebuilding
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
repo_root="$(cd "${script_dir}/.." && pwd)"
cd "$repo_root"
version="$(sed -n 's/^var Version = "\(.*\)"/\1/p' src/core/version.go)"
version="${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() {
cat <<EOF
Usage: $0 [target...]
Targets:
all Build every release artifact.
linux-amd64 Build dist/linux/gosentry-${version}-linux-amd64.
linux-arm64 Build dist/linux/gosentry-${version}-linux-arm64.
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.
EOF
}
for arg in "$@"; do
case "$arg" in
-h|--help|help)
usage
exit 0
;;
esac
done
choose_targets() {
if [ "$#" -gt 0 ]; then
printf '%s\n' "$@"
return
fi
if [ ! -t 0 ]; then
printf '%s\n' all
return
fi
echo "Select release artifacts to build:"
echo " 1) all"
echo " 2) linux-amd64"
echo " 3) linux-arm64"
echo " 4) windows-amd64"
echo "Enter numbers or target names separated by spaces or commas."
read -r -p "Build target [all]: " answer
answer="${answer:-all}"
echo "$answer" | tr ',' ' ' | tr ' ' '\n' | sed '/^$/d'
}
normalize_targets() {
while IFS= read -r target; do
case "$target" in
1|all)
printf '%s\n' linux-amd64 linux-arm64 windows-amd64
;;
2|linux-amd64)
printf '%s\n' linux-amd64
;;
3|linux-arm64)
printf '%s\n' linux-arm64
;;
4|windows-amd64)
printf '%s\n' windows-amd64
;;
*)
echo "Unknown build target: $target" >&2
usage >&2
exit 1
;;
esac
done
}
run_in_builder() {
docker run --rm \
"${docker_user_args[@]}" \
-e "VERSION=${version}" \
-e "GOCACHE=/tmp/go-build-cache" \
-v "${repo_root}:/src" \
-w /src \
"$tag" \
bash -c "$1"
}
build_linux_amd64() {
run_in_builder 'mkdir -p dist/linux && CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build -buildvcs=false -trimpath -ldflags "-s -w -X gitea.mixdep.ru/mix/gosentry/src/core.Version=${VERSION}" -o "dist/linux/gosentry-${VERSION}-linux-amd64" ./cmd/gosentry'
}
build_linux_arm64() {
run_in_builder 'mkdir -p dist/linux && CC=aarch64-linux-gnu-gcc CGO_ENABLED=1 GOOS=linux GOARCH=arm64 CGO_CFLAGS="--sysroot=/ -I/usr/include/aarch64-linux-gnu" CGO_LDFLAGS="--sysroot=/ -L/usr/lib/aarch64-linux-gnu" PKG_CONFIG_LIBDIR=/usr/lib/aarch64-linux-gnu/pkgconfig go build -buildvcs=false -trimpath -ldflags "-s -w -X gitea.mixdep.ru/mix/gosentry/src/core.Version=${VERSION}" -o "dist/linux/gosentry-${VERSION}-linux-arm64" ./cmd/gosentry'
}
build_windows_amd64() {
run_in_builder 'mkdir -p dist/windows && x86_64-w64-mingw32-windres -O coff -o cmd/gosentry/rsrc_windows_amd64.syso packaging/windows/gosentry.rc && CC=x86_64-w64-mingw32-gcc CGO_ENABLED=1 GOOS=windows GOARCH=amd64 go build -buildvcs=false -trimpath -ldflags "-s -w -H=windowsgui -X gitea.mixdep.ru/mix/gosentry/src/core.Version=${VERSION}" -o "dist/windows/gosentry-${VERSION}-windows-amd64.exe" ./cmd/gosentry'
}
mapfile -t targets < <(choose_targets "$@" | normalize_targets | awk '!seen[$0]++')
if [ "${#targets[@]}" -eq 0 ]; then
echo "No build targets selected." >&2
exit 1
fi
echo "Building Docker builder image: $tag"
docker build -f Dockerfile -t "$tag" . docker build -f Dockerfile -t "$tag" .
container_id="$(docker create "$tag")" for target in "${targets[@]}"; do
cleanup() { echo "Building $target..."
docker rm "$container_id" >/dev/null case "$target" in
} linux-amd64)
trap cleanup EXIT build_linux_amd64
;;
mkdir -p dist/linux dist/windows linux-arm64)
docker cp "${container_id}:/out/linux/." dist/linux build_linux_arm64
docker cp "${container_id}:/out/windows/." dist/windows ;;
windows-amd64)
build_windows_amd64
;;
esac
done
echo "Built release artifacts:" echo "Built release artifacts:"
find dist/linux dist/windows -maxdepth 1 -type f -print find dist/linux dist/windows -maxdepth 1 -type f -print 2>/dev/null || true
+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
+134 -40
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
// user service. systemd is tempting because it is explicit and scriptable,
// but it is the wrong owner for a windowed app that should inherit the
// desktop session environment and appear in the tray predictably.
if err := cleanupLegacySystemdAutostart(); err != nil {
return err
}
if err := cleanupLegacyDesktopAutostart(); err != nil {
return err
}
if enabled { if enabled {
if err := os.MkdirAll(unitDir, 0o755); err != nil { if err := os.MkdirAll(filepath.Dir(desktopPath), 0o755); err != nil {
return err return err
} }
unit := fmt.Sprintf(`[Unit] desktopFile := fmt.Sprintf(`[Desktop Entry]
Description=PySentry desktop scheduler Type=Application
Name=GoSentry
[Service] Comment=GoSentry desktop scheduler
ExecStart=%s Exec=%s %s
Restart=on-failure %s
Terminal=false
[Install] X-GNOME-Autostart-enabled=true
WantedBy=default.target `, quoteDesktopExec(executablePath), StartInTrayArgument, desktopIconLine(iconPath))
`, executablePath) return os.WriteFile(desktopPath, []byte(desktopFile), 0o644)
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(desktopPath); err != nil && !os.IsNotExist(err) {
if err := os.Remove(unitPath); err != nil && !os.IsNotExist(err) {
return err return err
} }
return exec.Command("systemctl", "--user", "daemon-reload").Run() 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
} }
+195 -27
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)
output, err := command.Output()
if !expectedEnabled {
if err != nil { if err != nil {
return false, "Startup folder cannot be resolved"
}
_, statErr := os.Stat(shortcutPath)
if !expectedEnabled {
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 shortcut exists while setting is off"
return false, "Autostart entry is missing"
} }
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
}
+10 -2
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,7 +29,7 @@ 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"`
@@ -32,6 +37,9 @@ type Job struct {
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"`
Arguments string `yaml:"arguments,omitempty"`
SuccessExitCodes string `yaml:"success_exit_codes,omitempty"`
StartOnly bool `yaml:"start_only,omitempty"`
Enabled bool `yaml:"enabled"` Enabled bool `yaml:"enabled"`
LastRun string `yaml:"-"` LastRun string `yaml:"-"`
NextRun string `yaml:"-"` NextRun string `yaml:"-"`
+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) {
+170 -29
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
var output string
var state string
var detail string
if job.StartOnly {
invocation := jobInvocation(context.Background(), *job)
state, detail, output = startJobOnly(invocation, *job, started)
} else {
invocation := jobInvocation(runCtx, *job)
command := invocation.command
command.WaitDelay = commandWaitDelay
if invocation.hideWindow {
configureHiddenWindow(command)
}
command.Stdout = &stdout command.Stdout = &stdout
command.Stderr = &stderr command.Stderr = &stderr
err := command.Run() err := command.Run()
duration := time.Since(started).Round(time.Millisecond) duration := time.Since(started).Round(time.Millisecond)
output := formatOutput(stdout.String(), stderr.String()) output = formatOutput(stdout.String(), stderr.String())
state, detail = runStateDetail(err, runCtx.Err(), duration, *job)
state := "OK"
detail := fmt.Sprintf("Completed in %s", duration)
if err != nil {
state = "Failed"
if errors.Is(runCtx.Err(), context.DeadlineExceeded) {
detail = fmt.Sprintf("Timed out after %s", commandTimeout)
} else {
detail = err.Error()
}
} }
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
+237
View File
@@ -4,6 +4,7 @@ import (
"context" "context"
"os" "os"
"path/filepath" "path/filepath"
"runtime"
"strings" "strings"
"testing" "testing"
) )
@@ -38,3 +39,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)
+25
View File
@@ -1,6 +1,7 @@
package core package core
import ( import (
"strings"
"testing" "testing"
"time" "time"
) )
@@ -27,3 +28,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,
}, },
{ {
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"strings" "strings"
"testing" "testing"
"gopkg.in/yaml.v3" "go.yaml.in/yaml/v4"
) )
func TestJobsYAMLDoesNotPersistRuntimeNoise(t *testing.T) { func TestJobsYAMLDoesNotPersistRuntimeNoise(t *testing.T) {
+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 {