Compare commits
31 Commits
dev
..
079961e735
| Author | SHA1 | Date | |
|---|---|---|---|
| 079961e735 | |||
| cc294ce718 | |||
| 5f85af27e9 | |||
| d4b1238c5f | |||
| d06f130c5c | |||
| fd3e8baa0e | |||
| d202f8a94c | |||
| c1bd8c952c | |||
| e8e0060063 | |||
| 7252d3683c | |||
| 088f6e77b0 | |||
| 4a8feb351e | |||
| e016da5277 | |||
| c644636e57 | |||
| e2464aab0f | |||
| 5ef32566db | |||
| 2932783143 | |||
| 91158bf5b8 | |||
| ab75226cdb | |||
| 9214958fd0 | |||
| ddabfd2da2 | |||
| 91080a7a9d | |||
| 81b04d3dff | |||
| 5727e13f23 | |||
| 47e2ba7272 | |||
| 59718e21b4 | |||
| a9d1d9529e | |||
| 414be2dfe9 | |||
| 4c11bb4f06 | |||
| 0a66d9da0e | |||
| 4dfb3e5e40 |
@@ -0,0 +1,7 @@
|
|||||||
|
.git
|
||||||
|
bin
|
||||||
|
dist
|
||||||
|
logs
|
||||||
|
pysentry.yaml
|
||||||
|
jobs.yaml
|
||||||
|
*.exe
|
||||||
+16
-170
@@ -1,176 +1,22 @@
|
|||||||
# ---> Python
|
# Build outputs
|
||||||
# Byte-compiled / optimized / DLL files
|
|
||||||
__pycache__/
|
|
||||||
*.py[cod]
|
|
||||||
*$py.class
|
|
||||||
|
|
||||||
# C extensions
|
|
||||||
*.so
|
|
||||||
|
|
||||||
# Distribution / packaging
|
|
||||||
.Python
|
|
||||||
build/
|
|
||||||
develop-eggs/
|
|
||||||
dist/
|
dist/
|
||||||
downloads/
|
|
||||||
eggs/
|
|
||||||
.eggs/
|
|
||||||
lib/
|
|
||||||
lib64/
|
|
||||||
parts/
|
|
||||||
sdist/
|
|
||||||
var/
|
|
||||||
wheels/
|
|
||||||
share/python-wheels/
|
|
||||||
*.egg-info/
|
|
||||||
.installed.cfg
|
|
||||||
*.egg
|
|
||||||
MANIFEST
|
|
||||||
|
|
||||||
# PyInstaller
|
# Generated Windows resource compiled from packaging/windows/pysentry.rc.
|
||||||
# Usually these files are written by a python script from a template
|
cmd/pysentry/*.syso
|
||||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
|
||||||
*.manifest
|
|
||||||
*.spec
|
|
||||||
|
|
||||||
# Installer logs
|
# Local binaries that may be produced by ad-hoc go build commands.
|
||||||
pip-log.txt
|
*.exe
|
||||||
pip-delete-this-directory.txt
|
*.test
|
||||||
|
|
||||||
# Unit test / coverage reports
|
# Runtime files created next to the executable during local runs.
|
||||||
htmlcov/
|
pysentry.yaml
|
||||||
.tox/
|
jobs.yaml
|
||||||
.nox/
|
logs/
|
||||||
.coverage
|
|
||||||
.coverage.*
|
|
||||||
.cache
|
|
||||||
nosetests.xml
|
|
||||||
coverage.xml
|
|
||||||
*.cover
|
|
||||||
*.py,cover
|
|
||||||
.hypothesis/
|
|
||||||
.pytest_cache/
|
|
||||||
cover/
|
|
||||||
|
|
||||||
# Translations
|
# Go workspace/cache files that should stay local if a developer creates them.
|
||||||
*.mo
|
go.work
|
||||||
*.pot
|
go.work.sum
|
||||||
|
|
||||||
# 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_/
|
||||||
|
|||||||
+41
@@ -0,0 +1,41 @@
|
|||||||
|
FROM golang:1.22-bookworm
|
||||||
|
|
||||||
|
# Fyne links against native desktop libraries, so the container must include a C
|
||||||
|
# compiler plus OpenGL/X11 headers. --no-install-recommends keeps the image from
|
||||||
|
# pulling in unrelated desktop packages that are not needed for compilation.
|
||||||
|
RUN apt-get update && \
|
||||||
|
dpkg --add-architecture arm64 && \
|
||||||
|
apt-get update && \
|
||||||
|
apt-get install -y --no-install-recommends \
|
||||||
|
ca-certificates \
|
||||||
|
gcc \
|
||||||
|
libc6-dev \
|
||||||
|
gcc-aarch64-linux-gnu \
|
||||||
|
libc6-dev-arm64-cross \
|
||||||
|
linux-libc-dev-arm64-cross \
|
||||||
|
gcc-mingw-w64-x86-64 \
|
||||||
|
binutils-mingw-w64-x86-64 \
|
||||||
|
pkg-config \
|
||||||
|
libgl1-mesa-dev \
|
||||||
|
xorg-dev \
|
||||||
|
libgl1-mesa-dev:arm64 \
|
||||||
|
libx11-dev:arm64 \
|
||||||
|
libxcursor-dev:arm64 \
|
||||||
|
libxrandr-dev:arm64 \
|
||||||
|
libxinerama-dev:arm64 \
|
||||||
|
libxi-dev:arm64 \
|
||||||
|
libxxf86vm-dev:arm64 && \
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /src
|
||||||
|
|
||||||
|
# Copy module files first so Docker can cache downloaded dependencies while the
|
||||||
|
# application source changes. The release script later mounts the live repository
|
||||||
|
# over /src, but the module cache remains in the image and keeps repeated builds
|
||||||
|
# faster.
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
|
||||||
|
# The image intentionally stops here. Artifact build commands live in
|
||||||
|
# scripts/build-release-linux.sh so a developer can choose targets interactively
|
||||||
|
# without rebuilding this environment image for every selection.
|
||||||
@@ -1,2 +1,342 @@
|
|||||||
# PySentry
|
# PySentry
|
||||||
PySentry — это cron-подобный планировщик задач с графическим интерфейсом.
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
PySentry 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
|
||||||
|
|
||||||
|
- Native desktop GUI built with [Fyne](https://fyne.io/).
|
||||||
|
- Job storage in one clean YAML file.
|
||||||
|
- App settings in a separate YAML file.
|
||||||
|
- `@every` schedules and standard 5-field cron expressions.
|
||||||
|
- Manual and scheduled command runs.
|
||||||
|
- Per-run `.log` files with stdout/stderr.
|
||||||
|
- Log cleanup by maximum file count and maximum age.
|
||||||
|
- Global pause/resume for all job execution.
|
||||||
|
- Windows tray support.
|
||||||
|
- Version shown in the window title, Settings, and build artifact names.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
Common:
|
||||||
|
|
||||||
|
- [Go](https://go.dev/) 1.22 or newer.
|
||||||
|
|
||||||
|
Windows:
|
||||||
|
|
||||||
|
- MSYS2 with UCRT64 GCC in `C:\msys64\ucrt64\bin`.
|
||||||
|
|
||||||
|
Install these dependencies on Windows:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# 1. Install Go 1.22 or newer from https://go.dev/dl/.
|
||||||
|
# The default installer path is C:\Program Files\Go.
|
||||||
|
go version
|
||||||
|
|
||||||
|
# 2. Install MSYS2 from https://www.msys2.org/.
|
||||||
|
# Use the default installation path so UCRT64 tools are placed under
|
||||||
|
# C:\msys64\ucrt64\bin.
|
||||||
|
|
||||||
|
# 3. Open "MSYS2 UCRT64" from the Start menu and install GCC plus windres.
|
||||||
|
pacman -Syu
|
||||||
|
pacman -S --needed mingw-w64-ucrt-x86_64-gcc mingw-w64-ucrt-x86_64-binutils
|
||||||
|
|
||||||
|
# 4. In PowerShell, check that the compiler is available where the build script
|
||||||
|
# expects it. build-windows.bat prepends this directory automatically.
|
||||||
|
Test-Path C:\msys64\ucrt64\bin\gcc.exe
|
||||||
|
Test-Path C:\msys64\ucrt64\bin\windres.exe
|
||||||
|
```
|
||||||
|
|
||||||
|
Linux:
|
||||||
|
|
||||||
|
- A C compiler.
|
||||||
|
- [Fyne](https://fyne.io/) native build dependencies, including OpenGL/X11 development packages.
|
||||||
|
|
||||||
|
On Debian/Ubuntu, the Linux dependencies are typically:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Go builds the application, gcc is required by CGO/Fyne, and the OpenGL/X11
|
||||||
|
# development packages provide the native desktop headers used by Fyne.
|
||||||
|
sudo apt install golang gcc libgl1-mesa-dev xorg-dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
Windows:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Builds dist\windows\pysentry-<version>-windows-amd64.exe. The script changes
|
||||||
|
# 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
|
||||||
|
# when windres is available, and uses the Windows GUI subsystem so no console
|
||||||
|
# window opens at startup.
|
||||||
|
.\scripts\build-windows.bat
|
||||||
|
```
|
||||||
|
|
||||||
|
The Windows build is created as a GUI application, so it does not open a terminal window.
|
||||||
|
|
||||||
|
The binary is written to:
|
||||||
|
|
||||||
|
```text
|
||||||
|
# GUI executable produced by scripts\build-windows.bat.
|
||||||
|
dist\windows\pysentry-0.2.4-windows-amd64.exe
|
||||||
|
```
|
||||||
|
|
||||||
|
Linux:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Make the helper executable once, then build a linux/amd64 Fyne binary.
|
||||||
|
chmod +x ./scripts/build-linux.sh
|
||||||
|
./scripts/build-linux.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
The binary is written to:
|
||||||
|
|
||||||
|
```text
|
||||||
|
# Linux executable produced by scripts/build-linux.sh.
|
||||||
|
dist/linux/pysentry-0.2.4-linux-amd64
|
||||||
|
```
|
||||||
|
|
||||||
|
Linux using Docker:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Builds the Linux binary inside Docker using the versioned image tag
|
||||||
|
# gitea.mixdep.ru/mix/pysentry-builder:<version>. Useful from hosts or CI jobs
|
||||||
|
# where the native Linux/Fyne packages are not installed locally.
|
||||||
|
chmod +x ./scripts/build-linux-docker.sh
|
||||||
|
./scripts/build-linux-docker.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
The binary is copied to:
|
||||||
|
|
||||||
|
```text
|
||||||
|
# Linux executable copied out of the Docker build image.
|
||||||
|
dist\linux\pysentry-0.2.4-linux-amd64
|
||||||
|
```
|
||||||
|
|
||||||
|
Release build from Linux:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Interactively choose Linux amd64, Linux arm64, Windows amd64, or all artifacts
|
||||||
|
# from one Linux/Docker workflow. The Dockerfile contains the builder
|
||||||
|
# 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
|
||||||
|
./scripts/build-release-linux.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Non-interactive release builds can pass target names:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build only Linux arm64 and Windows amd64 artifacts.
|
||||||
|
./scripts/build-release-linux.sh linux-arm64 windows-amd64
|
||||||
|
```
|
||||||
|
|
||||||
|
The binaries are copied to:
|
||||||
|
|
||||||
|
```text
|
||||||
|
# Linux artifact.
|
||||||
|
dist/linux/pysentry-0.2.4-linux-amd64
|
||||||
|
|
||||||
|
# Linux arm64 artifact.
|
||||||
|
dist/linux/pysentry-0.2.4-linux-arm64
|
||||||
|
|
||||||
|
# Windows artifact cross-compiled from Linux.
|
||||||
|
dist/windows/pysentry-0.2.4-windows-amd64.exe
|
||||||
|
```
|
||||||
|
|
||||||
|
## Run From Source
|
||||||
|
|
||||||
|
Windows:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Fyne requires CGO on Windows. MSYS2 UCRT64 provides the C compiler and native
|
||||||
|
# libraries used by the desktop backend.
|
||||||
|
$env:Path = 'C:\msys64\ucrt64\bin;' + $env:Path
|
||||||
|
$env:CGO_ENABLED = '1'
|
||||||
|
|
||||||
|
# go run starts the app from source. Use scripts\build-windows.bat when you need
|
||||||
|
# a standalone .exe without a console window.
|
||||||
|
& 'C:\Program Files\Go\bin\go.exe' run ./cmd/pysentry
|
||||||
|
```
|
||||||
|
|
||||||
|
Linux:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# CGO must stay enabled because the Fyne GUI links against native Linux desktop
|
||||||
|
# libraries.
|
||||||
|
CGO_ENABLED=1 go run ./cmd/pysentry
|
||||||
|
```
|
||||||
|
|
||||||
|
## Storage
|
||||||
|
|
||||||
|
PySentry creates its runtime files next to the executable by default.
|
||||||
|
|
||||||
|
`pysentry.yaml` stores application settings:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# Directory containing jobs.yaml. "." means "the folder where the PySentry
|
||||||
|
# executable lives"; an absolute path can be used when jobs should live elsewhere.
|
||||||
|
jobs_dir: .
|
||||||
|
|
||||||
|
# Directory for per-run command output logs. Relative paths are resolved against
|
||||||
|
# the program folder, just like jobs_dir.
|
||||||
|
logs_dir: logs
|
||||||
|
|
||||||
|
# Keep at most this many .log files after cleanup. Newest logs are preserved.
|
||||||
|
max_log_files: 100
|
||||||
|
|
||||||
|
# Delete .log files older than this many days during cleanup.
|
||||||
|
max_log_age_days: 30
|
||||||
|
|
||||||
|
# Start PySentry automatically when the current desktop user signs in.
|
||||||
|
start_on_login: false
|
||||||
|
|
||||||
|
# Closing the window hides it to the tray instead of stopping the scheduler.
|
||||||
|
keep_running_in_tray: true
|
||||||
|
|
||||||
|
# Reserved for desktop failure notifications; the setting is stored now so the
|
||||||
|
# UI and config format do not need to change when notifications are wired fully.
|
||||||
|
notify_on_failure: true
|
||||||
|
```
|
||||||
|
|
||||||
|
`jobs.yaml` stores only job definitions:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
jobs:
|
||||||
|
# A harmless sample job created on first run so the scheduler can be tested
|
||||||
|
# immediately. Runtime fields such as last run time, next run time, and command
|
||||||
|
# output are intentionally not stored here; they are displayed in the GUI and
|
||||||
|
# written to separate log files.
|
||||||
|
- id: 1
|
||||||
|
# Human-readable name shown in the jobs list and used in log file names.
|
||||||
|
name: Hello scheduler
|
||||||
|
|
||||||
|
# Optional grouping label. Omit it or leave it empty to put the job under
|
||||||
|
# the "No folder" filter.
|
||||||
|
folder: Examples
|
||||||
|
|
||||||
|
# Either @every with a Go duration, or a standard five-field cron expression.
|
||||||
|
schedule: '@every 1m'
|
||||||
|
|
||||||
|
# Command passed to the platform shell: cmd.exe /C on Windows, sh -c on Linux.
|
||||||
|
command: echo PySentry test job: scheduler is alive
|
||||||
|
|
||||||
|
# Disabled jobs remain in jobs.yaml but are skipped by the scheduler.
|
||||||
|
enabled: true
|
||||||
|
```
|
||||||
|
|
||||||
|
Command output is written to separate files under `logs_dir`. File names include the run timestamp and job name, for example:
|
||||||
|
|
||||||
|
```text
|
||||||
|
# Format: YYYYMMDD-HHMMSS_<sanitized job name>.log
|
||||||
|
20260614-224306_Hello_scheduler.log
|
||||||
|
```
|
||||||
|
|
||||||
|
## Schedules
|
||||||
|
|
||||||
|
Fast interval schedules:
|
||||||
|
|
||||||
|
```text
|
||||||
|
# Go duration syntax after @every; useful for tests and simple intervals.
|
||||||
|
@every 10s
|
||||||
|
@every 5m
|
||||||
|
@every 1h30m
|
||||||
|
```
|
||||||
|
|
||||||
|
Standard 5-field cron schedules:
|
||||||
|
|
||||||
|
```text
|
||||||
|
# Standard five-field cron: minute hour day-of-month month day-of-week.
|
||||||
|
*/5 * * * * every five minutes
|
||||||
|
0 2 * * * every day at 02:00
|
||||||
|
30 9 * * 1-5 weekdays at 09:30
|
||||||
|
```
|
||||||
|
|
||||||
|
## Using The App
|
||||||
|
|
||||||
|
1. Start PySentry.
|
||||||
|
2. Use `New job` to create a command.
|
||||||
|
3. Set `Schedule`, `Command`, optional `Folder`, and `Enabled`.
|
||||||
|
4. Use `Run now` for a manual test run.
|
||||||
|
5. Use `Pause` to disable one job.
|
||||||
|
6. Use `Pause all` as a global stop switch.
|
||||||
|
7. Open `History` to see whether a run was `Manual`, `Schedule`, or `UI`.
|
||||||
|
8. Open `Settings` to change `jobs_dir`, `logs_dir`, and log cleanup limits. Use `Browse` to choose directories.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Autostart
|
||||||
|
|
||||||
|
PySentry is a user desktop application, not a system daemon, so autostart should be configured per user.
|
||||||
|
|
||||||
|
Linux:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
# PySentry writes an XDG Autostart desktop entry when Start on login is enabled.
|
||||||
|
# This is better for a GUI/tray application than a systemd user service because
|
||||||
|
# the desktop environment starts it inside the graphical user session.
|
||||||
|
# Saving the setting also removes the old ~/.config/systemd/user/pysentry.service
|
||||||
|
# unit if it was created by an earlier PySentry build.
|
||||||
|
~/.config/autostart/pysentry.desktop
|
||||||
|
|
||||||
|
[Desktop Entry]
|
||||||
|
Type=Application
|
||||||
|
Name=PySentry
|
||||||
|
Exec=/opt/pysentry/pysentry-0.2.4-linux-amd64
|
||||||
|
Terminal=false
|
||||||
|
```
|
||||||
|
|
||||||
|
Windows:
|
||||||
|
|
||||||
|
```text
|
||||||
|
# PySentry writes an HKCU Run entry when Start on login is enabled. It needs no
|
||||||
|
# administrator rights and starts PySentry when the current user signs in. Task
|
||||||
|
# Scheduler remains a later option if delayed start or elevated tasks become
|
||||||
|
# necessary. Saving settings with the checkbox enabled rewrites this entry, so it
|
||||||
|
# repairs an old path after the executable was moved or renamed.
|
||||||
|
HKCU\Software\Microsoft\Windows\CurrentVersion\Run\PySentry
|
||||||
|
```
|
||||||
|
|
||||||
|
## Project Layout
|
||||||
|
|
||||||
|
- `cmd/pysentry` starts the desktop app.
|
||||||
|
- `src/gui` contains the GUI.
|
||||||
|
- `src/core` contains YAML storage, command execution, scheduling, and log cleanup.
|
||||||
|
- `assets` contains app icons that are embedded into the application binary.
|
||||||
|
- `scripts` contains build helpers.
|
||||||
|
- `docs` contains architecture notes, the changelog, and the roadmap.
|
||||||
|
|
||||||
|
Build outputs are written to `dist/`. The old local `bin/` directory is not used.
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
PySentry keeps the direct dependency list intentionally small:
|
||||||
|
|
||||||
|
- [`fyne.io/fyne/v2`](https://fyne.io/) for the native GUI.
|
||||||
|
- `github.com/robfig/cron/v3` for cron schedule parsing.
|
||||||
|
- [`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.
|
||||||
|
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package assets
|
||||||
|
|
||||||
|
import (
|
||||||
|
_ "embed"
|
||||||
|
|
||||||
|
"fyne.io/fyne/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The application icon is embedded into the binary instead of being loaded from
|
||||||
|
// an assets directory at runtime. That keeps the Windows/Linux distribution to a
|
||||||
|
// single executable and avoids the common failure mode where the app starts with
|
||||||
|
// a generic icon because a sidecar PNG was not copied with the binary.
|
||||||
|
//
|
||||||
|
// The blank import enables the compiler directive below; no runtime package
|
||||||
|
// initialization from embed is required.
|
||||||
|
//
|
||||||
|
//go:embed pysentry-icon.png
|
||||||
|
var iconBytes []byte
|
||||||
|
|
||||||
|
func Icon() fyne.Resource {
|
||||||
|
// Fyne accepts resources from memory, so the same embedded PNG can be used
|
||||||
|
// 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
|
||||||
|
// resources rather than Fyne runtime state.
|
||||||
|
return fyne.NewStaticResource("pysentry-icon.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 |
@@ -0,0 +1,10 @@
|
|||||||
|
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,71 @@
|
|||||||
|
# PySentry Architecture
|
||||||
|
|
||||||
|
This document shows the current component interaction model. PySentry 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 Run / Linux desktop startup"]
|
||||||
|
config["pysentry.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/pysentry`, which calls the GUI package. The GUI
|
||||||
|
opens the store, loads `pysentry.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 the
|
||||||
|
current user's Run registry key. Linux uses a desktop-session startup entry.
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable PySentry changes are recorded in this file.
|
||||||
|
|
||||||
|
## 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 PySentry 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,69 @@
|
|||||||
|
# Roadmap
|
||||||
|
|
||||||
|
This file tracks planned PySentry work that is larger than a single bug fix.
|
||||||
|
|
||||||
|
## Project Rename
|
||||||
|
|
||||||
|
Plan a rename from PySentry to GoSentry.
|
||||||
|
|
||||||
|
Rename checklist:
|
||||||
|
|
||||||
|
- Decide the final repository path and Go module path.
|
||||||
|
- Update application name, window title, tray menu, and desktop integration text.
|
||||||
|
- Update app ID and autostart entry names.
|
||||||
|
- Rename build artifacts from `pysentry-*` to `gosentry-*`.
|
||||||
|
- Decide whether runtime files should stay backward-compatible with existing
|
||||||
|
`pysentry.yaml`, `jobs.yaml`, and log directories or migrate to new names.
|
||||||
|
- Update README, CHANGELOG, ROADMAP, build scripts, Docker image names, and
|
||||||
|
package metadata.
|
||||||
|
- Consider a transition note for users with existing PySentry configuration.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
## 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 `pysentry.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%\PySentry` on Windows, and XDG directories such as
|
||||||
|
`~/.config/pysentry` and `~/.local/share/pysentry` on Linux.
|
||||||
|
|
||||||
|
Initial priority:
|
||||||
|
|
||||||
|
1. Windows portable `.zip`.
|
||||||
|
2. Linux portable `.tar.gz` for amd64 and arm64.
|
||||||
|
3. Debian/Ubuntu `.deb`.
|
||||||
|
4. Windows installer.
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
module github.com/pysentry/pysentry
|
||||||
|
|
||||||
|
go 1.22
|
||||||
|
|
||||||
|
require (
|
||||||
|
fyne.io/fyne/v2 v2.5.3
|
||||||
|
github.com/robfig/cron/v3 v3.0.1
|
||||||
|
go.yaml.in/yaml/v4 v4.0.0-rc.5
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
fyne.io/systray v1.11.0 // indirect
|
||||||
|
github.com/BurntSushi/toml v1.4.0 // indirect
|
||||||
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
|
github.com/fredbi/uri v1.1.0 // indirect
|
||||||
|
github.com/fsnotify/fsnotify v1.7.0 // indirect
|
||||||
|
github.com/fyne-io/gl-js v0.0.0-20220119005834-d2da28d9ccfe // indirect
|
||||||
|
github.com/fyne-io/glfw-js v0.0.0-20241126112943-313d8a0fe1d0 // indirect
|
||||||
|
github.com/fyne-io/image v0.0.0-20220602074514-4956b0afb3d2 // indirect
|
||||||
|
github.com/go-gl/gl v0.0.0-20211210172815-726fda9656d6 // indirect
|
||||||
|
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a // indirect
|
||||||
|
github.com/go-text/render v0.2.0 // indirect
|
||||||
|
github.com/go-text/typesetting v0.2.0 // indirect
|
||||||
|
github.com/godbus/dbus/v5 v5.1.0 // indirect
|
||||||
|
github.com/gopherjs/gopherjs v1.17.2 // indirect
|
||||||
|
github.com/jeandeaual/go-locale v0.0.0-20240223122105-ce5225dcaa49 // indirect
|
||||||
|
github.com/jsummers/gobmp v0.0.0-20151104160322-e2ba15ffa76e // indirect
|
||||||
|
github.com/nicksnyder/go-i18n/v2 v2.4.0 // indirect
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
|
github.com/rymdport/portal v0.3.0 // indirect
|
||||||
|
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c // indirect
|
||||||
|
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef // indirect
|
||||||
|
github.com/stretchr/testify v1.8.4 // indirect
|
||||||
|
github.com/yuin/goldmark v1.7.1 // indirect
|
||||||
|
golang.org/x/image v0.18.0 // indirect
|
||||||
|
golang.org/x/mobile v0.0.0-20231127183840-76ac6878050a // indirect
|
||||||
|
golang.org/x/net v0.25.0 // indirect
|
||||||
|
golang.org/x/sys v0.20.0 // indirect
|
||||||
|
golang.org/x/text v0.16.0 // indirect
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
|
)
|
||||||
@@ -0,0 +1,660 @@
|
|||||||
|
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||||
|
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||||
|
cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU=
|
||||||
|
cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU=
|
||||||
|
cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY=
|
||||||
|
cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc=
|
||||||
|
cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0=
|
||||||
|
cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To=
|
||||||
|
cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4=
|
||||||
|
cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M=
|
||||||
|
cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc=
|
||||||
|
cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk=
|
||||||
|
cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs=
|
||||||
|
cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc=
|
||||||
|
cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY=
|
||||||
|
cloud.google.com/go v0.72.0/go.mod h1:M+5Vjvlc2wnp6tjzE102Dw08nGShTscUx2nZMufOKPI=
|
||||||
|
cloud.google.com/go v0.74.0/go.mod h1:VV1xSbzvo+9QJOxLDaJfTjx5e+MePCpCWwvftOeQmWk=
|
||||||
|
cloud.google.com/go v0.78.0/go.mod h1:QjdrLG0uq+YwhjoVOLsS1t7TW8fs36kLs4XO5R5ECHg=
|
||||||
|
cloud.google.com/go v0.79.0/go.mod h1:3bzgcEeQlzbuEAYu4mrWhKqWjmpprinYgKJLgKHnbb8=
|
||||||
|
cloud.google.com/go v0.81.0/go.mod h1:mk/AM35KwGk/Nm2YSeZbxXdrNK3KZOYHmLkOqC2V6E0=
|
||||||
|
cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=
|
||||||
|
cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE=
|
||||||
|
cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc=
|
||||||
|
cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg=
|
||||||
|
cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc=
|
||||||
|
cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ=
|
||||||
|
cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE=
|
||||||
|
cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk=
|
||||||
|
cloud.google.com/go/firestore v1.1.0/go.mod h1:ulACoGHTpvq5r8rxGJ4ddJZBZqakUQqClKRT5SZwBmk=
|
||||||
|
cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I=
|
||||||
|
cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw=
|
||||||
|
cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA=
|
||||||
|
cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU=
|
||||||
|
cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw=
|
||||||
|
cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos=
|
||||||
|
cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk=
|
||||||
|
cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs=
|
||||||
|
cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0=
|
||||||
|
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
|
||||||
|
fyne.io/fyne/v2 v2.5.3 h1:k6LjZx6EzRZhClsuzy6vucLZBstdH2USDGHSGWq8ly8=
|
||||||
|
fyne.io/fyne/v2 v2.5.3/go.mod h1:0GOXKqyvNwk3DLmsFu9v0oYM0ZcD1ysGnlHCerKoAmo=
|
||||||
|
fyne.io/systray v1.11.0 h1:D9HISlxSkx+jHSniMBR6fCFOUjk1x/OOOJLa9lJYAKg=
|
||||||
|
fyne.io/systray v1.11.0/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs=
|
||||||
|
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||||
|
github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0=
|
||||||
|
github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||||
|
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
|
||||||
|
github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
|
||||||
|
github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o=
|
||||||
|
github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY=
|
||||||
|
github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
|
||||||
|
github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs=
|
||||||
|
github.com/bketelsen/crypt v0.0.4/go.mod h1:aI6NrJ0pMGgvZKL1iVgXLnfIFJtfV+bKCoqOes/6LfM=
|
||||||
|
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
||||||
|
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
|
||||||
|
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
|
||||||
|
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
|
||||||
|
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
||||||
|
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
|
||||||
|
github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
|
||||||
|
github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
|
||||||
|
github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
|
||||||
|
github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||||
|
github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
|
||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||||
|
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||||
|
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
||||||
|
github.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5ynNVH9qI8YYLbd1fK2po=
|
||||||
|
github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk=
|
||||||
|
github.com/envoyproxy/go-control-plane v0.9.9-0.20210217033140-668b12f5399d/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk=
|
||||||
|
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
|
||||||
|
github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
|
||||||
|
github.com/felixge/fgprof v0.9.3 h1:VvyZxILNuCiUCSXtPtYmmtGvb65nqXh2QFWc0Wpf2/g=
|
||||||
|
github.com/felixge/fgprof v0.9.3/go.mod h1:RdbpDgzqYVh/T9fPELJyV7EYJuHB55UTEULNun8eiPw=
|
||||||
|
github.com/fredbi/uri v1.1.0 h1:OqLpTXtyRg9ABReqvDGdJPqZUxs8cyBDOMXBbskCaB8=
|
||||||
|
github.com/fredbi/uri v1.1.0/go.mod h1:aYTUoAXBOq7BLfVJ8GnKmfcuURosB1xyHDIfWeC/iW4=
|
||||||
|
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
|
||||||
|
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
|
||||||
|
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
|
||||||
|
github.com/fyne-io/gl-js v0.0.0-20220119005834-d2da28d9ccfe h1:A/wiwvQ0CAjPkuJytaD+SsXkPU0asQ+guQEIg1BJGX4=
|
||||||
|
github.com/fyne-io/gl-js v0.0.0-20220119005834-d2da28d9ccfe/go.mod h1:d4clgH0/GrRwWjRzJJQXxT/h1TyuNSfF/X64zb/3Ggg=
|
||||||
|
github.com/fyne-io/glfw-js v0.0.0-20241126112943-313d8a0fe1d0 h1:/1YRWFv9bAWkoo3SuxpFfzpXH0D/bQnTjNXyF4ih7Os=
|
||||||
|
github.com/fyne-io/glfw-js v0.0.0-20241126112943-313d8a0fe1d0/go.mod h1:gsGA2dotD4v0SR6PmPCYvS9JuOeMwAtmfvDE7mbYXMY=
|
||||||
|
github.com/fyne-io/image v0.0.0-20220602074514-4956b0afb3d2 h1:hnLq+55b7Zh7/2IRzWCpiTcAvjv/P8ERF+N7+xXbZhk=
|
||||||
|
github.com/fyne-io/image v0.0.0-20220602074514-4956b0afb3d2/go.mod h1:eO7W361vmlPOrykIg+Rsh1SZ3tQBaOsfzZhsIOb/Lm0=
|
||||||
|
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
|
||||||
|
github.com/go-gl/gl v0.0.0-20211210172815-726fda9656d6 h1:zDw5v7qm4yH7N8C8uWd+8Ii9rROdgWxQuGoJ9WDXxfk=
|
||||||
|
github.com/go-gl/gl v0.0.0-20211210172815-726fda9656d6/go.mod h1:9YTyiznxEY1fVinfM7RvRcjRHbw2xLBJ3AAGIT0I4Nw=
|
||||||
|
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
|
||||||
|
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
|
||||||
|
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
|
||||||
|
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a h1:vxnBhFDDT+xzxf1jTJKMKZw3H0swfWk9RpWbBbDK5+0=
|
||||||
|
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
|
||||||
|
github.com/go-text/render v0.2.0 h1:LBYoTmp5jYiJ4NPqDc2pz17MLmA3wHw1dZSVGcOdeAc=
|
||||||
|
github.com/go-text/render v0.2.0/go.mod h1:CkiqfukRGKJA5vZZISkjSYrcdtgKQWRa2HIzvwNN5SU=
|
||||||
|
github.com/go-text/typesetting v0.2.0 h1:fbzsgbmk04KiWtE+c3ZD4W2nmCRzBqrqQOvYlwAOdho=
|
||||||
|
github.com/go-text/typesetting v0.2.0/go.mod h1:2+owI/sxa73XA581LAzVuEBZ3WEEV2pXeDswCH/3i1I=
|
||||||
|
github.com/go-text/typesetting-utils v0.0.0-20240317173224-1986cbe96c66 h1:GUrm65PQPlhFSKjLPGOZNPNxLCybjzjYBzjfoBGaDUY=
|
||||||
|
github.com/go-text/typesetting-utils v0.0.0-20240317173224-1986cbe96c66/go.mod h1:DDxDdQEnB70R8owOx3LVpEFvpMK9eeH1o2r0yZhFI9o=
|
||||||
|
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||||
|
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
|
||||||
|
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||||
|
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||||
|
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
||||||
|
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||||
|
github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||||
|
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||||
|
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
|
||||||
|
github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
|
||||||
|
github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y=
|
||||||
|
github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
|
||||||
|
github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
|
||||||
|
github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
|
||||||
|
github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4=
|
||||||
|
github.com/golang/mock v1.5.0/go.mod h1:CWnOUgYIOo4TcNZ0wHX3YZCqsaM1I1Jvs6v3mP3KVu8=
|
||||||
|
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||||
|
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||||
|
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||||
|
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
|
||||||
|
github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
|
||||||
|
github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk=
|
||||||
|
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
|
||||||
|
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
|
||||||
|
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
|
||||||
|
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
|
||||||
|
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
|
||||||
|
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
|
||||||
|
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
||||||
|
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
||||||
|
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||||
|
github.com/golang/protobuf v1.5.1/go.mod h1:DopwsBzvsk0Fs44TXzsVbJyPhcCPeIwnvohx4u74HPM=
|
||||||
|
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
|
||||||
|
github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
|
||||||
|
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
|
||||||
|
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
|
||||||
|
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||||
|
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||||
|
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
|
github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
|
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
|
github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
|
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
|
github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
|
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
|
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
|
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
|
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
|
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
|
||||||
|
github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
|
||||||
|
github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
|
||||||
|
github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
|
||||||
|
github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
|
||||||
|
github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
|
||||||
|
github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
|
||||||
|
github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
|
||||||
|
github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
|
||||||
|
github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
|
||||||
|
github.com/google/pprof v0.0.0-20201023163331-3e6fc7fc9c4c/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
|
||||||
|
github.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
|
||||||
|
github.com/google/pprof v0.0.0-20210122040257-d980be63207e/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
|
||||||
|
github.com/google/pprof v0.0.0-20210226084205-cbba55b83ad5/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
|
||||||
|
github.com/google/pprof v0.0.0-20211214055906-6f57359322fd h1:1FjCyPC+syAzJ5/2S8fqdZK1R22vvA0J7JZKcuOIQ7Y=
|
||||||
|
github.com/google/pprof v0.0.0-20211214055906-6f57359322fd/go.mod h1:KgnwoLYCZ8IQu3XUZ8Nc/bM9CCZFOyjUNOSygVozoDg=
|
||||||
|
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
|
||||||
|
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
|
||||||
|
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
|
||||||
|
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
|
||||||
|
github.com/gopherjs/gopherjs v0.0.0-20211219123610-ec9572f70e60/go.mod h1:cz9oNYuRUWGdHmLF2IodMLkAhcPtXeULvcBNagUrxTI=
|
||||||
|
github.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g=
|
||||||
|
github.com/gopherjs/gopherjs v1.17.2/go.mod h1:pRRIvn/QzFLrKfvEz3qUuEhtE/zLCWfreZ6J5gM2i+k=
|
||||||
|
github.com/goxjs/gl v0.0.0-20210104184919-e3fafc6f8f2a/go.mod h1:dy/f2gjY09hwVfIyATps4G2ai7/hLwLkc5TrPqONuXY=
|
||||||
|
github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw=
|
||||||
|
github.com/hashicorp/consul/api v1.1.0/go.mod h1:VmuI/Lkw1nC05EYQWNKwWGbkg+FbDBtguAZLlVdkD9Q=
|
||||||
|
github.com/hashicorp/consul/sdk v0.1.1/go.mod h1:VKf9jXwCTEY1QZP2MOLRhb5i/I/ssyNV1vwHyQBF0x8=
|
||||||
|
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||||
|
github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80=
|
||||||
|
github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60=
|
||||||
|
github.com/hashicorp/go-msgpack v0.5.3/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM=
|
||||||
|
github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk=
|
||||||
|
github.com/hashicorp/go-rootcerts v1.0.0/go.mod h1:K6zTfqpRlCUIjkwsN4Z+hiSfzSTQa6eBIzfwKfwNnHU=
|
||||||
|
github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU=
|
||||||
|
github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4=
|
||||||
|
github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
||||||
|
github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
||||||
|
github.com/hashicorp/go.net v0.0.1/go.mod h1:hjKkEWcCURg++eb33jQU7oqQcI9XDCnUzHA0oac0k90=
|
||||||
|
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
||||||
|
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
||||||
|
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
|
||||||
|
github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64=
|
||||||
|
github.com/hashicorp/mdns v1.0.0/go.mod h1:tL+uN++7HEJ6SQLQ2/p+z2pH24WQKWjBPkE0mNTz8vQ=
|
||||||
|
github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2pPBoIllUwCN7I=
|
||||||
|
github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc=
|
||||||
|
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
|
||||||
|
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
|
||||||
|
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
|
||||||
|
github.com/jeandeaual/go-locale v0.0.0-20240223122105-ce5225dcaa49 h1:Po+wkNdMmN+Zj1tDsJQy7mJlPlwGNQd9JZoPjObagf8=
|
||||||
|
github.com/jeandeaual/go-locale v0.0.0-20240223122105-ce5225dcaa49/go.mod h1:YiutDnxPRLk5DLUFj6Rw4pRBBURZY07GFr54NdV9mQg=
|
||||||
|
github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
|
||||||
|
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
|
||||||
|
github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
|
||||||
|
github.com/jsummers/gobmp v0.0.0-20151104160322-e2ba15ffa76e h1:LvL4XsI70QxOGHed6yhQtAU34Kx3Qq2wwBzGFKY8zKk=
|
||||||
|
github.com/jsummers/gobmp v0.0.0-20151104160322-e2ba15ffa76e/go.mod h1:kLgvv7o6UM+0QSf0QjAse3wReFDsb9qbZJdfexWlrQw=
|
||||||
|
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
|
||||||
|
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||||
|
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||||
|
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
|
||||||
|
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||||
|
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||||
|
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
|
||||||
|
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||||
|
github.com/magiconair/properties v1.8.5/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60=
|
||||||
|
github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
|
||||||
|
github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
|
||||||
|
github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg=
|
||||||
|
github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc=
|
||||||
|
github.com/mitchellh/go-homedir v1.0.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
|
||||||
|
github.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI=
|
||||||
|
github.com/mitchellh/gox v0.4.0/go.mod h1:Sd9lOJ0+aimLBi73mGofS1ycjY8lL3uZM3JPS42BGNg=
|
||||||
|
github.com/mitchellh/iochan v1.0.0/go.mod h1:JwYml1nuB7xOzsp52dPpHFffvOCDupsG0QubkSMEySY=
|
||||||
|
github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
|
||||||
|
github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
|
||||||
|
github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
|
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
|
||||||
|
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
|
||||||
|
github.com/neelance/astrewrite v0.0.0-20160511093645-99348263ae86/go.mod h1:kHJEU3ofeGjhHklVoIGuVj85JJwZ6kWPaJwCIxgnFmo=
|
||||||
|
github.com/neelance/sourcemap v0.0.0-20200213170602-2833bce08e4c/go.mod h1:Qr6/a/Q4r9LP1IltGz7tA7iOK1WonHEYhu1HRBA7ZiM=
|
||||||
|
github.com/nicksnyder/go-i18n/v2 v2.4.0 h1:3IcvPOAvnCKwNm0TB0dLDTuawWEj+ax/RERNC+diLMM=
|
||||||
|
github.com/nicksnyder/go-i18n/v2 v2.4.0/go.mod h1:nxYSZE9M0bf3Y70gPQjN9ha7XNHX7gMc814+6wVyEI4=
|
||||||
|
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
|
||||||
|
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
|
||||||
|
github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
|
||||||
|
github.com/pelletier/go-toml v1.9.3/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=
|
||||||
|
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
|
github.com/pkg/profile v1.7.0 h1:hnbDkaNWPCLMO9wGLdBFTIZvzDrDfBM2072E1S9gJkA=
|
||||||
|
github.com/pkg/profile v1.7.0/go.mod h1:8Uer0jas47ZQMJ7VD+OHknK4YDY07LPUC6dEvqDjvNo=
|
||||||
|
github.com/pkg/sftp v1.10.1/go.mod h1:lYOWFsE0bwd1+KfKJaKeuokY15vzFx25BLbzYYoAxZI=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=
|
||||||
|
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||||
|
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||||
|
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||||
|
github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ=
|
||||||
|
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
|
||||||
|
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
|
github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts=
|
||||||
|
github.com/rymdport/portal v0.3.0 h1:QRHcwKwx3kY5JTQcsVhmhC3TGqGQb9LFghVNUy8AdB8=
|
||||||
|
github.com/rymdport/portal v0.3.0/go.mod h1:kFF4jslnJ8pD5uCi17brj/ODlfIidOxlgUDTO5ncnC4=
|
||||||
|
github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=
|
||||||
|
github.com/shurcooL/go v0.0.0-20200502201357-93f07166e636/go.mod h1:TDJrrUr11Vxrven61rcy3hJMUqaf/CLWYhHNPmT14Lk=
|
||||||
|
github.com/shurcooL/httpfs v0.0.0-20190707220628-8d4bc4ba7749/go.mod h1:ZY1cvUeJuFPAdZ/B6v7RHavJWZn2YPVFQ1OSXhCGOkg=
|
||||||
|
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
|
||||||
|
github.com/shurcooL/vfsgen v0.0.0-20200824052919-0d455de96546/go.mod h1:TrYk7fJVaAttu97ZZKrO9UbRa8izdowaMIZcxYMbVaw=
|
||||||
|
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
|
||||||
|
github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
|
||||||
|
github.com/spf13/afero v1.6.0/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z93I=
|
||||||
|
github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
|
||||||
|
github.com/spf13/cobra v1.2.1/go.mod h1:ExllRjgxM/piMAM+3tAZvg8fsklGAf3tPfi+i8t68Nk=
|
||||||
|
github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo=
|
||||||
|
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
|
github.com/spf13/viper v1.8.1/go.mod h1:o0Pch8wJ9BVSWGQMbra6iw0oQ5oktSIBaujf1rJH9Ns=
|
||||||
|
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiYWEedbTT0qnSxrCjsVbb7yKY1KE=
|
||||||
|
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c/go.mod h1:cNQ3dwVJtS5Hmnjxy6AgTPd0Inb3pW05ftPSX7NZO7Q=
|
||||||
|
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef h1:Ch6Q+AZUxDBCVqdkI8FSpFyZDtCVBc2VmejdNrm5rRQ=
|
||||||
|
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef/go.mod h1:nXTWP6+gD5+LUJ8krVhhoeHjvHTutPxMYl5SvkcnJNE=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||||
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||||
|
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
|
||||||
|
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
||||||
|
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||||
|
github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw=
|
||||||
|
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
|
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
|
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
|
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
|
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
|
||||||
|
github.com/yuin/goldmark v1.4.0/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
|
||||||
|
github.com/yuin/goldmark v1.7.1 h1:3bajkSilaCbjdKVsKdZjZCLBNPL9pYzrCakKaf4U49U=
|
||||||
|
github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
|
||||||
|
go.etcd.io/etcd/api/v3 v3.5.0/go.mod h1:cbVKeC6lCfl7j/8jBhAK6aIYO9XOjdptoxU/nLQcPvs=
|
||||||
|
go.etcd.io/etcd/client/pkg/v3 v3.5.0/go.mod h1:IJHfcCEKxYu1Os13ZdwCwIUTUVGYTSAM3YSwc9/Ac1g=
|
||||||
|
go.etcd.io/etcd/client/v2 v2.305.0/go.mod h1:h9puh54ZTgAKtEbut2oe9P4L/oqKCVB6xsXlzd7alYQ=
|
||||||
|
go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
|
||||||
|
go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
|
||||||
|
go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
|
||||||
|
go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
|
||||||
|
go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
|
||||||
|
go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk=
|
||||||
|
go.opencensus.io v0.23.0/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E=
|
||||||
|
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
|
||||||
|
go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU=
|
||||||
|
go.uber.org/zap v1.17.0/go.mod h1:MXVU+bhUf/A7Xi2HNOnopQOrmycQ5Ih87HtOu4q5SSo=
|
||||||
|
go.yaml.in/yaml/v4 v4.0.0-rc.5 h1:JVliQq9EGOYaTgMi+k8BhUJyqcGk4ZqeuiN1Cirba9c=
|
||||||
|
go.yaml.in/yaml/v4 v4.0.0-rc.5/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
|
||||||
|
golang.org/x/crypto v0.0.0-20181029021203-45a5f77698d3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||||
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
|
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||||
|
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||||
|
golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||||
|
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||||
|
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||||
|
golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
|
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||||
|
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||||
|
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
|
||||||
|
golang.org/x/exp v0.0.0-20190731235908-ec7cb31e5a56/go.mod h1:JhuoJpWY28nO4Vef9tZUw9qufEGTyX1+7lmHxV5q5G4=
|
||||||
|
golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek=
|
||||||
|
golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY=
|
||||||
|
golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
|
||||||
|
golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
|
||||||
|
golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
|
||||||
|
golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
|
||||||
|
golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU=
|
||||||
|
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
|
||||||
|
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||||
|
golang.org/x/image v0.18.0 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ=
|
||||||
|
golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E=
|
||||||
|
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||||
|
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
||||||
|
golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||||
|
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||||
|
golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||||
|
golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||||
|
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||||
|
golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs=
|
||||||
|
golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
|
||||||
|
golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
|
||||||
|
golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
|
||||||
|
golang.org/x/lint v0.0.0-20210508222113-6edffad5e616/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
|
||||||
|
golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE=
|
||||||
|
golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o=
|
||||||
|
golang.org/x/mobile v0.0.0-20211207041440-4e6c2922fdee/go.mod h1:pe2sM7Uk+2Su1y7u/6Z8KJ24D7lepUjFZbhFOrmDfuQ=
|
||||||
|
golang.org/x/mobile v0.0.0-20231127183840-76ac6878050a h1:sYbmY3FwUWCBTodZL1S3JUuOvaW6kM2o+clDzzDNBWg=
|
||||||
|
golang.org/x/mobile v0.0.0-20231127183840-76ac6878050a/go.mod h1:Ede7gF0KGoHlj822RtphAHK1jLdrcuRBZg0sF1Q+SPc=
|
||||||
|
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
|
||||||
|
golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY=
|
||||||
|
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
|
||||||
|
golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
|
||||||
|
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
|
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
|
golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
|
golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
|
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
|
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
|
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
|
golang.org/x/net v0.0.0-20181023162649-9b4f9f5ad519/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
|
golang.org/x/net v0.0.0-20181201002055-351d144fa1fc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
|
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
|
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
|
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
|
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
|
golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
|
golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
|
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
||||||
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||||
|
golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||||
|
golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||||
|
golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||||
|
golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||||
|
golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
|
||||||
|
golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
|
||||||
|
golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
|
||||||
|
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||||
|
golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||||
|
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||||
|
golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
|
golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
|
golang.org/x/net v0.0.0-20210316092652-d523dce5a7f4/go.mod h1:RBQZq4jEuRlivfhVLdyRGr576XBO4/greRjx4P4O3yc=
|
||||||
|
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
|
||||||
|
golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||||
|
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
|
||||||
|
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||||
|
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||||
|
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||||
|
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||||
|
golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||||
|
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||||
|
golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
||||||
|
golang.org/x/oauth2 v0.0.0-20201109201403-9fd604954f58/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
||||||
|
golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
||||||
|
golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
||||||
|
golang.org/x/oauth2 v0.0.0-20210220000619-9bb904979d93/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
||||||
|
golang.org/x/oauth2 v0.0.0-20210313182246-cd4f82c27b84/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
||||||
|
golang.org/x/oauth2 v0.0.0-20210402161424-2e8d93401602/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
||||||
|
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20181026203630-95b1ffbd15a5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210220050731-9a76102bfb43/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210305230114-8fe3ee5dd75b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210315160823-c6e025ad8005/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210403161142-5e06dd20ab57/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
|
||||||
|
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
|
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
|
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
|
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||||
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
|
||||||
|
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
|
||||||
|
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||||
|
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||||
|
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||||
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
|
||||||
|
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||||
|
golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||||
|
golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||||
|
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||||
|
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||||
|
golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||||
|
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||||
|
golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
|
||||||
|
golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
|
||||||
|
golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
|
||||||
|
golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
|
golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
|
golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
|
golang.org/x/tools v0.0.0-20191112195655-aa38f8e97acc/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
|
golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
|
golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
|
golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
|
golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
|
golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||||
|
golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||||
|
golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||||
|
golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||||
|
golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||||
|
golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||||
|
golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||||
|
golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||||
|
golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||||
|
golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||||
|
golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
|
||||||
|
golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
|
||||||
|
golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8=
|
||||||
|
golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||||
|
golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||||
|
golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||||
|
golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||||
|
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||||
|
golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
|
||||||
|
golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
|
||||||
|
golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
|
||||||
|
golang.org/x/tools v0.0.0-20200904185747-39188db58858/go.mod h1:Cj7w3i3Rnn0Xh82ur9kSqwfTHTeVxaDqrfMjpcNT6bE=
|
||||||
|
golang.org/x/tools v0.0.0-20201110124207-079ba7bd75cd/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||||
|
golang.org/x/tools v0.0.0-20201201161351-ac6f37ff4c2a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||||
|
golang.org/x/tools v0.0.0-20201208233053-a543418bbed2/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||||
|
golang.org/x/tools v0.0.0-20210105154028-b0ab187a4818/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||||
|
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||||
|
golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0=
|
||||||
|
golang.org/x/tools v0.1.2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
|
||||||
|
golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
|
||||||
|
golang.org/x/tools v0.1.8-0.20211022200916-316ba0b74098/go.mod h1:LGqMHiF4EqQNHR1JncWGqT5BVaXmza+X+BDGol+dOxo=
|
||||||
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
|
||||||
|
google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M=
|
||||||
|
google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
|
||||||
|
google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
|
||||||
|
google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
|
||||||
|
google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
|
||||||
|
google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
|
||||||
|
google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
|
||||||
|
google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
|
||||||
|
google.golang.org/api v0.19.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
|
||||||
|
google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
|
||||||
|
google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
|
||||||
|
google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=
|
||||||
|
google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=
|
||||||
|
google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM=
|
||||||
|
google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc=
|
||||||
|
google.golang.org/api v0.35.0/go.mod h1:/XrVsuzM0rZmrsbjJutiuftIzeuTQcEeaYcSk/mQ1dg=
|
||||||
|
google.golang.org/api v0.36.0/go.mod h1:+z5ficQTmoYpPn8LCUNVpK5I7hwkpjbcgqA7I34qYtE=
|
||||||
|
google.golang.org/api v0.40.0/go.mod h1:fYKFpnQN0DsDSKRVRcQSDQNtqWPfM9i+zNPxepjRCQ8=
|
||||||
|
google.golang.org/api v0.41.0/go.mod h1:RkxM5lITDfTzmyKFPt+wGrCJbVfniCr2ool8kTBzRTU=
|
||||||
|
google.golang.org/api v0.43.0/go.mod h1:nQsDGjRXMo4lvh5hP0TKqF244gqhGcr/YSIykhUk/94=
|
||||||
|
google.golang.org/api v0.44.0/go.mod h1:EBOGZqzyhtvMDoxwS97ctnh0zUmYY6CxqXsc1AvkYD8=
|
||||||
|
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
||||||
|
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||||
|
google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||||
|
google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0=
|
||||||
|
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||||
|
google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||||
|
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||||
|
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
|
||||||
|
google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
|
||||||
|
google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
|
||||||
|
google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
|
||||||
|
google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
|
||||||
|
google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
|
||||||
|
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
|
||||||
|
google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8=
|
||||||
|
google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
|
||||||
|
google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
|
||||||
|
google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
|
||||||
|
google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
|
||||||
|
google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
|
||||||
|
google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
|
||||||
|
google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA=
|
||||||
|
google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
||||||
|
google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
||||||
|
google.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
||||||
|
google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
||||||
|
google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
||||||
|
google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
||||||
|
google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
||||||
|
google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
||||||
|
google.golang.org/genproto v0.0.0-20200513103714-09dca8ec2884/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
|
||||||
|
google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U=
|
||||||
|
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
|
||||||
|
google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA=
|
||||||
|
google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||||
|
google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||||
|
google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||||
|
google.golang.org/genproto v0.0.0-20200904004341-0bd0a958aa1d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||||
|
google.golang.org/genproto v0.0.0-20201109203340-2640f1f9cdfb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||||
|
google.golang.org/genproto v0.0.0-20201201144952-b05cb90ed32e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||||
|
google.golang.org/genproto v0.0.0-20201210142538-e3217bee35cc/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||||
|
google.golang.org/genproto v0.0.0-20201214200347-8c77b98c765d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||||
|
google.golang.org/genproto v0.0.0-20210222152913-aa3ee6e6a81c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||||
|
google.golang.org/genproto v0.0.0-20210303154014-9728d6b83eeb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||||
|
google.golang.org/genproto v0.0.0-20210310155132-4ce2db91004e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||||
|
google.golang.org/genproto v0.0.0-20210319143718-93e7006c17a6/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||||
|
google.golang.org/genproto v0.0.0-20210402141018-6c239bbf2bb1/go.mod h1:9lPAdzaEmUacj36I+k7YKbEc5CXzPIeORRgDAUOu28A=
|
||||||
|
google.golang.org/genproto v0.0.0-20210602131652-f16073e35f0c/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0=
|
||||||
|
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
||||||
|
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
|
||||||
|
google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
|
||||||
|
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
|
||||||
|
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
|
||||||
|
google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
|
||||||
|
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
|
||||||
|
google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
|
||||||
|
google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60=
|
||||||
|
google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk=
|
||||||
|
google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
|
||||||
|
google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
|
||||||
|
google.golang.org/grpc v1.31.1/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
|
||||||
|
google.golang.org/grpc v1.33.1/go.mod h1:fr5YgcSWrqhRRxogOsw7RzIpsmvOZ6IcH4kBYTpR3n0=
|
||||||
|
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
|
||||||
|
google.golang.org/grpc v1.34.0/go.mod h1:WotjhfgOW/POjDeRt8vscBtXq+2VjORFy659qA51WJ8=
|
||||||
|
google.golang.org/grpc v1.35.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU=
|
||||||
|
google.golang.org/grpc v1.36.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU=
|
||||||
|
google.golang.org/grpc v1.36.1/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU=
|
||||||
|
google.golang.org/grpc v1.38.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM=
|
||||||
|
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
||||||
|
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
||||||
|
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
||||||
|
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
|
||||||
|
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
|
||||||
|
google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||||
|
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||||
|
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||||
|
google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4=
|
||||||
|
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
|
||||||
|
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||||
|
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
|
||||||
|
gopkg.in/ini.v1 v1.62.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
||||||
|
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
|
gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
|
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
|
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||||
|
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||||
|
honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||||
|
honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||||
|
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||||
|
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
|
||||||
|
honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
|
||||||
|
honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
|
||||||
|
rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
|
||||||
|
rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
|
||||||
|
rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
IDI_ICON1 ICON "assets/pysentry.ico"
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Optional first argument mirrors build-linux.sh. The Docker build still writes
|
||||||
|
# the final artifact into the local dist/ tree, not into the container. The
|
||||||
|
# default includes the application version and target platform.
|
||||||
|
version="$(sed -n 's/^var Version = "\(.*\)"/\1/p' src/core/version.go)"
|
||||||
|
version="${version:-0.0.0-dev}"
|
||||||
|
tag="gitea.mixdep.ru/mix/pysentry-builder:${version}"
|
||||||
|
output="${1:-dist/linux/pysentry-${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
|
||||||
|
# environment in Docker makes Linux builds repeatable from Windows hosts and CI.
|
||||||
|
docker build -f Dockerfile -t "$tag" .
|
||||||
|
|
||||||
|
mkdir -p "$(dirname "$output")"
|
||||||
|
docker run --rm \
|
||||||
|
"${docker_user_args[@]}" \
|
||||||
|
-e "VERSION=${version}" \
|
||||||
|
-e "OUTPUT=${output}" \
|
||||||
|
-e "GOCACHE=/tmp/go-build-cache" \
|
||||||
|
-v "$(pwd):/src" \
|
||||||
|
-w /src \
|
||||||
|
"$tag" \
|
||||||
|
bash -c 'CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build -buildvcs=false -trimpath -ldflags "-s -w -X github.com/pysentry/pysentry/src/core.Version=${VERSION}" -o "${OUTPUT}" ./cmd/pysentry'
|
||||||
|
|
||||||
|
# Icons are embedded in the Go binary, so there is no assets directory to copy
|
||||||
|
# after extracting the Linux executable.
|
||||||
|
echo "Built $output"
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Optional first argument lets a developer or CI job choose the output path. The
|
||||||
|
# default includes the application version and target platform.
|
||||||
|
version="$(sed -n 's/^var Version = "\(.*\)"/\1/p' src/core/version.go)"
|
||||||
|
version="${version:-0.0.0-dev}"
|
||||||
|
output="${1:-dist/linux/pysentry-${version}-linux-amd64}"
|
||||||
|
mkdir -p "$(dirname "$output")"
|
||||||
|
|
||||||
|
# Fyne needs CGO for its native desktop backend. The script pins the target to
|
||||||
|
# linux/amd64 because this is the first supported Linux artifact; other
|
||||||
|
# architectures can be added later as explicit build targets.
|
||||||
|
export CGO_ENABLED=1
|
||||||
|
export GOOS=linux
|
||||||
|
export GOARCH=amd64
|
||||||
|
|
||||||
|
# -trimpath removes local machine paths from debug/build metadata. -s -w strips
|
||||||
|
# symbol/debug tables to keep the desktop binary smaller.
|
||||||
|
go build -trimpath -ldflags "-s -w -X github.com/pysentry/pysentry/src/core.Version=${version}" -o "$output" ./cmd/pysentry
|
||||||
|
|
||||||
|
# The application icon is embedded by Go, so the Linux build does not need a
|
||||||
|
# sidecar assets directory beside the executable.
|
||||||
|
echo "Built $output"
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Build selected release artifacts from a Linux host or CI runner. The Docker
|
||||||
|
# image contains Linux/Fyne dependencies for amd64 and arm64, plus the MinGW
|
||||||
|
# cross-compiler used for the Windows GUI executable. Actual build commands live
|
||||||
|
# here rather than in Dockerfile so target selection does not require rebuilding
|
||||||
|
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
repo_root="$(cd "${script_dir}/.." && pwd)"
|
||||||
|
cd "$repo_root"
|
||||||
|
|
||||||
|
version="$(sed -n 's/^var Version = "\(.*\)"/\1/p' src/core/version.go)"
|
||||||
|
version="${version:-0.0.0-dev}"
|
||||||
|
tag="gitea.mixdep.ru/mix/pysentry-builder:${version}"
|
||||||
|
|
||||||
|
docker_user_args=()
|
||||||
|
if command -v id >/dev/null 2>&1; then
|
||||||
|
docker_user_args=(--user "$(id -u):$(id -g)")
|
||||||
|
fi
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<EOF
|
||||||
|
Usage: $0 [target...]
|
||||||
|
|
||||||
|
Targets:
|
||||||
|
all Build every release artifact.
|
||||||
|
linux-amd64 Build dist/linux/pysentry-${version}-linux-amd64.
|
||||||
|
linux-arm64 Build dist/linux/pysentry-${version}-linux-arm64.
|
||||||
|
windows-amd64 Build dist/windows/pysentry-${version}-windows-amd64.exe.
|
||||||
|
|
||||||
|
When no target is passed and the script runs in a terminal, it asks what to build.
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
for arg in "$@"; do
|
||||||
|
case "$arg" in
|
||||||
|
-h|--help|help)
|
||||||
|
usage
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
choose_targets() {
|
||||||
|
if [ "$#" -gt 0 ]; then
|
||||||
|
printf '%s\n' "$@"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -t 0 ]; then
|
||||||
|
printf '%s\n' all
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Select release artifacts to build:"
|
||||||
|
echo " 1) all"
|
||||||
|
echo " 2) linux-amd64"
|
||||||
|
echo " 3) linux-arm64"
|
||||||
|
echo " 4) windows-amd64"
|
||||||
|
echo "Enter numbers or target names separated by spaces or commas."
|
||||||
|
read -r -p "Build target [all]: " answer
|
||||||
|
answer="${answer:-all}"
|
||||||
|
echo "$answer" | tr ',' ' ' | tr ' ' '\n' | sed '/^$/d'
|
||||||
|
}
|
||||||
|
|
||||||
|
normalize_targets() {
|
||||||
|
while IFS= read -r target; do
|
||||||
|
case "$target" in
|
||||||
|
1|all)
|
||||||
|
printf '%s\n' linux-amd64 linux-arm64 windows-amd64
|
||||||
|
;;
|
||||||
|
2|linux-amd64)
|
||||||
|
printf '%s\n' linux-amd64
|
||||||
|
;;
|
||||||
|
3|linux-arm64)
|
||||||
|
printf '%s\n' linux-arm64
|
||||||
|
;;
|
||||||
|
4|windows-amd64)
|
||||||
|
printf '%s\n' windows-amd64
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Unknown build target: $target" >&2
|
||||||
|
usage >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
run_in_builder() {
|
||||||
|
docker run --rm \
|
||||||
|
"${docker_user_args[@]}" \
|
||||||
|
-e "VERSION=${version}" \
|
||||||
|
-e "GOCACHE=/tmp/go-build-cache" \
|
||||||
|
-v "${repo_root}:/src" \
|
||||||
|
-w /src \
|
||||||
|
"$tag" \
|
||||||
|
bash -c "$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
build_linux_amd64() {
|
||||||
|
run_in_builder 'mkdir -p dist/linux && CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build -buildvcs=false -trimpath -ldflags "-s -w -X github.com/pysentry/pysentry/src/core.Version=${VERSION}" -o "dist/linux/pysentry-${VERSION}-linux-amd64" ./cmd/pysentry'
|
||||||
|
}
|
||||||
|
|
||||||
|
build_linux_arm64() {
|
||||||
|
run_in_builder 'mkdir -p dist/linux && CC=aarch64-linux-gnu-gcc CGO_ENABLED=1 GOOS=linux GOARCH=arm64 CGO_CFLAGS="--sysroot=/ -I/usr/include/aarch64-linux-gnu" CGO_LDFLAGS="--sysroot=/ -L/usr/lib/aarch64-linux-gnu" PKG_CONFIG_LIBDIR=/usr/lib/aarch64-linux-gnu/pkgconfig go build -buildvcs=false -trimpath -ldflags "-s -w -X github.com/pysentry/pysentry/src/core.Version=${VERSION}" -o "dist/linux/pysentry-${VERSION}-linux-arm64" ./cmd/pysentry'
|
||||||
|
}
|
||||||
|
|
||||||
|
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 -buildvcs=false -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'
|
||||||
|
}
|
||||||
|
|
||||||
|
mapfile -t targets < <(choose_targets "$@" | normalize_targets | awk '!seen[$0]++')
|
||||||
|
if [ "${#targets[@]}" -eq 0 ]; then
|
||||||
|
echo "No build targets selected." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Building Docker builder image: $tag"
|
||||||
|
docker build -f Dockerfile -t "$tag" .
|
||||||
|
|
||||||
|
for target in "${targets[@]}"; do
|
||||||
|
echo "Building $target..."
|
||||||
|
case "$target" in
|
||||||
|
linux-amd64)
|
||||||
|
build_linux_amd64
|
||||||
|
;;
|
||||||
|
linux-arm64)
|
||||||
|
build_linux_arm64
|
||||||
|
;;
|
||||||
|
windows-amd64)
|
||||||
|
build_windows_amd64
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "Built release artifacts:"
|
||||||
|
find dist/linux dist/windows -maxdepth 1 -type f -print 2>/dev/null || true
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
@echo off
|
||||||
|
setlocal enabledelayedexpansion
|
||||||
|
|
||||||
|
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 relative paths such as .\cmd\pysentry and packaging\windows\pysentry.rc.
|
||||||
|
cd /d "%~dp0\.."
|
||||||
|
|
||||||
|
for /f "tokens=4" %%V in ('findstr /C:"var Version" src\core\version.go') do set "VERSION=%%~V"
|
||||||
|
if "%VERSION%"=="" set "VERSION=0.0.0-dev"
|
||||||
|
set "VERSION=%VERSION:"=%"
|
||||||
|
|
||||||
|
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 stays clean and the old bin\ folder is no longer needed.
|
||||||
|
set "OUTPUT=%~1"
|
||||||
|
if "%OUTPUT%"=="" set "OUTPUT=dist\windows\pysentry-%VERSION%-windows-amd64.exe"
|
||||||
|
|
||||||
|
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.
|
||||||
|
set "GOEXE=%ProgramFiles%\Go\bin\go.exe"
|
||||||
|
if not exist "%GOEXE%" set "GOEXE=go"
|
||||||
|
|
||||||
|
REM Fyne uses native libraries through CGO. MSYS2 UCRT64 provides the GCC toolchain
|
||||||
|
REM expected by the Windows build; prepending it keeps the script self-contained
|
||||||
|
REM without permanently changing the user's system PATH.
|
||||||
|
if exist "C:\msys64\ucrt64\bin" set "PATH=C:\msys64\ucrt64\bin;%PATH%"
|
||||||
|
|
||||||
|
REM Build a 64-bit Windows binary. CGO must stay enabled for Fyne; disabling it
|
||||||
|
REM would make the native GUI backend fail to compile.
|
||||||
|
set "CGO_ENABLED=1"
|
||||||
|
set "GOOS=windows"
|
||||||
|
set "GOARCH=amd64"
|
||||||
|
|
||||||
|
REM Create the target directory before invoking Go so custom output paths work.
|
||||||
|
for %%I in ("%OUTPUT%") do set "OUTDIR=%%~dpI"
|
||||||
|
if not exist "%OUTDIR%" mkdir "%OUTDIR%"
|
||||||
|
|
||||||
|
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 handles Fyne's runtime icon, but Explorer reads this Windows resource instead.
|
||||||
|
where windres.exe >nul 2>nul
|
||||||
|
if %ERRORLEVEL%==0 (
|
||||||
|
windres.exe -O coff -o cmd\pysentry\rsrc_windows_amd64.syso packaging\windows\pysentry.rc
|
||||||
|
)
|
||||||
|
|
||||||
|
REM -trimpath removes local machine paths from the binary, -s -w reduce binary
|
||||||
|
REM size, and -H=windowsgui prevents a separate console window from opening when
|
||||||
|
REM the GUI app starts from Explorer or a shortcut.
|
||||||
|
"%GOEXE%" build -trimpath -ldflags "-s -w -H=windowsgui -X github.com/pysentry/pysentry/src/core.Version=%VERSION%" -o "%OUTPUT%" .\cmd\pysentry
|
||||||
|
if errorlevel 1 exit /b 1
|
||||||
|
|
||||||
|
REM Icons are embedded into the executable, so no assets directory is copied next
|
||||||
|
REM to the binary. Runtime YAML and log files are created by the app itself.
|
||||||
|
echo Built %OUTPUT%
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
//go:build linux
|
||||||
|
|
||||||
|
package core
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
const autostartDesktopFileName = "pysentry.desktop"
|
||||||
|
|
||||||
|
func SetAutostart(enabled bool, executablePath string, iconPath string) error {
|
||||||
|
desktopPath, err := autostartDesktopPath()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := cleanupLegacySystemdAutostart(); 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=PySentry
|
||||||
|
Comment=PySentry desktop scheduler
|
||||||
|
Exec=%s
|
||||||
|
%s
|
||||||
|
Terminal=false
|
||||||
|
X-GNOME-Autostart-enabled=true
|
||||||
|
`, quoteDesktopExec(executablePath), 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) {
|
||||||
|
desktopPath, err := autostartDesktopPath()
|
||||||
|
if err != nil {
|
||||||
|
return false, "Cannot resolve XDG autostart directory"
|
||||||
|
}
|
||||||
|
if legacySystemdAutostartExists() {
|
||||||
|
return false, "Legacy systemd autostart entry still exists"
|
||||||
|
}
|
||||||
|
data, readErr := os.ReadFile(desktopPath)
|
||||||
|
|
||||||
|
if !expectedEnabled {
|
||||||
|
if os.IsNotExist(readErr) {
|
||||||
|
return true, "Autostart is off"
|
||||||
|
}
|
||||||
|
return false, "Autostart desktop entry exists while setting is off"
|
||||||
|
}
|
||||||
|
if readErr != nil {
|
||||||
|
return false, "Autostart desktop entry is missing"
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(data), "Exec="+quoteDesktopExec(executablePath)) {
|
||||||
|
return false, "Autostart desktop entry points to another executable"
|
||||||
|
}
|
||||||
|
return true, "Autostart is configured"
|
||||||
|
}
|
||||||
|
|
||||||
|
func autostartDesktopPath() (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", autostartDesktopFileName), 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
|
||||||
|
// Linux implementation uses XDG Autostart because PySentry 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 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,19 @@
|
|||||||
|
//go:build !windows && !linux
|
||||||
|
|
||||||
|
package core
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
func SetAutostart(enabled bool, executablePath string, iconPath string) error {
|
||||||
|
if !enabled {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("autostart is not implemented for this platform")
|
||||||
|
}
|
||||||
|
|
||||||
|
func AutostartStatus(expectedEnabled bool, executablePath string) (bool, string) {
|
||||||
|
if !expectedEnabled {
|
||||||
|
return true, "Autostart is off"
|
||||||
|
}
|
||||||
|
return false, "Autostart is not implemented for this platform"
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package core
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
const autostartName = "PySentry"
|
||||||
|
|
||||||
|
func SetAutostart(enabled bool, executablePath string, iconPath string) error {
|
||||||
|
if enabled {
|
||||||
|
// Remove any stale entry first. This makes "uncheck, save, check, save"
|
||||||
|
// and even a plain "check, save" repair an old path after the executable
|
||||||
|
// was moved or renamed for a new version.
|
||||||
|
deleteCommand := exec.Command("reg.exe", "delete", `HKCU\Software\Microsoft\Windows\CurrentVersion\Run`, "/v", autostartName, "/f")
|
||||||
|
configureHiddenWindow(deleteCommand)
|
||||||
|
_ = deleteCommand.Run()
|
||||||
|
|
||||||
|
command := exec.Command("reg.exe", "add", `HKCU\Software\Microsoft\Windows\CurrentVersion\Run`, "/v", autostartName, "/t", "REG_SZ", "/d", strconv.Quote(executablePath), "/f")
|
||||||
|
configureHiddenWindow(command)
|
||||||
|
return command.Run()
|
||||||
|
}
|
||||||
|
command := exec.Command("reg.exe", "delete", `HKCU\Software\Microsoft\Windows\CurrentVersion\Run`, "/v", autostartName, "/f")
|
||||||
|
configureHiddenWindow(command)
|
||||||
|
_ = command.Run()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func AutostartStatus(expectedEnabled bool, executablePath string) (bool, string) {
|
||||||
|
command := exec.Command("reg.exe", "query", `HKCU\Software\Microsoft\Windows\CurrentVersion\Run`, "/v", autostartName)
|
||||||
|
configureHiddenWindow(command)
|
||||||
|
output, err := command.Output()
|
||||||
|
if !expectedEnabled {
|
||||||
|
if err != nil {
|
||||||
|
return true, "Autostart is off"
|
||||||
|
}
|
||||||
|
return false, "Autostart entry exists while setting is off"
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return false, "Autostart entry is missing"
|
||||||
|
}
|
||||||
|
|
||||||
|
actual, ok := parseRegistryRunValue(string(output))
|
||||||
|
if !ok {
|
||||||
|
return false, "Autostart entry cannot be read"
|
||||||
|
}
|
||||||
|
if !sameWindowsPath(actual, executablePath) {
|
||||||
|
return false, "Autostart points to another executable"
|
||||||
|
}
|
||||||
|
return true, "Autostart is configured"
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = filepath.Clean(strings.Trim(left, `"`))
|
||||||
|
right = filepath.Clean(strings.Trim(right, `"`))
|
||||||
|
return strings.EqualFold(left, right)
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package core
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestParseRegistryRunValue(t *testing.T) {
|
||||||
|
output := `
|
||||||
|
HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Run
|
||||||
|
PySentry REG_SZ "D:\Apps\PySentry\pysentry.exe"
|
||||||
|
`
|
||||||
|
value, ok := parseRegistryRunValue(output)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected registry value to parse")
|
||||||
|
}
|
||||||
|
if value != `D:\Apps\PySentry\pysentry.exe` {
|
||||||
|
t.Fatalf("unexpected value: %q", value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSameWindowsPathIgnoresCaseAndQuotes(t *testing.T) {
|
||||||
|
if !sameWindowsPath(`"D:\Apps\PySentry\pysentry.exe"`, `d:\apps\pysentry\pysentry.exe`) {
|
||||||
|
t.Fatal("expected paths to match")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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", "pysentry.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=PySentry
|
||||||
|
Comment=PySentry 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
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package core
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// Config is stored in pysentry.yaml next to the program. It contains only
|
||||||
|
// application-level choices: where to read jobs from, where to write logs, and
|
||||||
|
// how the desktop shell should behave.
|
||||||
|
type Config struct {
|
||||||
|
JobsDir string `yaml:"jobs_dir"`
|
||||||
|
LogsDir string `yaml:"logs_dir"`
|
||||||
|
MaxLogFiles int `yaml:"max_log_files"`
|
||||||
|
MaxLogAgeDays int `yaml:"max_log_age_days"`
|
||||||
|
StartOnLogin bool `yaml:"start_on_login"`
|
||||||
|
KeepRunningInTray bool `yaml:"keep_running_in_tray"`
|
||||||
|
NotifyOnFailure bool `yaml:"notify_on_failure"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// JobsFile is the on-disk shape of jobs.yaml. Wrapping the slice in a top-level
|
||||||
|
// object leaves room for future metadata without breaking the basic file format.
|
||||||
|
type JobsFile struct {
|
||||||
|
Jobs []Job `yaml:"jobs"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Job is the user-visible scheduled command.
|
||||||
|
//
|
||||||
|
// Fields with yaml:"-" are deliberately runtime-only. They are useful in the GUI
|
||||||
|
// while PySentry is running, but writing them to jobs.yaml would make the jobs
|
||||||
|
// file noisy and would mix durable configuration with transient execution state.
|
||||||
|
type Job struct {
|
||||||
|
ID int `yaml:"id"`
|
||||||
|
Name string `yaml:"name"`
|
||||||
|
Folder string `yaml:"folder,omitempty"`
|
||||||
|
Schedule string `yaml:"schedule"`
|
||||||
|
Command string `yaml:"command"`
|
||||||
|
Enabled bool `yaml:"enabled"`
|
||||||
|
LastRun string `yaml:"-"`
|
||||||
|
NextRun string `yaml:"-"`
|
||||||
|
LastState string `yaml:"-"`
|
||||||
|
Logs []RunRecord `yaml:"-"`
|
||||||
|
Output string `yaml:"-"`
|
||||||
|
|
||||||
|
// nextDue is kept as time.Time for scheduler comparisons. The formatted
|
||||||
|
// NextRun string above exists only for display in the GUI and YAML rewriting
|
||||||
|
// must not persist it.
|
||||||
|
nextDue time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunRecord represents one visible activity item. Scheduled and manual command
|
||||||
|
// output is also written to a log file; the in-memory Output copy exists so the
|
||||||
|
// latest run can be displayed without reopening the log on every repaint.
|
||||||
|
type RunRecord struct {
|
||||||
|
Time string `yaml:"time"`
|
||||||
|
JobID int `yaml:"job_id"`
|
||||||
|
JobName string `yaml:"job_name"`
|
||||||
|
Trigger string `yaml:"trigger,omitempty"`
|
||||||
|
State string `yaml:"state"`
|
||||||
|
Detail string `yaml:"detail"`
|
||||||
|
LogFile string `yaml:"log_file,omitempty"`
|
||||||
|
Output string `yaml:"output,omitempty"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package core
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// The config file stays beside the executable so the portable build behaves
|
||||||
|
// predictably: moving the program folder moves its settings with it.
|
||||||
|
ConfigFileName = "pysentry.yaml"
|
||||||
|
// Jobs are kept in a separate YAML file because the user can choose a
|
||||||
|
// different jobs directory, while application settings remain local to the
|
||||||
|
// installed/copied program.
|
||||||
|
JobsFileName = "jobs.yaml"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Paths contains both the physical program location and the resolved runtime
|
||||||
|
// storage locations. Keeping resolved paths in one struct prevents the GUI and
|
||||||
|
// scheduler from interpreting relative directories differently.
|
||||||
|
type Paths struct {
|
||||||
|
ExecutablePath string
|
||||||
|
AppDir string
|
||||||
|
ConfigPath string
|
||||||
|
JobsDir string
|
||||||
|
JobsPath string
|
||||||
|
LogsDir string
|
||||||
|
DesktopIcon string
|
||||||
|
}
|
||||||
|
|
||||||
|
func ResolvePaths() (Paths, error) {
|
||||||
|
// os.Executable is used instead of the current working directory because GUI
|
||||||
|
// apps are often launched from Explorer, a tray shortcut, or a desktop file.
|
||||||
|
// In those cases the working directory can be surprising, but the executable
|
||||||
|
// path is stable and matches the "portable app folder" storage model.
|
||||||
|
executable, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
return Paths{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
appDir := filepath.Dir(executable)
|
||||||
|
configPath := filepath.Join(appDir, ConfigFileName)
|
||||||
|
return Paths{
|
||||||
|
ExecutablePath: executable,
|
||||||
|
AppDir: appDir,
|
||||||
|
ConfigPath: configPath,
|
||||||
|
JobsDir: appDir,
|
||||||
|
JobsPath: filepath.Join(appDir, JobsFileName),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
package core
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
"unicode"
|
||||||
|
)
|
||||||
|
|
||||||
|
const commandTimeout = 30 * time.Second
|
||||||
|
|
||||||
|
func RunJob(ctx context.Context, job *Job, trigger string, logsDir string) RunRecord {
|
||||||
|
started := time.Now()
|
||||||
|
// Commands can hang forever if a script waits for input or a child process
|
||||||
|
// stalls. A fixed timeout is a conservative first guardrail for a desktop
|
||||||
|
// scheduler; later it can become a per-job setting without changing the
|
||||||
|
// runner contract.
|
||||||
|
runCtx, cancel := context.WithTimeout(ctx, commandTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// 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 stderr bytes.Buffer
|
||||||
|
command.Stdout = &stdout
|
||||||
|
command.Stderr = &stderr
|
||||||
|
|
||||||
|
err := command.Run()
|
||||||
|
duration := time.Since(started).Round(time.Millisecond)
|
||||||
|
output := formatOutput(stdout.String(), stderr.String())
|
||||||
|
|
||||||
|
state := "OK"
|
||||||
|
detail := fmt.Sprintf("Completed in %s", duration)
|
||||||
|
if err != nil {
|
||||||
|
state = "Failed"
|
||||||
|
if errors.Is(runCtx.Err(), context.DeadlineExceeded) {
|
||||||
|
detail = fmt.Sprintf("Timed out after %s", commandTimeout)
|
||||||
|
} else {
|
||||||
|
detail = err.Error()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
job.LastRun = now.Format("2006-01-02 15:04:05")
|
||||||
|
job.LastState = state
|
||||||
|
job.Output = output
|
||||||
|
logFile := writeRunLog(logsDir, *job, trigger, state, detail, output, now)
|
||||||
|
|
||||||
|
record := RunRecord{
|
||||||
|
Time: job.LastRun,
|
||||||
|
JobID: job.ID,
|
||||||
|
JobName: job.Name,
|
||||||
|
Trigger: trigger,
|
||||||
|
State: state,
|
||||||
|
Detail: detail,
|
||||||
|
LogFile: logFile,
|
||||||
|
Output: output,
|
||||||
|
}
|
||||||
|
// Keep a small in-memory history for the currently running GUI. Full command
|
||||||
|
// output is persisted to files, so retaining every past record in RAM would
|
||||||
|
// only duplicate data and make long sessions grow without bound.
|
||||||
|
job.Logs = append([]RunRecord{record}, job.Logs...)
|
||||||
|
if len(job.Logs) > 50 {
|
||||||
|
job.Logs = job.Logs[:50]
|
||||||
|
}
|
||||||
|
return record
|
||||||
|
}
|
||||||
|
|
||||||
|
func CleanupLogs(logsDir string, maxFiles int, maxAgeDays int) error {
|
||||||
|
entries, err := os.ReadDir(logsDir)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
type logFile struct {
|
||||||
|
path string
|
||||||
|
modTime time.Time
|
||||||
|
}
|
||||||
|
var logs []logFile
|
||||||
|
cutoff := time.Now().AddDate(0, 0, -maxAgeDays)
|
||||||
|
for _, entry := range entries {
|
||||||
|
// Only PySentry run logs are managed here. Directories and non-.log files
|
||||||
|
// are intentionally ignored so the user can keep notes or other artifacts
|
||||||
|
// in the same folder without the cleanup policy deleting them.
|
||||||
|
if entry.IsDir() || !strings.HasSuffix(strings.ToLower(entry.Name()), ".log") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
path := filepath.Join(logsDir, entry.Name())
|
||||||
|
info, err := entry.Info()
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if maxAgeDays > 0 && info.ModTime().Before(cutoff) {
|
||||||
|
// Cleanup is best-effort: failing to delete one file should not block
|
||||||
|
// the scheduler from running future jobs.
|
||||||
|
_ = os.Remove(path)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
logs = append(logs, logFile{path: path, modTime: info.ModTime()})
|
||||||
|
}
|
||||||
|
|
||||||
|
if maxFiles <= 0 || len(logs) <= maxFiles {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
sort.Slice(logs, func(i int, j int) bool {
|
||||||
|
// Newest files are kept first, then everything after maxFiles is removed.
|
||||||
|
// This matches the user's expectation that the most recent failures and
|
||||||
|
// command output remain available for investigation.
|
||||||
|
return logs[i].modTime.After(logs[j].modTime)
|
||||||
|
})
|
||||||
|
for _, old := range logs[maxFiles:] {
|
||||||
|
_ = os.Remove(old.path)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeRunLog(logsDir string, job Job, trigger string, state string, detail string, output string, started time.Time) string {
|
||||||
|
if strings.TrimSpace(logsDir) == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(logsDir, 0o755); err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
// The timestamp comes first so a plain directory listing is naturally sorted
|
||||||
|
// by run time. The job name is included for human scanning, but sanitized to
|
||||||
|
// avoid characters that are invalid on Windows or awkward on shells.
|
||||||
|
fileName := started.Format("20060102-150405") + "_" + sanitizeFileName(job.Name) + ".log"
|
||||||
|
path := filepath.Join(logsDir, fileName)
|
||||||
|
content := fmt.Sprintf("time: %s\njob_id: %d\njob_name: %s\ntrigger: %s\nstate: %s\ndetail: %s\ncommand: %s\n\n%s\n",
|
||||||
|
started.Format("2006-01-02 15:04:05"), job.ID, job.Name, trigger, state, detail, job.Command, output)
|
||||||
|
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
func sanitizeFileName(name string) string {
|
||||||
|
name = strings.TrimSpace(name)
|
||||||
|
if name == "" {
|
||||||
|
return "job"
|
||||||
|
}
|
||||||
|
var builder strings.Builder
|
||||||
|
for _, r := range name {
|
||||||
|
switch {
|
||||||
|
case unicode.IsLetter(r), unicode.IsDigit(r):
|
||||||
|
builder.WriteRune(r)
|
||||||
|
case r == '-', r == '_':
|
||||||
|
builder.WriteRune(r)
|
||||||
|
default:
|
||||||
|
builder.WriteRune('_')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result := strings.Trim(builder.String(), "_")
|
||||||
|
if result == "" {
|
||||||
|
return "job"
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func shellCommand(ctx context.Context, command string) *exec.Cmd {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
// cmd.exe /C preserves Windows users' expectations for commands such as
|
||||||
|
// "dir", "copy", variable expansion, and .bat/.cmd wrappers.
|
||||||
|
return exec.CommandContext(ctx, "cmd.exe", "/C", command)
|
||||||
|
}
|
||||||
|
// 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 formatOutput(stdout string, stderr string) string {
|
||||||
|
stdout = strings.TrimSpace(stdout)
|
||||||
|
stderr = strings.TrimSpace(stderr)
|
||||||
|
if stdout == "" {
|
||||||
|
// Showing an explicit placeholder is clearer than an empty panel in the
|
||||||
|
// GUI: the user can tell that the command ran but produced no stream data.
|
||||||
|
stdout = "<empty>"
|
||||||
|
}
|
||||||
|
if stderr == "" {
|
||||||
|
stderr = "<empty>"
|
||||||
|
}
|
||||||
|
return "stdout:\n" + stdout + "\n\nstderr:\n" + stderr
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package core
|
||||||
|
|
||||||
|
import "os/exec"
|
||||||
|
|
||||||
|
func configureHiddenWindow(command *exec.Cmd) {
|
||||||
|
// Non-Windows platforms do not create a new console window for sh -c from a
|
||||||
|
// desktop process in the same way Windows does, so no extra process attribute
|
||||||
|
// is required here.
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package core
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRunJobWritesLogFile(t *testing.T) {
|
||||||
|
logsDir := t.TempDir()
|
||||||
|
job := Job{
|
||||||
|
ID: 42,
|
||||||
|
Name: "Hello Test",
|
||||||
|
Command: echoCommand("hello from test"),
|
||||||
|
}
|
||||||
|
|
||||||
|
record := RunJob(context.Background(), &job, "Manual", logsDir)
|
||||||
|
if record.LogFile == "" {
|
||||||
|
t.Fatal("expected log file path")
|
||||||
|
}
|
||||||
|
if filepath.Dir(record.LogFile) != logsDir {
|
||||||
|
t.Fatalf("expected log in %q, got %q", logsDir, record.LogFile)
|
||||||
|
}
|
||||||
|
if !strings.Contains(filepath.Base(record.LogFile), "Hello_Test") {
|
||||||
|
t.Fatalf("expected job name in log filename, got %q", record.LogFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(record.LogFile)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
content := string(data)
|
||||||
|
for _, want := range []string{"trigger: Manual", "job_name: Hello Test", "hello from test"} {
|
||||||
|
if !strings.Contains(content, want) {
|
||||||
|
t.Fatalf("expected log content to contain %q, got:\n%s", want, content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package core
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os/exec"
|
||||||
|
"syscall"
|
||||||
|
)
|
||||||
|
|
||||||
|
func configureHiddenWindow(command *exec.Cmd) {
|
||||||
|
// PySentry is a GUI scheduler, so child commands should not flash a console
|
||||||
|
// window on Windows. CREATE_NO_WINDOW keeps cmd.exe and simple console tools
|
||||||
|
// quiet while stdout/stderr are still captured through pipes.
|
||||||
|
command.SysProcAttr = &syscall.SysProcAttr{
|
||||||
|
CreationFlags: 0x08000000,
|
||||||
|
HideWindow: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
package core
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/robfig/cron/v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
var cronParser = cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow | cron.Descriptor)
|
||||||
|
|
||||||
|
// Scheduler owns the timing loop for jobs that are currently loaded in the GUI.
|
||||||
|
// It receives a pointer to the jobs slice because the GUI edits the same slice;
|
||||||
|
// this keeps the early architecture simple while storage and scheduling are
|
||||||
|
// still in one desktop process.
|
||||||
|
type Scheduler struct {
|
||||||
|
store *Store
|
||||||
|
jobs *[]Job
|
||||||
|
onChange func(RunRecord)
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
ctx context.Context
|
||||||
|
cancel context.CancelFunc
|
||||||
|
paused bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewScheduler(store *Store, jobs *[]Job, onChange func(RunRecord)) *Scheduler {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
s := &Scheduler{
|
||||||
|
store: store,
|
||||||
|
jobs: jobs,
|
||||||
|
onChange: onChange,
|
||||||
|
ctx: ctx,
|
||||||
|
cancel: cancel,
|
||||||
|
}
|
||||||
|
s.resetNextRuns(time.Now())
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Scheduler) Start() {
|
||||||
|
// A one-second ticker is accurate enough for cron-style desktop automation
|
||||||
|
// and avoids the complexity of maintaining one timer per job. Five-field cron
|
||||||
|
// expressions have minute precision, while @every values may be shorter for
|
||||||
|
// testing and lightweight local tasks.
|
||||||
|
ticker := time.NewTicker(time.Second)
|
||||||
|
go func() {
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-s.ctx.Done():
|
||||||
|
return
|
||||||
|
case now := <-ticker.C:
|
||||||
|
s.tick(now)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Scheduler) Stop() {
|
||||||
|
s.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Scheduler) SetPaused(paused bool) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
s.paused = paused
|
||||||
|
now := time.Now()
|
||||||
|
// Pause state is reflected into each job's display string so the list view is
|
||||||
|
// understandable even before the next scheduler tick.
|
||||||
|
for index := range *s.jobs {
|
||||||
|
job := &(*s.jobs)[index]
|
||||||
|
if !job.Enabled {
|
||||||
|
job.NextRun = "Paused"
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if paused {
|
||||||
|
job.NextRun = "Scheduler paused"
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
s.prepareNextRun(job, now)
|
||||||
|
}
|
||||||
|
_ = s.store.SaveJobs(*s.jobs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Scheduler) RunNow(index int) bool {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
if index < 0 || index >= len(*s.jobs) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// Manual runs share the same runner and log writer as scheduled runs. The
|
||||||
|
// Trigger field is the only difference, which keeps History comparable and
|
||||||
|
// prevents "Run now" from becoming a separate behavior path.
|
||||||
|
return s.startRunLocked(index, "Manual")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Scheduler) RefreshSchedule(index int) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
if index < 0 || index >= len(*s.jobs) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
job := &(*s.jobs)[index]
|
||||||
|
if !job.Enabled {
|
||||||
|
job.NextRun = "Paused"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if s.paused {
|
||||||
|
job.NextRun = "Scheduler paused"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.prepareNextRun(job, time.Now())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Scheduler) tick(now time.Time) {
|
||||||
|
var changed bool
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
if !s.paused {
|
||||||
|
for index := range *s.jobs {
|
||||||
|
job := &(*s.jobs)[index]
|
||||||
|
if !job.Enabled || job.nextDue.IsZero() || now.Before(job.nextDue) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Run only one due job per tick for now. That avoids overlapping shell
|
||||||
|
// commands in the GUI process and keeps the first version predictable;
|
||||||
|
// a future worker pool can add concurrency once cancellation and status
|
||||||
|
// reporting are more explicit.
|
||||||
|
changed = s.startRunLocked(index, "Schedule")
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
_ = changed
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Scheduler) startRunLocked(index int, trigger string) bool {
|
||||||
|
job := &(*s.jobs)[index]
|
||||||
|
if job.LastState == "Running" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
jobCopy := *job
|
||||||
|
job.LastState = "Running"
|
||||||
|
job.NextRun = "Running"
|
||||||
|
job.nextDue = time.Time{}
|
||||||
|
_ = s.store.SaveJobs(*s.jobs)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
record := RunJob(s.ctx, &jobCopy, trigger, s.store.Paths.LogsDir)
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
if current := s.findJobByIDLocked(jobCopy.ID); current != nil {
|
||||||
|
current.LastRun = record.Time
|
||||||
|
current.LastState = record.State
|
||||||
|
current.Output = record.Output
|
||||||
|
current.Logs = append([]RunRecord{record}, current.Logs...)
|
||||||
|
if len(current.Logs) > 50 {
|
||||||
|
current.Logs = current.Logs[:50]
|
||||||
|
}
|
||||||
|
s.prepareNextRun(current, time.Now())
|
||||||
|
_ = CleanupLogs(s.store.Paths.LogsDir, s.store.Config.MaxLogFiles, s.store.Config.MaxLogAgeDays)
|
||||||
|
_ = s.store.SaveJobs(*s.jobs)
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
if s.onChange != nil {
|
||||||
|
s.onChange(record)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Scheduler) findJobByIDLocked(id int) *Job {
|
||||||
|
for index := range *s.jobs {
|
||||||
|
if (*s.jobs)[index].ID == id {
|
||||||
|
return &(*s.jobs)[index]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Scheduler) resetNextRuns(now time.Time) {
|
||||||
|
for index := range *s.jobs {
|
||||||
|
job := &(*s.jobs)[index]
|
||||||
|
if !job.Enabled {
|
||||||
|
job.NextRun = "Paused"
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
s.prepareNextRun(job, now)
|
||||||
|
}
|
||||||
|
_ = s.store.SaveJobs(*s.jobs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Scheduler) prepareNextRun(job *Job, from time.Time) {
|
||||||
|
next, ok := nextRunTime(job.Schedule, from)
|
||||||
|
if !ok {
|
||||||
|
job.NextRun = "Invalid schedule"
|
||||||
|
job.nextDue = time.Time{}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
job.nextDue = next
|
||||||
|
job.NextRun = job.nextDue.Format("2006-01-02 15:04:05")
|
||||||
|
}
|
||||||
|
|
||||||
|
func nextRunTime(schedule string, from time.Time) (time.Time, bool) {
|
||||||
|
schedule = strings.TrimSpace(schedule)
|
||||||
|
if schedule == "" {
|
||||||
|
return time.Time{}, false
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(schedule, "@every ") {
|
||||||
|
// @every is kept alongside cron because it is convenient for quick tests
|
||||||
|
// and for simple intervals that are awkward to express as five fields.
|
||||||
|
interval, err := time.ParseDuration(strings.TrimSpace(strings.TrimPrefix(schedule, "@every ")))
|
||||||
|
if err != nil || interval <= 0 {
|
||||||
|
return time.Time{}, false
|
||||||
|
}
|
||||||
|
return from.Add(interval), true
|
||||||
|
}
|
||||||
|
// Standard five-field cron keeps PySentry compatible with the mental model
|
||||||
|
// users already know from Unix cron, while robfig/cron handles edge cases
|
||||||
|
// such as ranges, steps, and day-of-week names.
|
||||||
|
parsed, err := cronParser.Parse(schedule)
|
||||||
|
if err != nil {
|
||||||
|
return time.Time{}, false
|
||||||
|
}
|
||||||
|
return parsed.Next(from), true
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package core
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNextRunTimeSupportsEvery(t *testing.T) {
|
||||||
|
from := time.Date(2026, 6, 14, 12, 0, 0, 0, time.UTC)
|
||||||
|
next, ok := nextRunTime("@every 10s", from)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected @every schedule to parse")
|
||||||
|
}
|
||||||
|
if want := from.Add(10 * time.Second); !next.Equal(want) {
|
||||||
|
t.Fatalf("expected %s, got %s", want, next)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNextRunTimeSupportsCron(t *testing.T) {
|
||||||
|
from := time.Date(2026, 6, 14, 12, 3, 0, 0, time.UTC)
|
||||||
|
next, ok := nextRunTime("*/5 * * * *", from)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected cron schedule to parse")
|
||||||
|
}
|
||||||
|
want := time.Date(2026, 6, 14, 12, 5, 0, 0, time.UTC)
|
||||||
|
if !next.Equal(want) {
|
||||||
|
t.Fatalf("expected %s, got %s", want, next)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
package core
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"go.yaml.in/yaml/v4"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Store struct {
|
||||||
|
Paths Paths
|
||||||
|
Config Config
|
||||||
|
}
|
||||||
|
|
||||||
|
func OpenStore() (*Store, []Job, error) {
|
||||||
|
paths, err := ResolvePaths()
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
store := &Store{Paths: paths}
|
||||||
|
config, err := loadOrCreateConfig(paths)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
store.Config = config
|
||||||
|
store.applyConfigPaths()
|
||||||
|
// Save the config after loading so missing defaults are written back. This
|
||||||
|
// rewrites old or hand-edited files into the current clean schema without
|
||||||
|
// forcing the user to delete them manually.
|
||||||
|
if err := store.SaveConfig(); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
jobs, err := loadOrCreateJobs(store.Paths.JobsPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
normalizeJobs(jobs)
|
||||||
|
// Jobs are also rewritten after normalization. That keeps jobs.yaml compact:
|
||||||
|
// only durable job definitions remain, because runtime fields are tagged
|
||||||
|
// yaml:"-" in the model.
|
||||||
|
if err := store.SaveJobs(jobs); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
return store, jobs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) SaveConfig() error {
|
||||||
|
s.applyConfigPaths()
|
||||||
|
if err := os.MkdirAll(s.Paths.AppDir, 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return writeYAML(s.Paths.ConfigPath, s.Config)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) SaveJobs(jobs []Job) error {
|
||||||
|
if err := os.MkdirAll(s.Paths.JobsDir, 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return writeYAML(s.Paths.JobsPath, JobsFile{Jobs: jobs})
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadOrCreateConfig(paths Paths) (Config, error) {
|
||||||
|
// Defaults favor a portable installation: settings and jobs begin next to the
|
||||||
|
// executable, while logs are grouped under a dedicated subdirectory.
|
||||||
|
config := Config{
|
||||||
|
JobsDir: ".",
|
||||||
|
LogsDir: "logs",
|
||||||
|
MaxLogFiles: 100,
|
||||||
|
MaxLogAgeDays: 30,
|
||||||
|
StartOnLogin: false,
|
||||||
|
KeepRunningInTray: true,
|
||||||
|
NotifyOnFailure: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := os.Stat(paths.ConfigPath); errors.Is(err, os.ErrNotExist) {
|
||||||
|
return config, writeYAML(paths.ConfigPath, config)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(paths.ConfigPath)
|
||||||
|
if err != nil {
|
||||||
|
return Config{}, err
|
||||||
|
}
|
||||||
|
if err := yaml.Unmarshal(data, &config); err != nil {
|
||||||
|
return Config{}, err
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(config.JobsDir) == "" {
|
||||||
|
// Empty paths are treated as missing values rather than intentional root
|
||||||
|
// directories. This avoids accidentally writing jobs to unexpected places.
|
||||||
|
config.JobsDir = "."
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(config.LogsDir) == "" {
|
||||||
|
config.LogsDir = "logs"
|
||||||
|
}
|
||||||
|
if config.MaxLogFiles <= 0 {
|
||||||
|
config.MaxLogFiles = 100
|
||||||
|
}
|
||||||
|
if config.MaxLogAgeDays <= 0 {
|
||||||
|
config.MaxLogAgeDays = 30
|
||||||
|
}
|
||||||
|
return config, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadOrCreateJobs(path string) ([]Job, error) {
|
||||||
|
if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
|
||||||
|
// The first run creates harmless sample jobs so a new user can immediately
|
||||||
|
// see scheduled and manual execution without inventing a command.
|
||||||
|
jobs := defaultJobs()
|
||||||
|
normalizeJobs(jobs)
|
||||||
|
return jobs, writeYAML(path, JobsFile{Jobs: jobs})
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var file JobsFile
|
||||||
|
if err := yaml.Unmarshal(data, &file); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return file.Jobs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeJobs(jobs []Job) {
|
||||||
|
next := 1
|
||||||
|
for index := range jobs {
|
||||||
|
job := &jobs[index]
|
||||||
|
if job.ID <= 0 {
|
||||||
|
// IDs are assigned only when absent. Existing IDs stay stable because
|
||||||
|
// History and future log associations use them to identify jobs.
|
||||||
|
job.ID = next
|
||||||
|
}
|
||||||
|
if job.ID >= next {
|
||||||
|
next = job.ID + 1
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(job.Name) == "" {
|
||||||
|
job.Name = "Untitled job"
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(job.Schedule) == "" {
|
||||||
|
job.Schedule = "@every 1m"
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(job.Command) == "" {
|
||||||
|
// An empty command would fail in a confusing way. A safe echo command
|
||||||
|
// gives the user something observable and harmless instead.
|
||||||
|
job.Command = echoCommand("PySentry job ran")
|
||||||
|
}
|
||||||
|
if job.LastRun == "" {
|
||||||
|
job.LastRun = "Never"
|
||||||
|
}
|
||||||
|
if job.Output == "" {
|
||||||
|
job.Output = "No command output captured yet."
|
||||||
|
}
|
||||||
|
if job.Enabled {
|
||||||
|
job.LastState = "Ready"
|
||||||
|
job.NextRun = "After start"
|
||||||
|
} else {
|
||||||
|
job.LastState = "Paused"
|
||||||
|
job.NextRun = "Paused"
|
||||||
|
}
|
||||||
|
// Runtime fields are reconstructed each time the app starts. Persisted run
|
||||||
|
// records live in log files, not in jobs.yaml, to keep the jobs file easy
|
||||||
|
// to review and edit by hand.
|
||||||
|
job.Logs = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveJobsDir(appDir string, jobsDir string) string {
|
||||||
|
return resolveConfiguredDir(appDir, jobsDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveConfiguredDir(appDir string, dir string) string {
|
||||||
|
if filepath.IsAbs(dir) {
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
// Relative paths are resolved against the executable directory, not the
|
||||||
|
// process working directory. This matches ResolvePaths and keeps shortcuts,
|
||||||
|
// Explorer launches, and terminal launches consistent.
|
||||||
|
return filepath.Clean(filepath.Join(appDir, dir))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) applyConfigPaths() {
|
||||||
|
s.Paths.JobsDir = resolveConfiguredDir(s.Paths.AppDir, s.Config.JobsDir)
|
||||||
|
s.Paths.JobsPath = filepath.Join(s.Paths.JobsDir, JobsFileName)
|
||||||
|
s.Paths.LogsDir = resolveConfiguredDir(s.Paths.AppDir, s.Config.LogsDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeYAML(path string, value any) error {
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
data, err := yaml.Marshal(value)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// WriteFile replaces the full file instead of patching it in place. For small
|
||||||
|
// YAML files this is simpler and prevents stale keys from older versions from
|
||||||
|
// lingering after the schema changes.
|
||||||
|
return os.WriteFile(path, data, 0o644)
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultJobs() []Job {
|
||||||
|
return []Job{
|
||||||
|
{
|
||||||
|
ID: 1,
|
||||||
|
Name: "Hello scheduler",
|
||||||
|
Folder: "Examples",
|
||||||
|
Schedule: "@every 1m",
|
||||||
|
Command: echoCommand("PySentry test job: scheduler is alive"),
|
||||||
|
Enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: 2,
|
||||||
|
Name: "Write timestamp",
|
||||||
|
Folder: "Examples",
|
||||||
|
Schedule: "*/1 * * * *",
|
||||||
|
Command: echoCommand("PySentry test job: timestamp command ran"),
|
||||||
|
Enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: 3,
|
||||||
|
Name: "Paused sample",
|
||||||
|
Schedule: "@every 1m",
|
||||||
|
Command: echoCommand("This paused sample should not run until enabled"),
|
||||||
|
Enabled: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func echoCommand(message string) string {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
return "echo " + message
|
||||||
|
}
|
||||||
|
// POSIX shells need quotes for messages with spaces. Single quotes inside the
|
||||||
|
// message are escaped using the standard close-quote/backslash/reopen pattern.
|
||||||
|
return "echo '" + strings.ReplaceAll(message, "'", "'\\''") + "'"
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package core
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"go.yaml.in/yaml/v4"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestJobsYAMLDoesNotPersistRuntimeNoise(t *testing.T) {
|
||||||
|
jobs := []Job{
|
||||||
|
{
|
||||||
|
ID: 1,
|
||||||
|
Name: "Clean job",
|
||||||
|
Schedule: "@every 10s",
|
||||||
|
Command: echoCommand("ok"),
|
||||||
|
Enabled: true,
|
||||||
|
LastRun: "2026-06-14 12:00:00",
|
||||||
|
NextRun: "2026-06-14 12:00:10",
|
||||||
|
LastState: "OK",
|
||||||
|
Output: "stdout: ok",
|
||||||
|
Logs: []RunRecord{
|
||||||
|
{Time: "2026-06-14 12:00:00", JobName: "Clean job", Output: "stdout: ok"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := yaml.Marshal(JobsFile{Jobs: jobs})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
text := string(data)
|
||||||
|
for _, unwanted := range []string{"last_run", "next_run", "last_state", "activity", "last_output", "stdout"} {
|
||||||
|
if strings.Contains(text, unwanted) {
|
||||||
|
t.Fatalf("jobs yaml should not contain %q:\n%s", unwanted, text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package core
|
||||||
|
|
||||||
|
// Version is the application version shown in the GUI and used by build
|
||||||
|
// scripts in artifact names. It is a var rather than a const so release builds
|
||||||
|
// can override it with Go ldflags when CI tags a build.
|
||||||
|
var Version = "0.2.4"
|
||||||
+958
@@ -0,0 +1,958 @@
|
|||||||
|
package gui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/url"
|
||||||
|
"runtime"
|
||||||
|
"runtime/debug"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/pysentry/pysentry/assets"
|
||||||
|
"github.com/pysentry/pysentry/src/core"
|
||||||
|
|
||||||
|
"fyne.io/fyne/v2"
|
||||||
|
"fyne.io/fyne/v2/app"
|
||||||
|
"fyne.io/fyne/v2/container"
|
||||||
|
"fyne.io/fyne/v2/dialog"
|
||||||
|
"fyne.io/fyne/v2/driver/desktop"
|
||||||
|
"fyne.io/fyne/v2/layout"
|
||||||
|
"fyne.io/fyne/v2/theme"
|
||||||
|
"fyne.io/fyne/v2/widget"
|
||||||
|
)
|
||||||
|
|
||||||
|
const appID = "io.github.pysentry.desktop"
|
||||||
|
const allFolders = "All"
|
||||||
|
const noFolder = "No folder"
|
||||||
|
const minJobsSidebarWidth float32 = 480
|
||||||
|
const settingsLabelWidth float32 = 140
|
||||||
|
const settingsControlWidth float32 = 330
|
||||||
|
const settingsStatusWidth float32 = 280
|
||||||
|
const projectRepositoryURL = "https://gitea.mixdep.ru/mix/gosentry"
|
||||||
|
const singleInstanceAddress = "127.0.0.1:37653"
|
||||||
|
const singleInstanceShowCommand = "show"
|
||||||
|
|
||||||
|
// The GUI package aliases core types to keep widget callbacks short. The actual
|
||||||
|
// durable model still lives in src/core, so GUI code does not define a second
|
||||||
|
// copy of the scheduler data.
|
||||||
|
type job = core.Job
|
||||||
|
type event = core.RunRecord
|
||||||
|
|
||||||
|
func Run() {
|
||||||
|
started := time.Now()
|
||||||
|
instanceListener, primary := acquireSingleInstance()
|
||||||
|
if !primary {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if instanceListener != nil {
|
||||||
|
defer instanceListener.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// A stable app ID lets Fyne persist desktop preferences consistently across
|
||||||
|
// launches and gives tray/window integration a predictable identity.
|
||||||
|
a := app.NewWithID(appID)
|
||||||
|
a.SetIcon(loadAppIcon())
|
||||||
|
|
||||||
|
w := a.NewWindow("PySentry " + core.Version)
|
||||||
|
configureSystemTray(a, w)
|
||||||
|
w.Resize(fyne.NewSize(1120, 720))
|
||||||
|
w.SetContent(newMainView(w, started))
|
||||||
|
serveSingleInstance(instanceListener, w)
|
||||||
|
w.ShowAndRun()
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadAppIcon() fyne.Resource {
|
||||||
|
return assets.Icon()
|
||||||
|
}
|
||||||
|
|
||||||
|
func configureSystemTray(a fyne.App, w fyne.Window) {
|
||||||
|
desk, ok := a.(desktop.App)
|
||||||
|
if !ok {
|
||||||
|
// Not every Fyne driver exposes desktop tray features. Returning silently
|
||||||
|
// keeps the same binary usable on platforms or sessions without a tray.
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
menu := fyne.NewMenu("PySentry",
|
||||||
|
fyne.NewMenuItem("Show", func() {
|
||||||
|
w.Show()
|
||||||
|
w.RequestFocus()
|
||||||
|
}),
|
||||||
|
fyne.NewMenuItemSeparator(),
|
||||||
|
fyne.NewMenuItem("Quit", func() {
|
||||||
|
a.Quit()
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
desk.SetSystemTrayMenu(menu)
|
||||||
|
w.SetCloseIntercept(func() {
|
||||||
|
// Closing hides the window instead of quitting because scheduler tools are
|
||||||
|
// expected to keep working in the background. The explicit Quit tray item
|
||||||
|
// remains the way to stop the process.
|
||||||
|
w.Hide()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func acquireSingleInstance() (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 {
|
||||||
|
_, _ = 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, started time.Time) fyne.CanvasObject {
|
||||||
|
store, jobs, err := core.OpenStore()
|
||||||
|
if err != nil {
|
||||||
|
return container.NewPadded(widget.NewLabel("Failed to load PySentry configuration: " + err.Error()))
|
||||||
|
}
|
||||||
|
if iconPath, err := core.InstallDesktopIntegration(appID, store.Paths.ExecutablePath, assets.IconBytes()); err == nil {
|
||||||
|
store.Paths.DesktopIcon = iconPath
|
||||||
|
}
|
||||||
|
startupDuration := time.Since(started).Round(time.Millisecond)
|
||||||
|
events := append(collectActivity(jobs), newEvent(0, "Application", "Started", "Startup completed in "+startupDuration.String()))
|
||||||
|
|
||||||
|
// The GUI keeps the loaded jobs slice in memory and persists changes after
|
||||||
|
// each edit/run. This keeps the first version responsive and easy to reason
|
||||||
|
// about; a database would be unnecessary overhead for one YAML file.
|
||||||
|
nextJobID := nextID(jobs)
|
||||||
|
selected := 0
|
||||||
|
selectedFolder := allFolders
|
||||||
|
schedulerPaused := false
|
||||||
|
filteredJobs := filteredJobIndexes(jobs, selectedFolder)
|
||||||
|
title := widget.NewLabelWithStyle(jobs[selected].Name, fyne.TextAlignLeading, fyne.TextStyle{Bold: true})
|
||||||
|
folder := widget.NewLabel(jobs[selected].Folder)
|
||||||
|
schedule := widget.NewLabel(jobs[selected].Schedule)
|
||||||
|
command := widget.NewLabel(jobs[selected].Command)
|
||||||
|
lastRun := widget.NewLabel(jobs[selected].LastRun)
|
||||||
|
nextRun := widget.NewLabel(jobs[selected].NextRun)
|
||||||
|
state := widget.NewLabel(jobs[selected].LastState)
|
||||||
|
schedulerState := widget.NewLabel("Scheduler running")
|
||||||
|
commandOutput := widget.NewTextGrid()
|
||||||
|
commandOutput.SetText(jobs[selected].Output)
|
||||||
|
commandOutputScroll := container.NewScroll(commandOutput)
|
||||||
|
// Command output can contain long lines and preserved whitespace. TextGrid is
|
||||||
|
// used instead of Label so stdout/stderr remains readable and does not vanish
|
||||||
|
// against the theme when it is placed inside a scroll container.
|
||||||
|
commandOutputScroll.SetMinSize(fyne.NewSize(520, 160))
|
||||||
|
history := newHistoryView(&events)
|
||||||
|
selectedLogs := append([]event(nil), jobs[selected].Logs...)
|
||||||
|
jobLogs := widget.NewList(
|
||||||
|
func() int {
|
||||||
|
return len(selectedLogs)
|
||||||
|
},
|
||||||
|
func() fyne.CanvasObject { return widget.NewLabel("log") },
|
||||||
|
func(id widget.ListItemID, item fyne.CanvasObject) {
|
||||||
|
item.(*widget.Label).SetText(eventText(selectedLogs[id]))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
updateDetails := func(index int) {
|
||||||
|
if index < 0 || index >= len(jobs) {
|
||||||
|
// A folder filter can temporarily leave no selectable rows. Clearing
|
||||||
|
// the details panel avoids showing stale information for a hidden job.
|
||||||
|
title.SetText("No job selected")
|
||||||
|
folder.SetText("")
|
||||||
|
schedule.SetText("")
|
||||||
|
command.SetText("")
|
||||||
|
lastRun.SetText("")
|
||||||
|
nextRun.SetText("")
|
||||||
|
state.SetText("")
|
||||||
|
commandOutput.SetText("")
|
||||||
|
selectedLogs = nil
|
||||||
|
return
|
||||||
|
}
|
||||||
|
selected = index
|
||||||
|
current := jobs[selected]
|
||||||
|
title.SetText(current.Name)
|
||||||
|
folder.SetText(displayFolder(current.Folder))
|
||||||
|
schedule.SetText(current.Schedule)
|
||||||
|
command.SetText(current.Command)
|
||||||
|
lastRun.SetText(current.LastRun)
|
||||||
|
nextRun.SetText(current.NextRun)
|
||||||
|
state.SetText(current.LastState)
|
||||||
|
commandOutput.SetText(current.Output)
|
||||||
|
selectedLogs = append(selectedLogs[:0], current.Logs...)
|
||||||
|
}
|
||||||
|
refresh := func() {
|
||||||
|
// Several callbacks mutate jobs, filters, and event history. A single
|
||||||
|
// refresh closure keeps the different widgets synchronized after each
|
||||||
|
// mutation without introducing a heavier state-management layer.
|
||||||
|
filteredJobs = filteredJobIndexes(jobs, selectedFolder)
|
||||||
|
updateDetails(selected)
|
||||||
|
jobLogs.Refresh()
|
||||||
|
history.Refresh()
|
||||||
|
}
|
||||||
|
var scheduler *core.Scheduler
|
||||||
|
|
||||||
|
list := widget.NewList(
|
||||||
|
func() int { return len(filteredJobs) },
|
||||||
|
func() fyne.CanvasObject {
|
||||||
|
name := widget.NewLabelWithStyle("Job name", fyne.TextAlignLeading, fyne.TextStyle{Bold: true})
|
||||||
|
meta := widget.NewLabel("schedule")
|
||||||
|
status := widget.NewLabel("status")
|
||||||
|
return container.NewVBox(name, meta, status)
|
||||||
|
},
|
||||||
|
func(id widget.ListItemID, item fyne.CanvasObject) {
|
||||||
|
row := item.(*fyne.Container)
|
||||||
|
name := row.Objects[0].(*widget.Label)
|
||||||
|
meta := row.Objects[1].(*widget.Label)
|
||||||
|
status := row.Objects[2].(*widget.Label)
|
||||||
|
|
||||||
|
current := jobs[filteredJobs[id]]
|
||||||
|
name.SetText(current.Name)
|
||||||
|
// Keep each row compact: folder, schedule, and command are shown in one
|
||||||
|
// metadata line so the left pane stays useful even with many jobs.
|
||||||
|
meta.SetText(displayFolder(current.Folder) + " " + current.Schedule + " " + current.Command)
|
||||||
|
status.SetText(statusText(current))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
list.OnSelected = func(id widget.ListItemID) {
|
||||||
|
if id < 0 || id >= len(filteredJobs) {
|
||||||
|
updateDetails(-1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
updateDetails(filteredJobs[id])
|
||||||
|
}
|
||||||
|
list.Select(selected)
|
||||||
|
|
||||||
|
folderSelect := widget.NewSelect(folderOptions(jobs), func(value string) {
|
||||||
|
if value == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
selectedFolder = value
|
||||||
|
filteredJobs = filteredJobIndexes(jobs, selectedFolder)
|
||||||
|
list.Refresh()
|
||||||
|
if len(filteredJobs) == 0 {
|
||||||
|
// The "No folder" filter is intentionally allowed to be empty. It is a
|
||||||
|
// real filter choice, not an error state, so the selection is cleared.
|
||||||
|
selected = -1
|
||||||
|
updateDetails(-1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
selected = filteredJobs[0]
|
||||||
|
list.Select(0)
|
||||||
|
refresh()
|
||||||
|
})
|
||||||
|
folderSelect.SetSelected(selectedFolder)
|
||||||
|
|
||||||
|
addButton := widget.NewButtonWithIcon("New job", theme.ContentAddIcon(), func() {
|
||||||
|
showJobDialog(w, "New job", job{Schedule: "@every 1m", Command: "echo PySentry job ran", Enabled: true, LastRun: "Never", NextRun: "After save", LastState: "Ready"}, func(saved job) {
|
||||||
|
saved.ID = nextJobID
|
||||||
|
nextJobID++
|
||||||
|
jobs = append(jobs, saved)
|
||||||
|
selected = len(jobs) - 1
|
||||||
|
created := newEvent(saved.ID, saved.Name, "Created", "Job was added")
|
||||||
|
// UI events are kept in memory for the current session. They explain
|
||||||
|
// user actions in History, while command output remains in log files.
|
||||||
|
jobs[selected].Logs = append([]event{created}, jobs[selected].Logs...)
|
||||||
|
events = append(events, created)
|
||||||
|
_ = store.SaveJobs(jobs)
|
||||||
|
folderSelect.Options = folderOptions(jobs)
|
||||||
|
folderSelect.Refresh()
|
||||||
|
targetFolder := filterValue(saved.Folder)
|
||||||
|
if selectedFolder != allFolders && selectedFolder != targetFolder {
|
||||||
|
selectedFolder = targetFolder
|
||||||
|
folderSelect.SetSelected(targetFolder)
|
||||||
|
}
|
||||||
|
filteredJobs = filteredJobIndexes(jobs, selectedFolder)
|
||||||
|
list.Refresh()
|
||||||
|
list.Select(displayIndex(filteredJobs, selected))
|
||||||
|
refresh()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
editButton := widget.NewButtonWithIcon("Edit", theme.DocumentCreateIcon(), func() {
|
||||||
|
if selected < 0 || selected >= len(jobs) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
showJobDialog(w, "Edit job", jobs[selected], func(saved job) {
|
||||||
|
saved.ID = jobs[selected].ID
|
||||||
|
saved.Logs = jobs[selected].Logs
|
||||||
|
saved.Output = jobs[selected].Output
|
||||||
|
jobs[selected] = saved
|
||||||
|
updated := newEvent(saved.ID, saved.Name, "Updated", "Job settings changed")
|
||||||
|
jobs[selected].Logs = append([]event{updated}, jobs[selected].Logs...)
|
||||||
|
events = append(events, updated)
|
||||||
|
if scheduler != nil {
|
||||||
|
scheduler.RefreshSchedule(selected)
|
||||||
|
}
|
||||||
|
_ = store.SaveJobs(jobs)
|
||||||
|
folderSelect.Options = folderOptions(jobs)
|
||||||
|
folderSelect.Refresh()
|
||||||
|
list.Refresh()
|
||||||
|
refresh()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
runButton := widget.NewButtonWithIcon("Run now", theme.MediaPlayIcon(), func() {
|
||||||
|
if selected < 0 || selected >= len(jobs) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if schedulerPaused {
|
||||||
|
// The global pause is treated as an emergency stop for all execution,
|
||||||
|
// including manual "Run now", so the user has one reliable switch.
|
||||||
|
dialog.ShowInformation("Scheduler paused", "Global pause is active. Resume the scheduler before running jobs.", w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !scheduler.RunNow(selected) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
list.Refresh()
|
||||||
|
refresh()
|
||||||
|
})
|
||||||
|
stopAllButton := widget.NewButtonWithIcon("Pause all", theme.MediaStopIcon(), nil)
|
||||||
|
stopAllButton.OnTapped = func() {
|
||||||
|
schedulerPaused = !schedulerPaused
|
||||||
|
if schedulerPaused {
|
||||||
|
schedulerState.SetText("Scheduler paused")
|
||||||
|
stopAllButton.SetText("Resume all")
|
||||||
|
stopAllButton.SetIcon(theme.MediaPlayIcon())
|
||||||
|
for index := range jobs {
|
||||||
|
if jobs[index].Enabled {
|
||||||
|
jobs[index].NextRun = "Scheduler paused"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if scheduler != nil {
|
||||||
|
scheduler.SetPaused(true)
|
||||||
|
}
|
||||||
|
events = append(events, newEvent(0, "Scheduler", "Paused", "All job execution paused"))
|
||||||
|
} else {
|
||||||
|
schedulerState.SetText("Scheduler running")
|
||||||
|
stopAllButton.SetText("Pause all")
|
||||||
|
stopAllButton.SetIcon(theme.MediaStopIcon())
|
||||||
|
for index := range jobs {
|
||||||
|
if jobs[index].Enabled && jobs[index].NextRun == "Scheduler paused" {
|
||||||
|
// The scheduler will calculate the exact next run when it is
|
||||||
|
// resumed; this interim text prevents a stale paused timestamp.
|
||||||
|
jobs[index].NextRun = "Waiting for scheduler"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if scheduler != nil {
|
||||||
|
scheduler.SetPaused(false)
|
||||||
|
}
|
||||||
|
events = append(events, newEvent(0, "Scheduler", "Resumed", "All job execution resumed"))
|
||||||
|
}
|
||||||
|
list.Refresh()
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
pauseButton := widget.NewButtonWithIcon("Pause", theme.MediaPauseIcon(), func() {
|
||||||
|
if selected < 0 || selected >= len(jobs) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
current := &jobs[selected]
|
||||||
|
current.Enabled = !current.Enabled
|
||||||
|
if current.Enabled {
|
||||||
|
current.LastState = "Ready"
|
||||||
|
current.NextRun = "Waiting for scheduler"
|
||||||
|
resumed := newEvent(current.ID, current.Name, "Resumed", "Job was enabled")
|
||||||
|
current.Logs = append([]event{resumed}, current.Logs...)
|
||||||
|
events = append(events, resumed)
|
||||||
|
if scheduler != nil {
|
||||||
|
scheduler.RefreshSchedule(selected)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
current.LastState = "Paused"
|
||||||
|
current.NextRun = "Paused"
|
||||||
|
paused := newEvent(current.ID, current.Name, "Paused", "Job was disabled")
|
||||||
|
current.Logs = append([]event{paused}, current.Logs...)
|
||||||
|
events = append(events, paused)
|
||||||
|
if scheduler != nil {
|
||||||
|
scheduler.RefreshSchedule(selected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = store.SaveJobs(jobs)
|
||||||
|
list.Refresh()
|
||||||
|
refresh()
|
||||||
|
})
|
||||||
|
deleteButton := widget.NewButtonWithIcon("Delete", theme.DeleteIcon(), func() {
|
||||||
|
if selected < 0 || selected >= len(jobs) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
deleted := jobs[selected]
|
||||||
|
// Deletion is confirmed because jobs can represent real system actions.
|
||||||
|
// There is no undo yet, so accidental removal should require one more click.
|
||||||
|
dialog.ShowConfirm("Delete job", fmt.Sprintf("Delete %q?", deleted.Name), func(confirm bool) {
|
||||||
|
if !confirm {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
jobs = append(jobs[:selected], jobs[selected+1:]...)
|
||||||
|
folderSelect.Options = folderOptions(jobs)
|
||||||
|
folderSelect.Refresh()
|
||||||
|
filteredJobs = filteredJobIndexes(jobs, selectedFolder)
|
||||||
|
if len(filteredJobs) == 0 && selectedFolder != allFolders {
|
||||||
|
selectedFolder = allFolders
|
||||||
|
folderSelect.SetSelected(allFolders)
|
||||||
|
filteredJobs = filteredJobIndexes(jobs, selectedFolder)
|
||||||
|
}
|
||||||
|
if len(filteredJobs) == 0 {
|
||||||
|
selected = -1
|
||||||
|
} else {
|
||||||
|
selected = filteredJobs[0]
|
||||||
|
}
|
||||||
|
events = append(events, newEvent(deleted.ID, deleted.Name, "Deleted", "Job was removed"))
|
||||||
|
_ = store.SaveJobs(jobs)
|
||||||
|
list.Refresh()
|
||||||
|
if selected >= 0 {
|
||||||
|
list.Select(displayIndex(filteredJobs, selected))
|
||||||
|
}
|
||||||
|
refresh()
|
||||||
|
}, w)
|
||||||
|
})
|
||||||
|
|
||||||
|
toolbar := container.NewHBox(addButton, editButton, runButton, pauseButton, deleteButton, layout.NewSpacer())
|
||||||
|
globalControls := container.NewHBox(stopAllButton, schedulerState, layout.NewSpacer())
|
||||||
|
sidebarHeader := container.NewVBox(globalControls, widget.NewSeparator(), widget.NewLabelWithStyle("Folder", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), folderSelect, toolbar)
|
||||||
|
sidebar := container.NewBorder(sidebarHeader, nil, nil, nil, list)
|
||||||
|
|
||||||
|
details := container.NewVBox(
|
||||||
|
title,
|
||||||
|
widget.NewSeparator(),
|
||||||
|
detailRow("Folder", folder),
|
||||||
|
detailRow("Schedule", schedule),
|
||||||
|
detailRow("Command", command),
|
||||||
|
detailRow("Last run", lastRun),
|
||||||
|
detailRow("Next run", nextRun),
|
||||||
|
detailRow("State", state),
|
||||||
|
widget.NewSeparator(),
|
||||||
|
widget.NewLabelWithStyle("Command output", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
|
||||||
|
commandOutputScroll,
|
||||||
|
widget.NewSeparator(),
|
||||||
|
widget.NewLabelWithStyle("Selected job activity", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
|
||||||
|
jobLogs,
|
||||||
|
)
|
||||||
|
|
||||||
|
scheduler = core.NewScheduler(store, &jobs, func(record core.RunRecord) {
|
||||||
|
// Scheduled runs happen on the scheduler goroutine. The callback updates
|
||||||
|
// the shared in-memory event list so History reflects background activity.
|
||||||
|
events = append(events, record)
|
||||||
|
refresh()
|
||||||
|
})
|
||||||
|
scheduler.Start()
|
||||||
|
|
||||||
|
fixedSidebar := container.New(minWidthLayout{width: minJobsSidebarWidth}, sidebar)
|
||||||
|
jobsView := container.NewBorder(nil, nil, fixedSidebar, nil, container.NewPadded(details))
|
||||||
|
tabs := container.NewAppTabs(
|
||||||
|
container.NewTabItemWithIcon("Jobs", theme.ListIcon(), jobsView),
|
||||||
|
container.NewTabItemWithIcon("History", theme.HistoryIcon(), history),
|
||||||
|
container.NewTabItemWithIcon("Settings", theme.SettingsIcon(), settingsView(w, store, &jobs)),
|
||||||
|
)
|
||||||
|
tabs.SetTabLocation(container.TabLocationTop)
|
||||||
|
|
||||||
|
return tabs
|
||||||
|
}
|
||||||
|
|
||||||
|
type minWidthLayout struct {
|
||||||
|
width float32
|
||||||
|
}
|
||||||
|
|
||||||
|
func (layout minWidthLayout) MinSize(objects []fyne.CanvasObject) fyne.Size {
|
||||||
|
width := layout.width
|
||||||
|
var height float32
|
||||||
|
for _, object := range objects {
|
||||||
|
if !object.Visible() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
min := object.MinSize()
|
||||||
|
if min.Width > width {
|
||||||
|
width = min.Width
|
||||||
|
}
|
||||||
|
if min.Height > height {
|
||||||
|
height = min.Height
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fyne.NewSize(width, height)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (layout minWidthLayout) Layout(objects []fyne.CanvasObject, size fyne.Size) {
|
||||||
|
for _, object := range objects {
|
||||||
|
if !object.Visible() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
object.Move(fyne.NewPos(0, 0))
|
||||||
|
object.Resize(size)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func statusText(j job) string {
|
||||||
|
if !j.Enabled {
|
||||||
|
return "Paused"
|
||||||
|
}
|
||||||
|
return j.LastState
|
||||||
|
}
|
||||||
|
|
||||||
|
func newEvent(jobID int, jobName string, state string, detail string) event {
|
||||||
|
// Use the same timestamp shape as command run records so the History tab is
|
||||||
|
// visually consistent across startup, UI actions, manual runs, and schedules.
|
||||||
|
return event{
|
||||||
|
Time: time.Now().Format("2006-01-02 15:04:05"),
|
||||||
|
JobID: jobID,
|
||||||
|
JobName: jobName,
|
||||||
|
Trigger: "UI",
|
||||||
|
State: state,
|
||||||
|
Detail: detail,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func eventText(e event) string {
|
||||||
|
trigger := e.Trigger
|
||||||
|
if trigger == "" {
|
||||||
|
trigger = "Unknown"
|
||||||
|
}
|
||||||
|
if e.LogFile != "" {
|
||||||
|
return fmt.Sprintf("%s %s %s %s %s %s", e.Time, trigger, e.JobName, e.State, e.Detail, e.LogFile)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s %s %s %s %s", e.Time, trigger, e.JobName, e.State, e.Detail)
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectActivity(jobs []job) []event {
|
||||||
|
var events []event
|
||||||
|
for _, current := range jobs {
|
||||||
|
// At startup this is usually empty because jobs.yaml does not persist
|
||||||
|
// runtime logs. The function still centralizes the merge for future
|
||||||
|
// history loading from log metadata.
|
||||||
|
events = append(events, current.Logs...)
|
||||||
|
}
|
||||||
|
sort.SliceStable(events, func(left int, right int) bool {
|
||||||
|
return events[left].Time < events[right].Time
|
||||||
|
})
|
||||||
|
return events
|
||||||
|
}
|
||||||
|
|
||||||
|
func nextID(jobs []job) int {
|
||||||
|
next := 1
|
||||||
|
for _, current := range jobs {
|
||||||
|
if current.ID >= next {
|
||||||
|
next = current.ID + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
func detailRow(label string, value fyne.CanvasObject) fyne.CanvasObject {
|
||||||
|
caption := widget.NewLabelWithStyle(label, fyne.TextAlignLeading, fyne.TextStyle{Bold: true})
|
||||||
|
caption.Wrapping = fyne.TextTruncate
|
||||||
|
return container.NewGridWithColumns(2, caption, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func settingsRow(label string, value fyne.CanvasObject) fyne.CanvasObject {
|
||||||
|
caption := widget.NewLabelWithStyle(label, fyne.TextAlignLeading, fyne.TextStyle{Bold: true})
|
||||||
|
caption.Wrapping = fyne.TextTruncate
|
||||||
|
captionBox := container.New(minWidthLayout{width: settingsLabelWidth}, caption)
|
||||||
|
return container.NewBorder(nil, nil, captionBox, nil, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func settingsRowWithStatus(label string, value fyne.CanvasObject, status fyne.CanvasObject) fyne.CanvasObject {
|
||||||
|
valueBox := container.New(minWidthLayout{width: settingsControlWidth}, value)
|
||||||
|
statusBox := container.New(minWidthLayout{width: settingsStatusWidth}, status)
|
||||||
|
return settingsRow(label, container.NewBorder(nil, nil, valueBox, nil, statusBox))
|
||||||
|
}
|
||||||
|
|
||||||
|
func filteredJobIndexes(jobs []job, folder string) []int {
|
||||||
|
indexes := make([]int, 0, len(jobs))
|
||||||
|
for index, current := range jobs {
|
||||||
|
if folder == allFolders || filterValue(current.Folder) == folder {
|
||||||
|
indexes = append(indexes, index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return indexes
|
||||||
|
}
|
||||||
|
|
||||||
|
func folderOptions(jobs []job) []string {
|
||||||
|
// "All" and "No folder" are always present so the filter UI is stable even
|
||||||
|
// before the user creates folders.
|
||||||
|
options := []string{allFolders, noFolder}
|
||||||
|
seen := map[string]bool{allFolders: true, noFolder: true}
|
||||||
|
for _, current := range jobs {
|
||||||
|
folder := strings.TrimSpace(current.Folder)
|
||||||
|
if folder == "" || seen[folder] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[folder] = true
|
||||||
|
options = append(options, folder)
|
||||||
|
}
|
||||||
|
return options
|
||||||
|
}
|
||||||
|
|
||||||
|
func filterValue(folder string) string {
|
||||||
|
if strings.TrimSpace(folder) == "" {
|
||||||
|
return noFolder
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(folder)
|
||||||
|
}
|
||||||
|
|
||||||
|
func displayFolder(folder string) string {
|
||||||
|
if strings.TrimSpace(folder) == "" {
|
||||||
|
return "(" + noFolder + ")"
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(folder)
|
||||||
|
}
|
||||||
|
|
||||||
|
func displayIndex(indexes []int, jobIndex int) int {
|
||||||
|
for display, index := range indexes {
|
||||||
|
if index == jobIndex {
|
||||||
|
return display
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func showJobDialog(w fyne.Window, title string, current job, onSave func(job)) {
|
||||||
|
name := widget.NewEntry()
|
||||||
|
name.SetPlaceHolder("Nightly backup")
|
||||||
|
name.SetText(current.Name)
|
||||||
|
folder := widget.NewEntry()
|
||||||
|
folder.SetPlaceHolder("Maintenance")
|
||||||
|
folder.SetText(current.Folder)
|
||||||
|
schedule := widget.NewEntry()
|
||||||
|
schedule.SetPlaceHolder("@every 1m")
|
||||||
|
schedule.SetText(current.Schedule)
|
||||||
|
command := widget.NewEntry()
|
||||||
|
command.SetPlaceHolder("echo PySentry job ran")
|
||||||
|
command.SetText(current.Command)
|
||||||
|
enabled := widget.NewCheck("Enabled", nil)
|
||||||
|
enabled.SetChecked(current.Enabled)
|
||||||
|
|
||||||
|
form := dialog.NewForm(
|
||||||
|
title,
|
||||||
|
"Save",
|
||||||
|
"Cancel",
|
||||||
|
[]*widget.FormItem{
|
||||||
|
widget.NewFormItem("Name", name),
|
||||||
|
widget.NewFormItem("Folder", folder),
|
||||||
|
widget.NewFormItem("Schedule", schedule),
|
||||||
|
widget.NewFormItem("Command", command),
|
||||||
|
widget.NewFormItem("", enabled),
|
||||||
|
},
|
||||||
|
func(saved bool) {
|
||||||
|
if !saved {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(name.Text) == "" || strings.TrimSpace(schedule.Text) == "" || strings.TrimSpace(command.Text) == "" {
|
||||||
|
// These three fields are the minimum executable job definition.
|
||||||
|
// Folder is optional because ungrouped jobs are a supported workflow.
|
||||||
|
dialog.ShowError(fmt.Errorf("name, schedule, and command are required"), w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
current.Name = strings.TrimSpace(name.Text)
|
||||||
|
current.Folder = strings.TrimSpace(folder.Text)
|
||||||
|
current.Schedule = strings.TrimSpace(schedule.Text)
|
||||||
|
current.Command = strings.TrimSpace(command.Text)
|
||||||
|
current.Enabled = enabled.Checked
|
||||||
|
if current.LastRun == "" {
|
||||||
|
current.LastRun = "Never"
|
||||||
|
}
|
||||||
|
if current.Enabled {
|
||||||
|
current.NextRun = "Waiting for scheduler"
|
||||||
|
if current.LastState == "" || current.LastState == "Paused" {
|
||||||
|
current.LastState = "Ready"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
current.NextRun = "Paused"
|
||||||
|
current.LastState = "Paused"
|
||||||
|
}
|
||||||
|
onSave(current)
|
||||||
|
},
|
||||||
|
w,
|
||||||
|
)
|
||||||
|
form.Resize(fyne.NewSize(560, 280))
|
||||||
|
form.Show()
|
||||||
|
}
|
||||||
|
|
||||||
|
func newHistoryView(events *[]event) *fyne.Container {
|
||||||
|
descending := false
|
||||||
|
headerText := func(id widget.TableCellID) string {
|
||||||
|
headers := []string{"Time", "Trigger", "Job", "State", "Detail", "Log"}
|
||||||
|
if id.Row < 0 && id.Col == 0 {
|
||||||
|
if descending {
|
||||||
|
return "Time desc"
|
||||||
|
}
|
||||||
|
return "Time asc"
|
||||||
|
}
|
||||||
|
if id.Row < 0 && id.Col >= 0 && id.Col < len(headers) {
|
||||||
|
return headers[id.Col]
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
sortedEvents := func() []event {
|
||||||
|
result := append([]event(nil), (*events)...)
|
||||||
|
sort.SliceStable(result, func(left int, right int) bool {
|
||||||
|
if descending {
|
||||||
|
return result[left].Time > result[right].Time
|
||||||
|
}
|
||||||
|
return result[left].Time < result[right].Time
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
table := widget.NewTable(
|
||||||
|
func() (int, int) {
|
||||||
|
return len(*events), 6
|
||||||
|
},
|
||||||
|
func() fyne.CanvasObject {
|
||||||
|
label := widget.NewLabel("")
|
||||||
|
label.Wrapping = fyne.TextTruncate
|
||||||
|
return label
|
||||||
|
},
|
||||||
|
func(id widget.TableCellID, item fyne.CanvasObject) {
|
||||||
|
label := item.(*widget.Label)
|
||||||
|
label.SetText(historyCellText(id, sortedEvents()))
|
||||||
|
label.TextStyle = fyne.TextStyle{}
|
||||||
|
label.Refresh()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
table.ShowHeaderRow = true
|
||||||
|
table.CreateHeader = func() fyne.CanvasObject {
|
||||||
|
label := widget.NewLabel("")
|
||||||
|
label.Wrapping = fyne.TextTruncate
|
||||||
|
return label
|
||||||
|
}
|
||||||
|
table.UpdateHeader = func(id widget.TableCellID, item fyne.CanvasObject) {
|
||||||
|
label := item.(*widget.Label)
|
||||||
|
label.SetText(headerText(id))
|
||||||
|
label.TextStyle = fyne.TextStyle{Bold: true}
|
||||||
|
label.Refresh()
|
||||||
|
}
|
||||||
|
table.OnSelected = func(id widget.TableCellID) {
|
||||||
|
if id.Row < 0 && id.Col == 0 {
|
||||||
|
descending = !descending
|
||||||
|
table.Refresh()
|
||||||
|
}
|
||||||
|
table.Unselect(id)
|
||||||
|
}
|
||||||
|
table.SetColumnWidth(0, 150)
|
||||||
|
table.SetColumnWidth(1, 90)
|
||||||
|
table.SetColumnWidth(2, 170)
|
||||||
|
table.SetColumnWidth(3, 90)
|
||||||
|
table.SetColumnWidth(4, 260)
|
||||||
|
table.SetColumnWidth(5, 240)
|
||||||
|
return container.NewPadded(table)
|
||||||
|
}
|
||||||
|
|
||||||
|
func historyCellText(id widget.TableCellID, events []event) string {
|
||||||
|
if id.Row < 0 || id.Row >= len(events) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
current := events[id.Row]
|
||||||
|
trigger := current.Trigger
|
||||||
|
if trigger == "" {
|
||||||
|
trigger = "Unknown"
|
||||||
|
}
|
||||||
|
switch id.Col {
|
||||||
|
case 0:
|
||||||
|
return current.Time
|
||||||
|
case 1:
|
||||||
|
return trigger
|
||||||
|
case 2:
|
||||||
|
return current.JobName
|
||||||
|
case 3:
|
||||||
|
return current.State
|
||||||
|
case 4:
|
||||||
|
return current.Detail
|
||||||
|
case 5:
|
||||||
|
return logFileName(current.LogFile)
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func logFileName(path string) string {
|
||||||
|
path = strings.TrimSpace(path)
|
||||||
|
if path == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
path = strings.ReplaceAll(path, "\\", "/")
|
||||||
|
if slash := strings.LastIndex(path, "/"); slash >= 0 {
|
||||||
|
return path[slash+1:]
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
func settingsView(w fyne.Window, store *core.Store, jobs *[]job) fyne.CanvasObject {
|
||||||
|
startOnLogin := widget.NewCheck("Start on login", nil)
|
||||||
|
startOnLogin.SetChecked(store.Config.StartOnLogin)
|
||||||
|
autostartStatus := widget.NewLabel("")
|
||||||
|
refreshAutostartStatus := func() {
|
||||||
|
ok, message := core.AutostartStatus(store.Config.StartOnLogin, store.Paths.ExecutablePath)
|
||||||
|
if ok {
|
||||||
|
autostartStatus.SetText("OK: " + message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
autostartStatus.SetText("Problem: " + message)
|
||||||
|
}
|
||||||
|
startOnLogin.OnChanged = func(bool) {
|
||||||
|
if startOnLogin.Checked != store.Config.StartOnLogin {
|
||||||
|
autostartStatus.SetText("Pending: save settings to apply")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
refreshAutostartStatus()
|
||||||
|
}
|
||||||
|
refreshAutostartStatus()
|
||||||
|
minimizeToTray := widget.NewCheck("Keep running in the system tray", nil)
|
||||||
|
minimizeToTray.SetChecked(store.Config.KeepRunningInTray)
|
||||||
|
notifications := widget.NewCheck("Show desktop notifications for failed jobs", nil)
|
||||||
|
notifications.SetChecked(store.Config.NotifyOnFailure)
|
||||||
|
jobsDir := widget.NewEntry()
|
||||||
|
jobsDir.SetText(store.Config.JobsDir)
|
||||||
|
jobsDirBrowse := widget.NewButtonWithIcon("Browse", theme.FolderOpenIcon(), func() {
|
||||||
|
chooseFolder(w, jobsDir)
|
||||||
|
})
|
||||||
|
logsDir := widget.NewEntry()
|
||||||
|
logsDir.SetText(store.Config.LogsDir)
|
||||||
|
logsDirBrowse := widget.NewButtonWithIcon("Browse", theme.FolderOpenIcon(), func() {
|
||||||
|
chooseFolder(w, logsDir)
|
||||||
|
})
|
||||||
|
maxLogFiles := widget.NewEntry()
|
||||||
|
maxLogFiles.SetText(strconv.Itoa(store.Config.MaxLogFiles))
|
||||||
|
maxLogAgeDays := widget.NewEntry()
|
||||||
|
maxLogAgeDays.SetText(strconv.Itoa(store.Config.MaxLogAgeDays))
|
||||||
|
settingsStatus := widget.NewLabel("")
|
||||||
|
|
||||||
|
saveSettings := widget.NewButtonWithIcon("Save settings", theme.DocumentSaveIcon(), func() {
|
||||||
|
files, err := strconv.Atoi(strings.TrimSpace(maxLogFiles.Text))
|
||||||
|
if err != nil || files <= 0 {
|
||||||
|
settingsStatus.SetText("Max log files must be a positive number")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
days, err := strconv.Atoi(strings.TrimSpace(maxLogAgeDays.Text))
|
||||||
|
if err != nil || days <= 0 {
|
||||||
|
settingsStatus.SetText("Max log age days must be a positive number")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
store.Config.LogsDir = strings.TrimSpace(logsDir.Text)
|
||||||
|
if strings.TrimSpace(jobsDir.Text) == "" {
|
||||||
|
settingsStatus.SetText("Jobs directory is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(logsDir.Text) == "" {
|
||||||
|
settingsStatus.SetText("Logs directory is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
store.Config.JobsDir = strings.TrimSpace(jobsDir.Text)
|
||||||
|
store.Config.MaxLogFiles = files
|
||||||
|
store.Config.MaxLogAgeDays = days
|
||||||
|
store.Config.StartOnLogin = startOnLogin.Checked
|
||||||
|
store.Config.KeepRunningInTray = minimizeToTray.Checked
|
||||||
|
store.Config.NotifyOnFailure = notifications.Checked
|
||||||
|
if err := store.SaveConfig(); err != nil {
|
||||||
|
settingsStatus.SetText("Save failed: " + err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := core.SetAutostart(store.Config.StartOnLogin, store.Paths.ExecutablePath, store.Paths.DesktopIcon); err != nil {
|
||||||
|
refreshAutostartStatus()
|
||||||
|
settingsStatus.SetText("Saved, autostart failed: " + err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
refreshAutostartStatus()
|
||||||
|
// When the jobs directory changes, save the currently loaded jobs to the
|
||||||
|
// newly resolved path immediately. That makes the setting visible on disk
|
||||||
|
// without requiring a restart or a separate migration command.
|
||||||
|
if err := store.SaveJobs(*jobs); err != nil {
|
||||||
|
settingsStatus.SetText("Jobs save failed: " + err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Cleanup runs on settings save so a user who tightens retention limits
|
||||||
|
// sees the new policy take effect right away.
|
||||||
|
if err := core.CleanupLogs(store.Paths.LogsDir, store.Config.MaxLogFiles, store.Config.MaxLogAgeDays); err != nil {
|
||||||
|
settingsStatus.SetText("Saved, cleanup failed: " + err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
settingsStatus.SetText("Saved")
|
||||||
|
})
|
||||||
|
|
||||||
|
return container.NewPadded(container.NewVBox(
|
||||||
|
widget.NewLabelWithStyle("Application", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
|
||||||
|
settingsRowWithStatus("Autostart", startOnLogin, autostartStatus),
|
||||||
|
settingsRow("Tray", container.New(minWidthLayout{width: settingsControlWidth}, minimizeToTray)),
|
||||||
|
settingsRow("Notifications", container.New(minWidthLayout{width: settingsControlWidth}, notifications)),
|
||||||
|
widget.NewSeparator(),
|
||||||
|
widget.NewLabelWithStyle("Storage", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
|
||||||
|
settingsRow("Config YAML", widget.NewLabel(store.Paths.ConfigPath)),
|
||||||
|
settingsRow("Jobs directory", container.NewBorder(nil, nil, nil, jobsDirBrowse, jobsDir)),
|
||||||
|
settingsRow("Logs directory", container.NewBorder(nil, nil, nil, logsDirBrowse, logsDir)),
|
||||||
|
settingsRow("Max log files", maxLogFiles),
|
||||||
|
settingsRow("Max log age days", maxLogAgeDays),
|
||||||
|
saveSettings,
|
||||||
|
settingsStatus,
|
||||||
|
widget.NewSeparator(),
|
||||||
|
widget.NewLabelWithStyle("About", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
|
||||||
|
settingsRow("GoSentry", widget.NewLabel(core.Version)),
|
||||||
|
settingsRow("Go", widget.NewLabel(runtime.Version())),
|
||||||
|
settingsRow("Fyne", widget.NewLabel(fyneVersion())),
|
||||||
|
settingsRow("Repository", widget.NewHyperlink(projectRepositoryURL, mustParseURL(projectRepositoryURL))),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
func fyneVersion() string {
|
||||||
|
info, ok := debug.ReadBuildInfo()
|
||||||
|
if !ok {
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
for _, dependency := range info.Deps {
|
||||||
|
if dependency.Path == "fyne.io/fyne/v2" {
|
||||||
|
if dependency.Replace != nil && dependency.Replace.Version != "" {
|
||||||
|
return dependency.Replace.Version
|
||||||
|
}
|
||||||
|
if dependency.Version != "" {
|
||||||
|
return dependency.Version
|
||||||
|
}
|
||||||
|
return "local"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustParseURL(raw string) *url.URL {
|
||||||
|
parsed, err := url.Parse(raw)
|
||||||
|
if err != nil {
|
||||||
|
return &url.URL{}
|
||||||
|
}
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
func chooseFolder(w fyne.Window, target *widget.Entry) {
|
||||||
|
folderDialog := dialog.NewFolderOpen(func(uri fyne.ListableURI, err error) {
|
||||||
|
if err != nil || uri == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
target.SetText(uri.Path())
|
||||||
|
}, w)
|
||||||
|
// The default folder picker can be cramped on Windows. A larger size makes
|
||||||
|
// long paths readable and avoids forcing the user to resize it every time.
|
||||||
|
folderDialog.Resize(fyne.NewSize(900, 640))
|
||||||
|
folderDialog.Show()
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user