Compare commits
33 Commits
91080a7a9d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| d24211cab2 | |||
| 0c2c9f1f67 | |||
| 89704b0470 | |||
| 975829ed70 | |||
| eb6a1907e6 | |||
| 2f7bbe4fca | |||
| 94033e794f | |||
| d828e34121 | |||
| 872cc82c5c | |||
| b1fe8bd675 | |||
| 44f24ab3d8 | |||
| 0bc9e91d1e | |||
| 079961e735 | |||
| cc294ce718 | |||
| 5f85af27e9 | |||
| d4b1238c5f | |||
| d06f130c5c | |||
| fd3e8baa0e | |||
| d202f8a94c | |||
| c1bd8c952c | |||
| e8e0060063 | |||
| 7252d3683c | |||
| 088f6e77b0 | |||
| 4a8feb351e | |||
| e016da5277 | |||
| c644636e57 | |||
| e2464aab0f | |||
| 5ef32566db | |||
| 2932783143 | |||
| 91158bf5b8 | |||
| ab75226cdb | |||
| 9214958fd0 | |||
| ddabfd2da2 |
@@ -2,6 +2,7 @@
|
|||||||
bin
|
bin
|
||||||
dist
|
dist
|
||||||
logs
|
logs
|
||||||
|
gosentry.yaml
|
||||||
pysentry.yaml
|
pysentry.yaml
|
||||||
jobs.yaml
|
jobs.yaml
|
||||||
*.exe
|
*.exe
|
||||||
|
|||||||
+16
-178
@@ -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_/
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
# 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
|
||||||
|
|
||||||
@@ -66,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
|
||||||
@@ -80,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:
|
||||||
@@ -95,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
|
||||||
```
|
```
|
||||||
@@ -112,7 +118,7 @@ The binary is copied to:
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
# Linux executable copied out of the Docker build image.
|
# Linux executable copied out of the Docker build image.
|
||||||
dist\linux\pysentry-0.1.0-linux-amd64
|
dist\linux\gosentry-0.3.0-linux-amd64
|
||||||
```
|
```
|
||||||
|
|
||||||
Release build from Linux:
|
Release build from Linux:
|
||||||
@@ -120,7 +126,8 @@ Release build from Linux:
|
|||||||
```bash
|
```bash
|
||||||
# Interactively choose Linux amd64, Linux arm64, Windows amd64, or all artifacts
|
# Interactively choose Linux amd64, Linux arm64, Windows amd64, or all artifacts
|
||||||
# from one Linux/Docker workflow. The Dockerfile contains the builder
|
# from one Linux/Docker workflow. The Dockerfile contains the builder
|
||||||
# environment; the build commands live in this script.
|
# environment; the build commands live in this script. Docker runs the build
|
||||||
|
# with the current user's UID/GID so dist/ files are not owned by root.
|
||||||
chmod +x ./scripts/build-release-linux.sh
|
chmod +x ./scripts/build-release-linux.sh
|
||||||
./scripts/build-release-linux.sh
|
./scripts/build-release-linux.sh
|
||||||
```
|
```
|
||||||
@@ -136,13 +143,13 @@ The binaries are copied to:
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
# Linux artifact.
|
# Linux artifact.
|
||||||
dist/linux/pysentry-0.1.0-linux-amd64
|
dist/linux/gosentry-0.3.0-linux-amd64
|
||||||
|
|
||||||
# Linux arm64 artifact.
|
# Linux arm64 artifact.
|
||||||
dist/linux/pysentry-0.1.0-linux-arm64
|
dist/linux/gosentry-0.3.0-linux-arm64
|
||||||
|
|
||||||
# Windows artifact cross-compiled from Linux.
|
# Windows artifact cross-compiled from Linux.
|
||||||
dist/windows/pysentry-0.1.0-windows-amd64.exe
|
dist/windows/gosentry-0.3.0-windows-amd64.exe
|
||||||
```
|
```
|
||||||
|
|
||||||
## Run From Source
|
## Run From Source
|
||||||
@@ -157,7 +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:
|
||||||
@@ -165,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: .
|
||||||
|
|
||||||
@@ -189,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.
|
||||||
@@ -220,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
|
||||||
@@ -255,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.
|
||||||
@@ -267,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`](https://fyne.io/) for the native GUI.
|
- [`fyne.io/fyne/v2`](https://fyne.io/) for the native GUI.
|
||||||
- `github.com/robfig/cron/v3` for cron schedule parsing.
|
- `github.com/robfig/cron/v3` for cron schedule parsing.
|
||||||
- `gopkg.in/yaml.v3` for YAML settings and jobs.
|
- [`go.yaml.in/yaml/v4`](https://github.com/yaml/go-yaml) for YAML settings and jobs.
|
||||||
|
|
||||||
The remaining entries in `go.mod` are indirect dependencies pulled by Fyne and the Go module resolver.
|
The remaining entries in `go.mod` are indirect dependencies pulled by Fyne and the Go module resolver.
|
||||||
|
|
||||||
|
Source repositories for mirroring:
|
||||||
|
|
||||||
|
- Go toolchain: https://go.googlesource.com/go
|
||||||
|
- Fyne: https://github.com/fyne-io/fyne
|
||||||
|
- robfig/cron: https://github.com/robfig/cron
|
||||||
|
- yaml/go-yaml: https://github.com/yaml/go-yaml
|
||||||
|
|
||||||
|
To list every direct and indirect Go module used by the current checkout:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go list -m all
|
||||||
|
```
|
||||||
|
|||||||
+6
-2
@@ -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 |
@@ -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 |
@@ -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
|
||||||
|
}
|
||||||
@@ -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()
|
|
||||||
}
|
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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
@@ -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
|
||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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=
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
IDI_ICON1 ICON "assets/gosentry.ico"
|
||||||
@@ -1 +0,0 @@
|
|||||||
IDI_ICON1 ICON "assets/pysentry.ico"
|
|
||||||
@@ -6,20 +6,27 @@ set -euo pipefail
|
|||||||
# default includes the application version and target platform.
|
# default includes the application version and target platform.
|
||||||
version="$(sed -n 's/^var Version = "\(.*\)"/\1/p' src/core/version.go)"
|
version="$(sed -n 's/^var Version = "\(.*\)"/\1/p' src/core/version.go)"
|
||||||
version="${version:-0.0.0-dev}"
|
version="${version:-0.0.0-dev}"
|
||||||
output="${1:-dist/linux/pysentry-${version}-linux-amd64}"
|
tag="gitea.mixdep.ru/mix/gosentry-builder:${version}"
|
||||||
|
output="${1:-dist/linux/gosentry-${version}-linux-amd64}"
|
||||||
|
docker_user_args=()
|
||||||
|
if command -v id >/dev/null 2>&1; then
|
||||||
|
docker_user_args=(--user "$(id -u):$(id -g)")
|
||||||
|
fi
|
||||||
|
|
||||||
# Dockerfile contains the native packages required by Fyne. Keeping that
|
# Dockerfile contains the native packages required by Fyne. Keeping that
|
||||||
# environment in Docker makes Linux builds repeatable from Windows hosts and CI.
|
# environment in Docker makes Linux builds repeatable from Windows hosts and CI.
|
||||||
docker build -f Dockerfile -t gitea.mixdep.ru/mix/pysentry-builder .
|
docker build -f Dockerfile -t "$tag" .
|
||||||
|
|
||||||
mkdir -p "$(dirname "$output")"
|
mkdir -p "$(dirname "$output")"
|
||||||
docker run --rm \
|
docker run --rm \
|
||||||
|
"${docker_user_args[@]}" \
|
||||||
-e "VERSION=${version}" \
|
-e "VERSION=${version}" \
|
||||||
-e "OUTPUT=${output}" \
|
-e "OUTPUT=${output}" \
|
||||||
|
-e "GOCACHE=/tmp/go-build-cache" \
|
||||||
-v "$(pwd):/src" \
|
-v "$(pwd):/src" \
|
||||||
-w /src \
|
-w /src \
|
||||||
gitea.mixdep.ru/mix/pysentry-builder \
|
"$tag" \
|
||||||
bash -lc 'CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags "-s -w -X github.com/pysentry/pysentry/src/core.Version=${VERSION}" -o "${OUTPUT}" ./cmd/pysentry'
|
bash -c 'CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build -buildvcs=false -trimpath -ldflags "-s -w -X gitea.mixdep.ru/mix/gosentry/src/core.Version=${VERSION}" -o "${OUTPUT}" ./cmd/gosentry'
|
||||||
|
|
||||||
# Icons are embedded in the Go binary, so there is no assets directory to copy
|
# Icons are embedded in the Go binary, so there is no assets directory to copy
|
||||||
# after extracting the Linux executable.
|
# after extracting the Linux executable.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -5,15 +5,18 @@ set -euo pipefail
|
|||||||
# image contains Linux/Fyne dependencies for amd64 and arm64, plus the MinGW
|
# image contains Linux/Fyne dependencies for amd64 and arm64, plus the MinGW
|
||||||
# cross-compiler used for the Windows GUI executable. Actual build commands live
|
# cross-compiler used for the Windows GUI executable. Actual build commands live
|
||||||
# here rather than in Dockerfile so target selection does not require rebuilding
|
# here rather than in Dockerfile so target selection does not require rebuilding
|
||||||
# the image.
|
|
||||||
tag="gitea.mixdep.ru/mix/pysentry-builder"
|
|
||||||
|
|
||||||
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
repo_root="$(cd "${script_dir}/.." && pwd)"
|
repo_root="$(cd "${script_dir}/.." && pwd)"
|
||||||
cd "$repo_root"
|
cd "$repo_root"
|
||||||
|
|
||||||
version="$(sed -n 's/^var Version = "\(.*\)"/\1/p' src/core/version.go)"
|
version="$(sed -n 's/^var Version = "\(.*\)"/\1/p' src/core/version.go)"
|
||||||
version="${version:-0.0.0-dev}"
|
version="${version:-0.0.0-dev}"
|
||||||
|
tag="gitea.mixdep.ru/mix/gosentry-builder:${version}"
|
||||||
|
|
||||||
|
docker_user_args=()
|
||||||
|
if command -v id >/dev/null 2>&1; then
|
||||||
|
docker_user_args=(--user "$(id -u):$(id -g)")
|
||||||
|
fi
|
||||||
|
|
||||||
usage() {
|
usage() {
|
||||||
cat <<EOF
|
cat <<EOF
|
||||||
@@ -21,9 +24,9 @@ Usage: $0 [target...]
|
|||||||
|
|
||||||
Targets:
|
Targets:
|
||||||
all Build every release artifact.
|
all Build every release artifact.
|
||||||
linux-amd64 Build dist/linux/pysentry-${version}-linux-amd64.
|
linux-amd64 Build dist/linux/gosentry-${version}-linux-amd64.
|
||||||
linux-arm64 Build dist/linux/pysentry-${version}-linux-arm64.
|
linux-arm64 Build dist/linux/gosentry-${version}-linux-arm64.
|
||||||
windows-amd64 Build dist/windows/pysentry-${version}-windows-amd64.exe.
|
windows-amd64 Build dist/windows/gosentry-${version}-windows-amd64.exe.
|
||||||
|
|
||||||
When no target is passed and the script runs in a terminal, it asks what to build.
|
When no target is passed and the script runs in a terminal, it asks what to build.
|
||||||
EOF
|
EOF
|
||||||
@@ -86,23 +89,25 @@ normalize_targets() {
|
|||||||
|
|
||||||
run_in_builder() {
|
run_in_builder() {
|
||||||
docker run --rm \
|
docker run --rm \
|
||||||
|
"${docker_user_args[@]}" \
|
||||||
-e "VERSION=${version}" \
|
-e "VERSION=${version}" \
|
||||||
|
-e "GOCACHE=/tmp/go-build-cache" \
|
||||||
-v "${repo_root}:/src" \
|
-v "${repo_root}:/src" \
|
||||||
-w /src \
|
-w /src \
|
||||||
"$tag" \
|
"$tag" \
|
||||||
bash -lc "$1"
|
bash -c "$1"
|
||||||
}
|
}
|
||||||
|
|
||||||
build_linux_amd64() {
|
build_linux_amd64() {
|
||||||
run_in_builder 'mkdir -p dist/linux && CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags "-s -w -X github.com/pysentry/pysentry/src/core.Version=${VERSION}" -o "dist/linux/pysentry-${VERSION}-linux-amd64" ./cmd/pysentry'
|
run_in_builder 'mkdir -p dist/linux && CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build -buildvcs=false -trimpath -ldflags "-s -w -X gitea.mixdep.ru/mix/gosentry/src/core.Version=${VERSION}" -o "dist/linux/gosentry-${VERSION}-linux-amd64" ./cmd/gosentry'
|
||||||
}
|
}
|
||||||
|
|
||||||
build_linux_arm64() {
|
build_linux_arm64() {
|
||||||
run_in_builder 'mkdir -p dist/linux && CC=aarch64-linux-gnu-gcc CGO_ENABLED=1 GOOS=linux GOARCH=arm64 CGO_CFLAGS="--sysroot=/ -I/usr/include/aarch64-linux-gnu" CGO_LDFLAGS="--sysroot=/ -L/usr/lib/aarch64-linux-gnu" PKG_CONFIG_LIBDIR=/usr/lib/aarch64-linux-gnu/pkgconfig go build -trimpath -ldflags "-s -w -X github.com/pysentry/pysentry/src/core.Version=${VERSION}" -o "dist/linux/pysentry-${VERSION}-linux-arm64" ./cmd/pysentry'
|
run_in_builder 'mkdir -p dist/linux && CC=aarch64-linux-gnu-gcc CGO_ENABLED=1 GOOS=linux GOARCH=arm64 CGO_CFLAGS="--sysroot=/ -I/usr/include/aarch64-linux-gnu" CGO_LDFLAGS="--sysroot=/ -L/usr/lib/aarch64-linux-gnu" PKG_CONFIG_LIBDIR=/usr/lib/aarch64-linux-gnu/pkgconfig go build -buildvcs=false -trimpath -ldflags "-s -w -X gitea.mixdep.ru/mix/gosentry/src/core.Version=${VERSION}" -o "dist/linux/gosentry-${VERSION}-linux-arm64" ./cmd/gosentry'
|
||||||
}
|
}
|
||||||
|
|
||||||
build_windows_amd64() {
|
build_windows_amd64() {
|
||||||
run_in_builder 'mkdir -p dist/windows && x86_64-w64-mingw32-windres -O coff -o cmd/pysentry/rsrc_windows_amd64.syso packaging/windows/pysentry.rc && CC=x86_64-w64-mingw32-gcc CGO_ENABLED=1 GOOS=windows GOARCH=amd64 go build -trimpath -ldflags "-s -w -H=windowsgui -X github.com/pysentry/pysentry/src/core.Version=${VERSION}" -o "dist/windows/pysentry-${VERSION}-windows-amd64.exe" ./cmd/pysentry'
|
run_in_builder 'mkdir -p dist/windows && x86_64-w64-mingw32-windres -O coff -o cmd/gosentry/rsrc_windows_amd64.syso packaging/windows/gosentry.rc && CC=x86_64-w64-mingw32-gcc CGO_ENABLED=1 GOOS=windows GOARCH=amd64 go build -buildvcs=false -trimpath -ldflags "-s -w -H=windowsgui -X gitea.mixdep.ru/mix/gosentry/src/core.Version=${VERSION}" -o "dist/windows/gosentry-${VERSION}-windows-amd64.exe" ./cmd/gosentry'
|
||||||
}
|
}
|
||||||
|
|
||||||
mapfile -t targets < <(choose_targets "$@" | normalize_targets | awk '!seen[$0]++')
|
mapfile -t targets < <(choose_targets "$@" | normalize_targets | awk '!seen[$0]++')
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+140
-46
@@ -7,76 +7,82 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
const autostartUnitName = "pysentry.service"
|
const autostartDesktopFileName = "gosentry.desktop"
|
||||||
|
const legacyAutostartDesktopFileName = "pysentry.desktop"
|
||||||
|
|
||||||
func SetAutostart(enabled bool, executablePath string) error {
|
func SetAutostart(enabled bool, executablePath string, iconPath string) error {
|
||||||
unitDir, err := userSystemdDir()
|
desktopPath, err := autostartDesktopPath()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
unitPath := filepath.Join(unitDir, autostartUnitName)
|
// A desktop scheduler with a tray icon belongs to the graphical session, so
|
||||||
|
// Linux autostart is implemented through XDG Autostart instead of a systemd
|
||||||
if enabled {
|
// user service. systemd is tempting because it is explicit and scriptable,
|
||||||
if err := os.MkdirAll(unitDir, 0o755); err != nil {
|
// but it is the wrong owner for a windowed app that should inherit the
|
||||||
return err
|
// desktop session environment and appear in the tray predictably.
|
||||||
}
|
if err := cleanupLegacySystemdAutostart(); err != nil {
|
||||||
unit := fmt.Sprintf(`[Unit]
|
|
||||||
Description=PySentry desktop scheduler
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
ExecStart=%s
|
|
||||||
Restart=on-failure
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=default.target
|
|
||||||
`, executablePath)
|
|
||||||
if err := os.WriteFile(unitPath, []byte(unit), 0o644); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := exec.Command("systemctl", "--user", "daemon-reload").Run(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return exec.Command("systemctl", "--user", "enable", "--now", autostartUnitName).Run()
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = exec.Command("systemctl", "--user", "disable", "--now", autostartUnitName).Run()
|
|
||||||
if err := os.Remove(unitPath); err != nil && !os.IsNotExist(err) {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return exec.Command("systemctl", "--user", "daemon-reload").Run()
|
if err := cleanupLegacyDesktopAutostart(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if enabled {
|
||||||
|
if err := os.MkdirAll(filepath.Dir(desktopPath), 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
desktopFile := fmt.Sprintf(`[Desktop Entry]
|
||||||
|
Type=Application
|
||||||
|
Name=GoSentry
|
||||||
|
Comment=GoSentry desktop scheduler
|
||||||
|
Exec=%s %s
|
||||||
|
%s
|
||||||
|
Terminal=false
|
||||||
|
X-GNOME-Autostart-enabled=true
|
||||||
|
`, quoteDesktopExec(executablePath), StartInTrayArgument, desktopIconLine(iconPath))
|
||||||
|
return os.WriteFile(desktopPath, []byte(desktopFile), 0o644)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.Remove(desktopPath); err != nil && !os.IsNotExist(err) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func AutostartStatus(expectedEnabled bool, executablePath string) (bool, string) {
|
func AutostartStatus(expectedEnabled bool, executablePath string) (bool, string) {
|
||||||
unitDir, err := userSystemdDir()
|
desktopPath, err := autostartDesktopPath()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, "Cannot resolve user systemd directory"
|
return false, "Cannot resolve XDG autostart directory"
|
||||||
}
|
}
|
||||||
unitPath := filepath.Join(unitDir, autostartUnitName)
|
if legacySystemdAutostartExists() {
|
||||||
data, readErr := os.ReadFile(unitPath)
|
return false, "Legacy systemd autostart entry still exists"
|
||||||
enabledErr := exec.Command("systemctl", "--user", "is-enabled", "--quiet", autostartUnitName).Run()
|
}
|
||||||
|
if legacyDesktopAutostartExists() {
|
||||||
|
return false, "Legacy desktop autostart entry still exists"
|
||||||
|
}
|
||||||
|
data, readErr := os.ReadFile(desktopPath)
|
||||||
|
|
||||||
if !expectedEnabled {
|
if !expectedEnabled {
|
||||||
if os.IsNotExist(readErr) && enabledErr != nil {
|
if os.IsNotExist(readErr) {
|
||||||
return true, "Autostart is off"
|
return true, "Autostart is off"
|
||||||
}
|
}
|
||||||
return false, "Autostart unit exists while setting is off"
|
return false, "Autostart desktop entry exists while setting is off"
|
||||||
}
|
}
|
||||||
if readErr != nil {
|
if readErr != nil {
|
||||||
return false, "Autostart unit is missing"
|
return false, "Autostart desktop entry is missing"
|
||||||
}
|
}
|
||||||
if !strings.Contains(string(data), executablePath) {
|
expectedExec := "Exec=" + quoteDesktopExec(executablePath) + " " + StartInTrayArgument
|
||||||
return false, "Autostart unit points to another executable"
|
if !strings.Contains(string(data), expectedExec) {
|
||||||
}
|
return false, "Autostart desktop entry points to another executable"
|
||||||
if enabledErr != nil {
|
|
||||||
return false, "Autostart unit is not enabled"
|
|
||||||
}
|
}
|
||||||
return true, "Autostart is configured"
|
return true, "Autostart is configured"
|
||||||
}
|
}
|
||||||
|
|
||||||
func userSystemdDir() (string, error) {
|
func autostartDesktopPath() (string, error) {
|
||||||
configHome := os.Getenv("XDG_CONFIG_HOME")
|
configHome := os.Getenv("XDG_CONFIG_HOME")
|
||||||
if configHome == "" {
|
if configHome == "" {
|
||||||
home, err := os.UserHomeDir()
|
home, err := os.UserHomeDir()
|
||||||
@@ -85,5 +91,93 @@ func userSystemdDir() (string, error) {
|
|||||||
}
|
}
|
||||||
configHome = filepath.Join(home, ".config")
|
configHome = filepath.Join(home, ".config")
|
||||||
}
|
}
|
||||||
return filepath.Join(configHome, "systemd", "user"), nil
|
return filepath.Join(configHome, "autostart", autostartDesktopFileName), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func legacyAutostartDesktopPath() (string, error) {
|
||||||
|
configHome := os.Getenv("XDG_CONFIG_HOME")
|
||||||
|
if configHome == "" {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
configHome = filepath.Join(home, ".config")
|
||||||
|
}
|
||||||
|
return filepath.Join(configHome, "autostart", legacyAutostartDesktopFileName), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func quoteDesktopExec(path string) string {
|
||||||
|
return strconv.Quote(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func desktopIconLine(iconPath string) string {
|
||||||
|
if strings.TrimSpace(iconPath) == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return "Icon=" + iconPath
|
||||||
|
}
|
||||||
|
|
||||||
|
func cleanupLegacySystemdAutostart() error {
|
||||||
|
unitPath, err := legacySystemdUnitPath()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(unitPath); os.IsNotExist(err) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Older PySentry builds used a systemd user unit for autostart. The current
|
||||||
|
// GoSentry implementation uses XDG Autostart because it is a GUI/tray
|
||||||
|
// application and should be launched by the desktop session. Disable and
|
||||||
|
// remove the old unit so the two mechanisms do not fight or start duplicates.
|
||||||
|
_ = exec.Command("systemctl", "--user", "disable", "pysentry.service").Run()
|
||||||
|
if err := os.Remove(unitPath); err != nil && !os.IsNotExist(err) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_ = exec.Command("systemctl", "--user", "daemon-reload").Run()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func cleanupLegacyDesktopAutostart() error {
|
||||||
|
desktopPath, err := legacyAutostartDesktopPath()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// The old PySentry desktop file is removed proactively instead of tolerated
|
||||||
|
// alongside the new one. Leaving both files in place would risk duplicate
|
||||||
|
// launches or confusing status diagnostics after the rename.
|
||||||
|
if err := os.Remove(desktopPath); err != nil && !os.IsNotExist(err) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func legacyDesktopAutostartExists() bool {
|
||||||
|
desktopPath, err := legacyAutostartDesktopPath()
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
_, err = os.Stat(desktopPath)
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func legacySystemdAutostartExists() bool {
|
||||||
|
unitPath, err := legacySystemdUnitPath()
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
_, err = os.Stat(unitPath)
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func legacySystemdUnitPath() (string, error) {
|
||||||
|
configHome := os.Getenv("XDG_CONFIG_HOME")
|
||||||
|
if configHome == "" {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
configHome = filepath.Join(home, ".config")
|
||||||
|
}
|
||||||
|
return filepath.Join(configHome, "systemd", "user", "pysentry.service"), nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ package core
|
|||||||
|
|
||||||
import "fmt"
|
import "fmt"
|
||||||
|
|
||||||
func SetAutostart(enabled bool, executablePath string) error {
|
func SetAutostart(enabled bool, executablePath string, iconPath string) error {
|
||||||
if !enabled {
|
if !enabled {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
+196
-28
@@ -2,48 +2,216 @@ package core
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
const autostartName = "PySentry"
|
const autostartName = "GoSentry"
|
||||||
|
const legacyAutostartName = "PySentry"
|
||||||
|
const startupShortcutFile = autostartName + ".lnk"
|
||||||
|
|
||||||
func SetAutostart(enabled bool, executablePath string) error {
|
func SetAutostart(enabled bool, executablePath string, iconPath string) error {
|
||||||
if enabled {
|
// Windows autostart used to write HKCU\Run values, but that approach became
|
||||||
// Remove any stale entry first. This makes "uncheck, save, check, save"
|
// brittle once paths with spaces and the "--start-in-tray" argument entered
|
||||||
// and even a plain "check, save" repair an old path after the executable
|
// the picture. A Startup-folder shortcut stores target path and arguments as
|
||||||
// was moved or renamed for a new version.
|
// separate structured fields, so it avoids quoting bugs and more closely
|
||||||
deleteCommand := exec.Command("reg.exe", "delete", `HKCU\Software\Microsoft\Windows\CurrentVersion\Run`, "/v", autostartName, "/f")
|
// matches how a user would configure a GUI app by hand.
|
||||||
configureHiddenWindow(deleteCommand)
|
if err := cleanupLegacyRegistryAutostart(); err != nil {
|
||||||
_ = deleteCommand.Run()
|
return err
|
||||||
|
|
||||||
command := exec.Command("reg.exe", "add", `HKCU\Software\Microsoft\Windows\CurrentVersion\Run`, "/v", autostartName, "/t", "REG_SZ", "/d", fmt.Sprintf("%q", executablePath), "/f")
|
|
||||||
configureHiddenWindow(command)
|
|
||||||
return command.Run()
|
|
||||||
}
|
}
|
||||||
command := exec.Command("reg.exe", "delete", `HKCU\Software\Microsoft\Windows\CurrentVersion\Run`, "/v", autostartName, "/f")
|
|
||||||
configureHiddenWindow(command)
|
shortcutPath, err := startupShortcutPath()
|
||||||
_ = command.Run()
|
if err != nil {
|
||||||
return nil
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if enabled {
|
||||||
|
return createStartupShortcut(shortcutPath, executablePath, iconPath)
|
||||||
|
}
|
||||||
|
return removeIfExists(shortcutPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
func AutostartStatus(expectedEnabled bool, executablePath string) (bool, string) {
|
func AutostartStatus(expectedEnabled bool, executablePath string) (bool, string) {
|
||||||
command := exec.Command("reg.exe", "query", `HKCU\Software\Microsoft\Windows\CurrentVersion\Run`, "/v", autostartName)
|
shortcutPath, err := startupShortcutPath()
|
||||||
configureHiddenWindow(command)
|
if err != nil {
|
||||||
output, err := command.Output()
|
return false, "Startup folder cannot be resolved"
|
||||||
|
}
|
||||||
|
_, statErr := os.Stat(shortcutPath)
|
||||||
if !expectedEnabled {
|
if !expectedEnabled {
|
||||||
if err != nil {
|
if os.IsNotExist(statErr) {
|
||||||
|
if legacyRegistryAutostartExists() {
|
||||||
|
return false, "Legacy registry autostart exists; save settings to repair"
|
||||||
|
}
|
||||||
return true, "Autostart is off"
|
return true, "Autostart is off"
|
||||||
}
|
}
|
||||||
return false, "Autostart entry exists while setting is off"
|
if statErr != nil {
|
||||||
}
|
return false, "Autostart shortcut cannot be checked"
|
||||||
if err != nil {
|
}
|
||||||
return false, "Autostart entry is missing"
|
return false, "Autostart shortcut exists while setting is off"
|
||||||
}
|
}
|
||||||
|
|
||||||
text := strings.ReplaceAll(string(output), `"`, "")
|
if os.IsNotExist(statErr) {
|
||||||
if !strings.Contains(text, executablePath) {
|
if legacyRegistryAutostartExists() {
|
||||||
return false, "Autostart points to another executable"
|
return false, "Legacy registry autostart exists; save settings to repair"
|
||||||
|
}
|
||||||
|
return false, "Autostart shortcut is missing"
|
||||||
|
}
|
||||||
|
if statErr != nil {
|
||||||
|
return false, "Autostart shortcut cannot be checked"
|
||||||
|
}
|
||||||
|
|
||||||
|
actual, arguments, err := readShortcut(shortcutPath)
|
||||||
|
if err != nil {
|
||||||
|
return false, "Autostart shortcut cannot be read"
|
||||||
|
}
|
||||||
|
if !sameWindowsPath(actual, executablePath) {
|
||||||
|
return false, "Autostart shortcut points to another executable"
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(arguments) != StartInTrayArgument {
|
||||||
|
return false, "Autostart shortcut does not start in tray"
|
||||||
}
|
}
|
||||||
return true, "Autostart is configured"
|
return true, "Autostart is configured"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func startupShortcutPath() (string, error) {
|
||||||
|
appData := os.Getenv("APPDATA")
|
||||||
|
if appData == "" {
|
||||||
|
return "", fmt.Errorf("APPDATA is not set")
|
||||||
|
}
|
||||||
|
return filepath.Join(appData, "Microsoft", "Windows", "Start Menu", "Programs", "Startup", startupShortcutFile), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func createStartupShortcut(shortcutPath string, executablePath string, iconPath string) error {
|
||||||
|
if err := os.MkdirAll(filepath.Dir(shortcutPath), 0755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
workingDirectory := filepath.Dir(executablePath)
|
||||||
|
if iconPath == "" {
|
||||||
|
iconPath = executablePath
|
||||||
|
}
|
||||||
|
// WScript.Shell is used here deliberately instead of a third-party Go COM
|
||||||
|
// wrapper. The PowerShell bridge is not glamorous, but it is already present
|
||||||
|
// on supported Windows systems and keeps the dependency surface much smaller
|
||||||
|
// for a project that otherwise aims to stay light.
|
||||||
|
script := `$shell = New-Object -ComObject WScript.Shell; $shortcut = $shell.CreateShortcut($env:GOSENTRY_SHORTCUT_PATH); $shortcut.TargetPath = $env:GOSENTRY_TARGET_PATH; $shortcut.Arguments = $env:GOSENTRY_ARGUMENTS; $shortcut.WorkingDirectory = $env:GOSENTRY_WORKING_DIRECTORY; $shortcut.IconLocation = $env:GOSENTRY_ICON_PATH; $shortcut.Save()`
|
||||||
|
command := exec.Command("powershell.exe", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", script)
|
||||||
|
command.Env = append(os.Environ(),
|
||||||
|
"GOSENTRY_SHORTCUT_PATH="+shortcutPath,
|
||||||
|
"GOSENTRY_TARGET_PATH="+executablePath,
|
||||||
|
"GOSENTRY_ARGUMENTS="+StartInTrayArgument,
|
||||||
|
"GOSENTRY_WORKING_DIRECTORY="+workingDirectory,
|
||||||
|
"GOSENTRY_ICON_PATH="+iconPath,
|
||||||
|
)
|
||||||
|
configureHiddenWindow(command)
|
||||||
|
if output, err := command.CombinedOutput(); err != nil {
|
||||||
|
return fmt.Errorf("create startup shortcut: %w: %s", err, strings.TrimSpace(string(output)))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func readShortcut(shortcutPath string) (string, string, error) {
|
||||||
|
// Force UTF-8 before writing the path. PowerShell defaults to the system
|
||||||
|
// OEM code page (e.g. CP866 on Russian Windows). Without this override,
|
||||||
|
// [Console]::Out.Write encodes Cyrillic and other non-ASCII characters as
|
||||||
|
// OEM bytes; Go then reads them as UTF-8 and gets a different string from
|
||||||
|
// os.Executable, causing AutostartStatus to report "shortcut points to
|
||||||
|
// another executable" for any install path that contains non-ASCII chars.
|
||||||
|
// New-Object System.Text.UTF8Encoding($false) is UTF-8 without BOM.
|
||||||
|
script := `[Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false); $shell = New-Object -ComObject WScript.Shell; $shortcut = $shell.CreateShortcut($env:GOSENTRY_SHORTCUT_PATH); [Console]::Out.Write($shortcut.TargetPath + [Environment]::NewLine + $shortcut.Arguments)`
|
||||||
|
command := exec.Command("powershell.exe", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", script)
|
||||||
|
command.Env = append(os.Environ(), "GOSENTRY_SHORTCUT_PATH="+shortcutPath)
|
||||||
|
configureHiddenWindow(command)
|
||||||
|
output, err := command.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("read startup shortcut: %w: %s", err, strings.TrimSpace(string(output)))
|
||||||
|
}
|
||||||
|
lines := strings.SplitN(string(output), "\n", 2)
|
||||||
|
target := strings.TrimSpace(lines[0])
|
||||||
|
arguments := ""
|
||||||
|
if len(lines) > 1 {
|
||||||
|
arguments = strings.TrimSpace(lines[1])
|
||||||
|
}
|
||||||
|
return target, arguments, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func readShortcutTarget(shortcutPath string) (string, error) {
|
||||||
|
target, _, err := readShortcut(shortcutPath)
|
||||||
|
return target, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeIfExists(path string) error {
|
||||||
|
err := os.Remove(path)
|
||||||
|
if err == nil || os.IsNotExist(err) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func cleanupLegacyRegistryAutostart() error {
|
||||||
|
for _, name := range []string{legacyAutostartName, autostartName} {
|
||||||
|
command := exec.Command("reg.exe", "delete", `HKCU\Software\Microsoft\Windows\CurrentVersion\Run`, "/v", name, "/f")
|
||||||
|
configureHiddenWindow(command)
|
||||||
|
_ = command.Run()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func legacyRegistryAutostartExists() bool {
|
||||||
|
for _, name := range []string{legacyAutostartName, autostartName} {
|
||||||
|
command := exec.Command("reg.exe", "query", `HKCU\Software\Microsoft\Windows\CurrentVersion\Run`, "/v", name)
|
||||||
|
configureHiddenWindow(command)
|
||||||
|
if command.Run() == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseRegistryRunValue(output string) (string, bool) {
|
||||||
|
for _, line := range strings.Split(output, "\n") {
|
||||||
|
fields := strings.Fields(strings.TrimSpace(line))
|
||||||
|
for index, field := range fields {
|
||||||
|
if field == "REG_SZ" && index+1 < len(fields) {
|
||||||
|
value := strings.Join(fields[index+1:], " ")
|
||||||
|
value = strings.Trim(value, `"`)
|
||||||
|
return value, value != ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
func sameWindowsPath(left string, right string) bool {
|
||||||
|
left = normalizeWindowsPath(left)
|
||||||
|
right = normalizeWindowsPath(right)
|
||||||
|
if strings.EqualFold(left, right) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// If the string comparison fails, compare by filesystem object identity.
|
||||||
|
// os.SameFile uses the volume serial number and file index on Windows, so
|
||||||
|
// it correctly handles cases where one path uses an NTFS 8.3 short name
|
||||||
|
// while the other uses the long name. Windows generates 8.3 names for
|
||||||
|
// directory entries that contain spaces; when the process is launched via
|
||||||
|
// a Startup-folder shortcut the OS may resolve the PIDL to the short-name
|
||||||
|
// form, so os.Executable can return a different string than WScript reads
|
||||||
|
// back from TargetPath even though both point to the same file. The same
|
||||||
|
// fallback also covers directory junction points.
|
||||||
|
leftInfo, leftErr := os.Lstat(left)
|
||||||
|
rightInfo, rightErr := os.Lstat(right)
|
||||||
|
if leftErr == nil && rightErr == nil {
|
||||||
|
return os.SameFile(leftInfo, rightInfo)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeWindowsPath(p string) string {
|
||||||
|
p = strings.Trim(p, `"`)
|
||||||
|
// filepath.Clean preserves the \\?\ extended-length device path prefix that
|
||||||
|
// Windows adds for paths exceeding MAX_PATH. Strip it so the cleaned result
|
||||||
|
// compares equal to the same path without the prefix.
|
||||||
|
p = strings.TrimPrefix(p, `\\?\`)
|
||||||
|
return filepath.Clean(p)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
//go:build !linux
|
||||||
|
|
||||||
|
package core
|
||||||
|
|
||||||
|
func InstallDesktopIntegration(appID string, executablePath string, icon []byte) (string, error) {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
+21
-13
@@ -2,7 +2,12 @@ package core
|
|||||||
|
|
||||||
import "time"
|
import "time"
|
||||||
|
|
||||||
// Config is stored in pysentry.yaml next to the program. It contains only
|
// StartInTrayArgument is written to the Windows Startup shortcut so autostart
|
||||||
|
// can keep the scheduler running without flashing the main window. Manual
|
||||||
|
// launches omit this flag and open the normal window.
|
||||||
|
const StartInTrayArgument = "--start-in-tray"
|
||||||
|
|
||||||
|
// Config is stored in gosentry.yaml next to the program. It contains only
|
||||||
// application-level choices: where to read jobs from, where to write logs, and
|
// application-level choices: where to read jobs from, where to write logs, and
|
||||||
// how the desktop shell should behave.
|
// how the desktop shell should behave.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
@@ -24,20 +29,23 @@ type JobsFile struct {
|
|||||||
// Job is the user-visible scheduled command.
|
// Job is the user-visible scheduled command.
|
||||||
//
|
//
|
||||||
// Fields with yaml:"-" are deliberately runtime-only. They are useful in the GUI
|
// Fields with yaml:"-" are deliberately runtime-only. They are useful in the GUI
|
||||||
// while PySentry is running, but writing them to jobs.yaml would make the jobs
|
// while GoSentry is running, but writing them to jobs.yaml would make the jobs
|
||||||
// file noisy and would mix durable configuration with transient execution state.
|
// file noisy and would mix durable configuration with transient execution state.
|
||||||
type Job struct {
|
type Job struct {
|
||||||
ID int `yaml:"id"`
|
ID int `yaml:"id"`
|
||||||
Name string `yaml:"name"`
|
Name string `yaml:"name"`
|
||||||
Folder string `yaml:"folder,omitempty"`
|
Folder string `yaml:"folder,omitempty"`
|
||||||
Schedule string `yaml:"schedule"`
|
Schedule string `yaml:"schedule"`
|
||||||
Command string `yaml:"command"`
|
Command string `yaml:"command"`
|
||||||
Enabled bool `yaml:"enabled"`
|
Arguments string `yaml:"arguments,omitempty"`
|
||||||
LastRun string `yaml:"-"`
|
SuccessExitCodes string `yaml:"success_exit_codes,omitempty"`
|
||||||
NextRun string `yaml:"-"`
|
StartOnly bool `yaml:"start_only,omitempty"`
|
||||||
LastState string `yaml:"-"`
|
Enabled bool `yaml:"enabled"`
|
||||||
Logs []RunRecord `yaml:"-"`
|
LastRun string `yaml:"-"`
|
||||||
Output string `yaml:"-"`
|
NextRun string `yaml:"-"`
|
||||||
|
LastState string `yaml:"-"`
|
||||||
|
Logs []RunRecord `yaml:"-"`
|
||||||
|
Output string `yaml:"-"`
|
||||||
|
|
||||||
// nextDue is kept as time.Time for scheduler comparisons. The formatted
|
// nextDue is kept as time.Time for scheduler comparisons. The formatted
|
||||||
// NextRun string above exists only for display in the GUI and YAML rewriting
|
// NextRun string above exists only for display in the GUI and YAML rewriting
|
||||||
|
|||||||
+6
-1
@@ -8,7 +8,11 @@ import (
|
|||||||
const (
|
const (
|
||||||
// The config file stays beside the executable so the portable build behaves
|
// The config file stays beside the executable so the portable build behaves
|
||||||
// predictably: moving the program folder moves its settings with it.
|
// predictably: moving the program folder moves its settings with it.
|
||||||
ConfigFileName = "pysentry.yaml"
|
ConfigFileName = "gosentry.yaml"
|
||||||
|
// Older builds were named PySentry. Keep the old config name readable during
|
||||||
|
// the rename window so portable installations can start once and rewrite the
|
||||||
|
// settings to gosentry.yaml without manual file copying.
|
||||||
|
LegacyConfigFileName = "pysentry.yaml"
|
||||||
// Jobs are kept in a separate YAML file because the user can choose a
|
// Jobs are kept in a separate YAML file because the user can choose a
|
||||||
// different jobs directory, while application settings remain local to the
|
// different jobs directory, while application settings remain local to the
|
||||||
// installed/copied program.
|
// installed/copied program.
|
||||||
@@ -25,6 +29,7 @@ type Paths struct {
|
|||||||
JobsDir string
|
JobsDir string
|
||||||
JobsPath string
|
JobsPath string
|
||||||
LogsDir string
|
LogsDir string
|
||||||
|
DesktopIcon string
|
||||||
}
|
}
|
||||||
|
|
||||||
func ResolvePaths() (Paths, error) {
|
func ResolvePaths() (Paths, error) {
|
||||||
|
|||||||
+174
-33
@@ -8,14 +8,15 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
|
||||||
"sort"
|
"sort"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
"unicode"
|
"unicode"
|
||||||
)
|
)
|
||||||
|
|
||||||
const commandTimeout = 30 * time.Second
|
const commandTimeout = 30 * time.Second
|
||||||
|
const commandWaitDelay = 2 * time.Second
|
||||||
|
|
||||||
func RunJob(ctx context.Context, job *Job, trigger string, logsDir string) RunRecord {
|
func RunJob(ctx context.Context, job *Job, trigger string, logsDir string) RunRecord {
|
||||||
started := time.Now()
|
started := time.Now()
|
||||||
@@ -26,30 +27,28 @@ func RunJob(ctx context.Context, job *Job, trigger string, logsDir string) RunRe
|
|||||||
runCtx, cancel := context.WithTimeout(ctx, commandTimeout)
|
runCtx, cancel := context.WithTimeout(ctx, commandTimeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
// The command is executed through the platform shell so users can type the
|
|
||||||
// same command they would test manually in cmd.exe or sh. This is less strict
|
|
||||||
// than argv-based execution, but it is the expected behavior for a cron-like
|
|
||||||
// tool that supports redirection, environment expansion, and shell builtins.
|
|
||||||
command := shellCommand(runCtx, job.Command)
|
|
||||||
configureHiddenWindow(command)
|
|
||||||
var stdout bytes.Buffer
|
var stdout bytes.Buffer
|
||||||
var stderr bytes.Buffer
|
var stderr bytes.Buffer
|
||||||
command.Stdout = &stdout
|
var output string
|
||||||
command.Stderr = &stderr
|
var state string
|
||||||
|
var detail string
|
||||||
err := command.Run()
|
if job.StartOnly {
|
||||||
duration := time.Since(started).Round(time.Millisecond)
|
invocation := jobInvocation(context.Background(), *job)
|
||||||
output := formatOutput(stdout.String(), stderr.String())
|
state, detail, output = startJobOnly(invocation, *job, started)
|
||||||
|
} else {
|
||||||
state := "OK"
|
invocation := jobInvocation(runCtx, *job)
|
||||||
detail := fmt.Sprintf("Completed in %s", duration)
|
command := invocation.command
|
||||||
if err != nil {
|
command.WaitDelay = commandWaitDelay
|
||||||
state = "Failed"
|
if invocation.hideWindow {
|
||||||
if errors.Is(runCtx.Err(), context.DeadlineExceeded) {
|
configureHiddenWindow(command)
|
||||||
detail = fmt.Sprintf("Timed out after %s", commandTimeout)
|
|
||||||
} else {
|
|
||||||
detail = err.Error()
|
|
||||||
}
|
}
|
||||||
|
command.Stdout = &stdout
|
||||||
|
command.Stderr = &stderr
|
||||||
|
|
||||||
|
err := command.Run()
|
||||||
|
duration := time.Since(started).Round(time.Millisecond)
|
||||||
|
output = formatOutput(stdout.String(), stderr.String())
|
||||||
|
state, detail = runStateDetail(err, runCtx.Err(), duration, *job)
|
||||||
}
|
}
|
||||||
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
@@ -94,7 +93,7 @@ func CleanupLogs(logsDir string, maxFiles int, maxAgeDays int) error {
|
|||||||
var logs []logFile
|
var logs []logFile
|
||||||
cutoff := time.Now().AddDate(0, 0, -maxAgeDays)
|
cutoff := time.Now().AddDate(0, 0, -maxAgeDays)
|
||||||
for _, entry := range entries {
|
for _, entry := range entries {
|
||||||
// Only PySentry run logs are managed here. Directories and non-.log files
|
// Only GoSentry run logs are managed here. Directories and non-.log files
|
||||||
// are intentionally ignored so the user can keep notes or other artifacts
|
// are intentionally ignored so the user can keep notes or other artifacts
|
||||||
// in the same folder without the cleanup policy deleting them.
|
// in the same folder without the cleanup policy deleting them.
|
||||||
if entry.IsDir() || !strings.HasSuffix(strings.ToLower(entry.Name()), ".log") {
|
if entry.IsDir() || !strings.HasSuffix(strings.ToLower(entry.Name()), ".log") {
|
||||||
@@ -141,8 +140,8 @@ func writeRunLog(logsDir string, job Job, trigger string, state string, detail s
|
|||||||
// avoid characters that are invalid on Windows or awkward on shells.
|
// avoid characters that are invalid on Windows or awkward on shells.
|
||||||
fileName := started.Format("20060102-150405") + "_" + sanitizeFileName(job.Name) + ".log"
|
fileName := started.Format("20060102-150405") + "_" + sanitizeFileName(job.Name) + ".log"
|
||||||
path := filepath.Join(logsDir, fileName)
|
path := filepath.Join(logsDir, fileName)
|
||||||
content := fmt.Sprintf("time: %s\njob_id: %d\njob_name: %s\ntrigger: %s\nstate: %s\ndetail: %s\ncommand: %s\n\n%s\n",
|
content := fmt.Sprintf("time: %s\njob_id: %d\njob_name: %s\ntrigger: %s\nstate: %s\ndetail: %s\ncommand: %s\narguments: %s\nsuccess_exit_codes: %s\nstart_only: %t\n\n%s\n",
|
||||||
started.Format("2006-01-02 15:04:05"), job.ID, job.Name, trigger, state, detail, job.Command, output)
|
started.Format("2006-01-02 15:04:05"), job.ID, job.Name, trigger, state, detail, job.Command, logArguments(job.Arguments), successExitCodesText(job), job.StartOnly, output)
|
||||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
@@ -172,15 +171,157 @@ func sanitizeFileName(name string) string {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
func shellCommand(ctx context.Context, command string) *exec.Cmd {
|
func startJobOnly(invocation commandInvocation, job Job, started time.Time) (string, string, string) {
|
||||||
if runtime.GOOS == "windows" {
|
command := invocation.command
|
||||||
// cmd.exe /C preserves Windows users' expectations for commands such as
|
if invocation.hideWindow {
|
||||||
// "dir", "copy", variable expansion, and .bat/.cmd wrappers.
|
configureHiddenWindow(command)
|
||||||
return exec.CommandContext(ctx, "cmd.exe", "/C", command)
|
|
||||||
}
|
}
|
||||||
// sh -c is the portable baseline for Linux builds. It keeps the runner small
|
err := command.Start()
|
||||||
// and avoids a hard dependency on a larger shell such as bash.
|
duration := time.Since(started).Round(time.Millisecond)
|
||||||
return exec.CommandContext(ctx, "sh", "-c", command)
|
if err != nil {
|
||||||
|
return "Failed", fmt.Sprintf("%T: %v", err, err), startOnlyOutput(job, 0)
|
||||||
|
}
|
||||||
|
pid := command.Process.Pid
|
||||||
|
if releaseErr := command.Process.Release(); releaseErr != nil {
|
||||||
|
return "Failed", fmt.Sprintf("process started with pid %d, but release failed: %T: %v", pid, releaseErr, releaseErr), startOnlyOutput(job, pid)
|
||||||
|
}
|
||||||
|
return "OK", fmt.Sprintf("Started in %s (pid %d); not waiting for process exit", duration, pid), startOnlyOutput(job, pid)
|
||||||
|
}
|
||||||
|
|
||||||
|
func startOnlyOutput(job Job, pid int) string {
|
||||||
|
var builder strings.Builder
|
||||||
|
builder.WriteString("status:\n")
|
||||||
|
if pid > 0 {
|
||||||
|
builder.WriteString(fmt.Sprintf("Started process pid %d. GoSentry is not waiting for it to exit.\n\n", pid))
|
||||||
|
} else {
|
||||||
|
builder.WriteString("Process did not start.\n\n")
|
||||||
|
}
|
||||||
|
builder.WriteString("command:\n")
|
||||||
|
builder.WriteString(job.Command + "\n\n")
|
||||||
|
builder.WriteString("arguments:\n")
|
||||||
|
builder.WriteString(logArguments(job.Arguments))
|
||||||
|
builder.WriteString("\n\nstart_only:\ntrue")
|
||||||
|
return builder.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func runStateDetail(err error, runErr error, duration time.Duration, job Job) (string, string) {
|
||||||
|
if err == nil {
|
||||||
|
return "OK", fmt.Sprintf("Completed in %s (exit code 0)", duration)
|
||||||
|
}
|
||||||
|
if errors.Is(runErr, context.DeadlineExceeded) {
|
||||||
|
return "Failed", fmt.Sprintf("Timed out after %s", commandTimeout)
|
||||||
|
}
|
||||||
|
if errors.Is(err, exec.ErrWaitDelay) {
|
||||||
|
return "OK", fmt.Sprintf("Completed; output capture stopped after %s because a child process kept the stream open", commandWaitDelay)
|
||||||
|
}
|
||||||
|
|
||||||
|
var exitError *exec.ExitError
|
||||||
|
if errors.As(err, &exitError) {
|
||||||
|
exitCode := exitError.ExitCode()
|
||||||
|
if acceptedExitCode(exitCode, job.SuccessExitCodes) {
|
||||||
|
return "OK", fmt.Sprintf("Completed in %s with accepted exit code %d", duration, exitCode)
|
||||||
|
}
|
||||||
|
return "Failed", fmt.Sprintf("Exit code %d is not in success_exit_codes (%s)", exitCode, successExitCodesText(job))
|
||||||
|
}
|
||||||
|
return "Failed", fmt.Sprintf("%T: %v", err, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func acceptedExitCode(exitCode int, successExitCodes string) bool {
|
||||||
|
for _, accepted := range parseExitCodes(successExitCodes) {
|
||||||
|
if exitCode == accepted {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseExitCodes(value string) []int {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if value == "" {
|
||||||
|
return []int{0}
|
||||||
|
}
|
||||||
|
fields := strings.FieldsFunc(value, func(r rune) bool {
|
||||||
|
return r == ',' || r == ';' || r == ' ' || r == '\t' || r == '\n' || r == '\r'
|
||||||
|
})
|
||||||
|
result := make([]int, 0, len(fields))
|
||||||
|
seen := map[int]bool{}
|
||||||
|
for _, field := range fields {
|
||||||
|
code, err := strconv.Atoi(strings.TrimSpace(field))
|
||||||
|
if err != nil || seen[code] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[code] = true
|
||||||
|
result = append(result, code)
|
||||||
|
}
|
||||||
|
if len(result) == 0 {
|
||||||
|
return []int{0}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func successExitCodesText(job Job) string {
|
||||||
|
codes := parseExitCodes(job.SuccessExitCodes)
|
||||||
|
parts := make([]string, 0, len(codes))
|
||||||
|
for _, code := range codes {
|
||||||
|
parts = append(parts, strconv.Itoa(code))
|
||||||
|
}
|
||||||
|
return strings.Join(parts, ",")
|
||||||
|
}
|
||||||
|
|
||||||
|
type commandInvocation struct {
|
||||||
|
command *exec.Cmd
|
||||||
|
hideWindow bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func jobInvocation(ctx context.Context, job Job) commandInvocation {
|
||||||
|
command := strings.TrimSpace(job.Command)
|
||||||
|
arguments := commandArguments(job.Arguments)
|
||||||
|
if len(arguments) > 0 || commandPathExists(command) {
|
||||||
|
return commandInvocation{
|
||||||
|
command: exec.CommandContext(ctx, unquoteCommandPath(command), arguments...),
|
||||||
|
hideWindow: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shell mode remains for existing jobs and for commands that intentionally
|
||||||
|
// use builtins, redirection, variables, or chained command syntax.
|
||||||
|
return commandInvocation{
|
||||||
|
command: shellCommand(ctx, command),
|
||||||
|
hideWindow: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func commandArguments(arguments string) []string {
|
||||||
|
var result []string
|
||||||
|
for _, line := range strings.FieldsFunc(arguments, func(r rune) bool {
|
||||||
|
return r == '\n' || r == '\r'
|
||||||
|
}) {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line != "" {
|
||||||
|
result = append(result, line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func commandPathExists(command string) bool {
|
||||||
|
command = unquoteCommandPath(strings.TrimSpace(command))
|
||||||
|
if command == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
info, err := os.Stat(command)
|
||||||
|
return err == nil && !info.IsDir()
|
||||||
|
}
|
||||||
|
|
||||||
|
func unquoteCommandPath(command string) string {
|
||||||
|
return strings.Trim(strings.TrimSpace(command), `"`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func logArguments(arguments string) string {
|
||||||
|
if strings.TrimSpace(arguments) == "" {
|
||||||
|
return "<empty>"
|
||||||
|
}
|
||||||
|
return strings.ReplaceAll(strings.TrimSpace(arguments), "\r\n", "\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
func formatOutput(stdout string, stderr string) string {
|
func formatOutput(stdout string, stderr string) string {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user