Add autostart status and release builds
This commit is contained in:
+27
-10
@@ -4,11 +4,19 @@ FROM golang:1.22-bookworm
|
|||||||
# compiler plus OpenGL/X11 headers. --no-install-recommends keeps the image from
|
# compiler plus OpenGL/X11 headers. --no-install-recommends keeps the image from
|
||||||
# pulling in unrelated desktop packages that are not needed for compilation.
|
# pulling in unrelated desktop packages that are not needed for compilation.
|
||||||
RUN apt-get update && \
|
RUN apt-get update && \
|
||||||
|
dpkg --add-architecture arm64 && \
|
||||||
|
apt-get update && \
|
||||||
apt-get install -y --no-install-recommends \
|
apt-get install -y --no-install-recommends \
|
||||||
ca-certificates \
|
ca-certificates \
|
||||||
gcc \
|
gcc \
|
||||||
|
gcc-aarch64-linux-gnu \
|
||||||
|
gcc-mingw-w64-x86-64 \
|
||||||
|
binutils-mingw-w64-x86-64 \
|
||||||
|
pkg-config \
|
||||||
libgl1-mesa-dev \
|
libgl1-mesa-dev \
|
||||||
xorg-dev && \
|
xorg-dev \
|
||||||
|
libgl1-mesa-dev:arm64 \
|
||||||
|
xorg-dev:arm64 && \
|
||||||
rm -rf /var/lib/apt/lists/*
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
@@ -20,12 +28,21 @@ RUN go mod download
|
|||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# CGO is required by Fyne. The first Linux package target is linux/amd64; other
|
# CGO is required by Fyne. This builder produces the release artifacts from
|
||||||
# architectures can be added later as separate, explicit build targets.
|
# Linux: Linux amd64, Linux arm64, and a Windows amd64 binary cross-compiled with
|
||||||
ENV CGO_ENABLED=1
|
# MinGW. The Windows resource is generated inside the container so Explorer still
|
||||||
ENV GOOS=linux
|
# sees the application icon.
|
||||||
ENV GOARCH=amd64
|
RUN version="$(sed -n 's/^var Version = "\(.*\)"/\1/p' src/core/version.go)" && \
|
||||||
|
version="${version:-0.0.0-dev}" && \
|
||||||
# -trimpath removes host paths from the binary, and -s -w strips symbol/debug
|
mkdir -p /out/linux /out/windows && \
|
||||||
# tables to keep the produced desktop executable smaller.
|
CGO_ENABLED=1 GOOS=linux GOARCH=amd64 \
|
||||||
RUN go build -trimpath -ldflags "-s -w" -o /out/pysentry ./cmd/pysentry
|
go build -trimpath -ldflags "-s -w -X github.com/pysentry/pysentry/src/core.Version=${version}" \
|
||||||
|
-o "/out/linux/pysentry-${version}-linux-amd64" ./cmd/pysentry && \
|
||||||
|
CC=aarch64-linux-gnu-gcc CGO_ENABLED=1 GOOS=linux GOARCH=arm64 \
|
||||||
|
PKG_CONFIG_PATH=/usr/lib/aarch64-linux-gnu/pkgconfig \
|
||||||
|
go build -trimpath -ldflags "-s -w -X github.com/pysentry/pysentry/src/core.Version=${version}" \
|
||||||
|
-o "/out/linux/pysentry-${version}-linux-arm64" ./cmd/pysentry && \
|
||||||
|
x86_64-w64-mingw32-windres -O coff -o cmd/pysentry/rsrc_windows_amd64.syso packaging/windows/pysentry.rc && \
|
||||||
|
CC=x86_64-w64-mingw32-gcc CGO_ENABLED=1 GOOS=windows GOARCH=amd64 \
|
||||||
|
go build -trimpath -ldflags "-s -w -H=windowsgui -X github.com/pysentry/pysentry/src/core.Version=${version}" \
|
||||||
|
-o "/out/windows/pysentry-${version}-windows-amd64.exe" ./cmd/pysentry
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ PySentry is being designed and implemented with assistance from OpenAI Codex.
|
|||||||
- Log cleanup by maximum file count and maximum age.
|
- Log cleanup by maximum file count and maximum age.
|
||||||
- Global pause/resume for all job execution.
|
- Global pause/resume for all job execution.
|
||||||
- Windows tray support.
|
- Windows tray support.
|
||||||
|
- Version shown in the window title, Settings, and build artifact names.
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
@@ -44,9 +45,11 @@ sudo apt install golang gcc libgl1-mesa-dev xorg-dev
|
|||||||
Windows:
|
Windows:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
# Builds dist\windows\pysentry.exe. The script adds MSYS2 UCRT64 to PATH for
|
# Builds dist\windows\pysentry-<version>-windows-amd64.exe. The script changes
|
||||||
# this process only, embeds the Windows icon when windres is available, and uses
|
# to the repository root first, so double-clicking it from Explorer works. It
|
||||||
# the Windows GUI subsystem so no console window opens at startup.
|
# 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
|
.\scripts\build-windows.bat
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -56,7 +59,7 @@ The binary is written to:
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
# GUI executable produced by scripts\build-windows.bat.
|
# GUI executable produced by scripts\build-windows.bat.
|
||||||
dist\windows\pysentry.exe
|
dist\windows\pysentry-0.1.0-windows-amd64.exe
|
||||||
```
|
```
|
||||||
|
|
||||||
Linux:
|
Linux:
|
||||||
@@ -71,14 +74,15 @@ The binary is written to:
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
# Linux executable produced by scripts/build-linux.sh.
|
# Linux executable produced by scripts/build-linux.sh.
|
||||||
dist/linux/pysentry
|
dist/linux/pysentry-0.1.0-linux-amd64
|
||||||
```
|
```
|
||||||
|
|
||||||
Linux using Docker:
|
Linux using Docker:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Builds the same Linux binary inside Docker, useful from Windows hosts or CI
|
# Builds the Linux binary inside Docker using the image tag
|
||||||
# where the native Linux/Fyne packages are not installed locally.
|
# gitea.mixdep.ru/mix/pysentry-builder. Useful from hosts or CI jobs where the
|
||||||
|
# native Linux/Fyne packages are not installed locally.
|
||||||
chmod +x ./scripts/build-linux-docker.sh
|
chmod +x ./scripts/build-linux-docker.sh
|
||||||
./scripts/build-linux-docker.sh
|
./scripts/build-linux-docker.sh
|
||||||
```
|
```
|
||||||
@@ -87,7 +91,30 @@ The binary is copied to:
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
# Linux executable copied out of the Docker build image.
|
# Linux executable copied out of the Docker build image.
|
||||||
dist\linux\pysentry
|
dist\linux\pysentry-0.1.0-linux-amd64
|
||||||
|
```
|
||||||
|
|
||||||
|
Release build from Linux:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Builds Linux amd64, Linux arm64, and Windows amd64 artifacts from one
|
||||||
|
# Linux/Docker workflow. The Dockerfile includes Linux Fyne dependencies plus
|
||||||
|
# cross-compilers for arm64 Linux and the Windows .exe.
|
||||||
|
chmod +x ./scripts/build-release-linux.sh
|
||||||
|
./scripts/build-release-linux.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
The binaries are copied to:
|
||||||
|
|
||||||
|
```text
|
||||||
|
# Linux artifact.
|
||||||
|
dist/linux/pysentry-0.1.0-linux-amd64
|
||||||
|
|
||||||
|
# Linux arm64 artifact.
|
||||||
|
dist/linux/pysentry-0.1.0-linux-arm64
|
||||||
|
|
||||||
|
# Windows artifact cross-compiled from Linux.
|
||||||
|
dist/windows/pysentry-0.1.0-windows-amd64.exe
|
||||||
```
|
```
|
||||||
|
|
||||||
## Run From Source
|
## Run From Source
|
||||||
@@ -120,8 +147,8 @@ PySentry creates its runtime files next to the executable by default.
|
|||||||
`pysentry.yaml` stores application settings:
|
`pysentry.yaml` stores application settings:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
# Directory containing jobs.yaml. "." means "the folder where pysentry.exe lives";
|
# Directory containing jobs.yaml. "." means "the folder where the PySentry
|
||||||
# an absolute path can be used when jobs should live elsewhere.
|
# executable lives"; an absolute path can be used when jobs should live elsewhere.
|
||||||
jobs_dir: .
|
jobs_dir: .
|
||||||
|
|
||||||
# Directory for per-run command output logs. Relative paths are resolved against
|
# Directory for per-run command output logs. Relative paths are resolved against
|
||||||
@@ -134,6 +161,9 @@ max_log_files: 100
|
|||||||
# Delete .log files older than this many days during cleanup.
|
# Delete .log files older than this many days during cleanup.
|
||||||
max_log_age_days: 30
|
max_log_age_days: 30
|
||||||
|
|
||||||
|
# Start PySentry automatically when the current desktop user signs in.
|
||||||
|
start_on_login: false
|
||||||
|
|
||||||
# Closing the window hides it to the tray instead of stopping the scheduler.
|
# Closing the window hides it to the tray instead of stopping the scheduler.
|
||||||
keep_running_in_tray: true
|
keep_running_in_tray: true
|
||||||
|
|
||||||
@@ -159,7 +189,7 @@ jobs:
|
|||||||
folder: Examples
|
folder: Examples
|
||||||
|
|
||||||
# Either @every with a Go duration, or a standard five-field cron expression.
|
# Either @every with a Go duration, or a standard five-field cron expression.
|
||||||
schedule: '@every 10s'
|
schedule: '@every 1m'
|
||||||
|
|
||||||
# Command passed to the platform shell: cmd.exe /C on Windows, sh -c on Linux.
|
# Command passed to the platform shell: cmd.exe /C on Windows, sh -c on Linux.
|
||||||
command: echo PySentry test job: scheduler is alive
|
command: echo PySentry test job: scheduler is alive
|
||||||
@@ -208,6 +238,41 @@ Standard 5-field cron schedules:
|
|||||||
|
|
||||||
Changing `jobs_dir` saves the current job list to the new directory.
|
Changing `jobs_dir` saves the current job list to the new directory.
|
||||||
|
|
||||||
|
The `Start on login` setting shows an `OK` or `Problem` status next to the checkbox. Saving settings with the checkbox enabled rewrites the autostart entry using the current executable path.
|
||||||
|
|
||||||
|
## Autostart
|
||||||
|
|
||||||
|
PySentry is a user desktop application, not a system daemon, so autostart should be configured per user.
|
||||||
|
|
||||||
|
Linux:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
# PySentry writes a systemd user unit and enables it with
|
||||||
|
# systemctl --user enable --now pysentry.service when Start on login is enabled.
|
||||||
|
# A user unit starts after login and can run the tray/GUI app in the user's
|
||||||
|
# desktop session.
|
||||||
|
[Unit]
|
||||||
|
Description=PySentry desktop scheduler
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
ExecStart=/opt/pysentry/pysentry-0.1.0-linux-amd64
|
||||||
|
Restart=on-failure
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=default.target
|
||||||
|
```
|
||||||
|
|
||||||
|
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
|
## Project Layout
|
||||||
|
|
||||||
- `cmd/pysentry` starts the desktop app.
|
- `cmd/pysentry` starts the desktop app.
|
||||||
|
|||||||
@@ -2,19 +2,27 @@
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
# Optional first argument mirrors build-linux.sh. The Docker build still writes
|
# 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 final artifact into the local dist/ tree, not into the container. The
|
||||||
output="${1:-dist/linux/pysentry}"
|
# 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}"
|
||||||
|
|
||||||
# Dockerfile contains the native packages required by Fyne. Keeping that
|
# Dockerfile contains the native packages required by Fyne. Keeping that
|
||||||
# environment in Docker makes Linux builds repeatable from Windows hosts and CI.
|
# environment in Docker makes Linux builds repeatable from Windows hosts and CI.
|
||||||
docker build -f Dockerfile -t pysentry-linux-builder .
|
docker build -f Dockerfile -t gitea.mixdep.ru/mix/pysentry-builder .
|
||||||
|
|
||||||
|
# The image build produces /out/linux and /out/windows. This helper copies only
|
||||||
|
# the Linux binary for compatibility with the older Linux-only workflow; use
|
||||||
|
# build-release-linux.sh when both platform artifacts are needed.
|
||||||
|
container_id="$(docker create gitea.mixdep.ru/mix/pysentry-builder)"
|
||||||
|
cleanup() {
|
||||||
|
docker rm "$container_id" >/dev/null
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
# The image build produces /out/pysentry. A temporary container is used only as a
|
|
||||||
# convenient way to copy that file out; the app is not run inside the container.
|
|
||||||
container_id="$(docker create pysentry-linux-builder)"
|
|
||||||
mkdir -p "$(dirname "$output")"
|
mkdir -p "$(dirname "$output")"
|
||||||
docker cp "${container_id}:/out/pysentry" "$output"
|
docker cp "${container_id}:/out/linux/pysentry-${version}-linux-amd64" "$output"
|
||||||
docker rm "$container_id" >/dev/null
|
|
||||||
|
|
||||||
# Icons are embedded in the Go binary, so there is no assets directory to copy
|
# Icons are embedded in the Go binary, so there is no assets directory to copy
|
||||||
# after extracting the Linux executable.
|
# after extracting the Linux executable.
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
# Optional first argument lets a developer or CI job choose the output path.
|
# Optional first argument lets a developer or CI job choose the output path. The
|
||||||
# dist/linux/pysentry is the default so generated binaries stay outside src/.
|
# default includes the application version and target platform.
|
||||||
output="${1:-dist/linux/pysentry}"
|
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")"
|
mkdir -p "$(dirname "$output")"
|
||||||
|
|
||||||
# Fyne needs CGO for its native desktop backend. The script pins the target to
|
# Fyne needs CGO for its native desktop backend. The script pins the target to
|
||||||
@@ -15,7 +17,7 @@ export GOARCH=amd64
|
|||||||
|
|
||||||
# -trimpath removes local machine paths from debug/build metadata. -s -w strips
|
# -trimpath removes local machine paths from debug/build metadata. -s -w strips
|
||||||
# symbol/debug tables to keep the desktop binary smaller.
|
# symbol/debug tables to keep the desktop binary smaller.
|
||||||
go build -trimpath -ldflags "-s -w" -o "$output" ./cmd/pysentry
|
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
|
# The application icon is embedded by Go, so the Linux build does not need a
|
||||||
# sidecar assets directory beside the executable.
|
# sidecar assets directory beside the executable.
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Build all 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.
|
||||||
|
tag="gitea.mixdep.ru/mix/pysentry-builder"
|
||||||
|
|
||||||
|
docker build -f Dockerfile -t "$tag" .
|
||||||
|
|
||||||
|
container_id="$(docker create "$tag")"
|
||||||
|
cleanup() {
|
||||||
|
docker rm "$container_id" >/dev/null
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
mkdir -p dist/linux dist/windows
|
||||||
|
docker cp "${container_id}:/out/linux/." dist/linux
|
||||||
|
docker cp "${container_id}:/out/windows/." dist/windows
|
||||||
|
|
||||||
|
echo "Built release artifacts:"
|
||||||
|
find dist/linux dist/windows -maxdepth 1 -type f -print
|
||||||
@@ -1,11 +1,20 @@
|
|||||||
@echo off
|
@echo off
|
||||||
setlocal enabledelayedexpansion
|
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 Optional first argument allows CI or a developer to choose another output
|
||||||
REM path. The default keeps all generated binaries under dist\ so the source tree
|
REM path. The default keeps all generated binaries under dist\ so the source tree
|
||||||
REM stays clean and the old bin\ folder is no longer needed.
|
REM stays clean and the old bin\ folder is no longer needed.
|
||||||
set "OUTPUT=%~1"
|
set "OUTPUT=%~1"
|
||||||
if "%OUTPUT%"=="" set "OUTPUT=dist\windows\pysentry.exe"
|
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 Prefer the standard Go installer path on Windows, but fall back to PATH for
|
||||||
REM machines where Go was installed by another package manager.
|
REM machines where Go was installed by another package manager.
|
||||||
@@ -38,7 +47,7 @@ if %ERRORLEVEL%==0 (
|
|||||||
REM -trimpath removes local machine paths from the binary, -s -w reduce binary
|
REM -trimpath removes local machine paths from the binary, -s -w reduce binary
|
||||||
REM size, and -H=windowsgui prevents a separate console window from opening when
|
REM size, and -H=windowsgui prevents a separate console window from opening when
|
||||||
REM the GUI app starts from Explorer or a shortcut.
|
REM the GUI app starts from Explorer or a shortcut.
|
||||||
"%GOEXE%" build -trimpath -ldflags "-s -w -H=windowsgui" -o "%OUTPUT%" .\cmd\pysentry
|
"%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
|
if errorlevel 1 exit /b 1
|
||||||
|
|
||||||
REM Icons are embedded into the executable, so no assets directory is copied next
|
REM Icons are embedded into the executable, so no assets directory is copied next
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
//go:build linux
|
||||||
|
|
||||||
|
package core
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
const autostartUnitName = "pysentry.service"
|
||||||
|
|
||||||
|
func SetAutostart(enabled bool, executablePath string) error {
|
||||||
|
unitDir, err := userSystemdDir()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
unitPath := filepath.Join(unitDir, autostartUnitName)
|
||||||
|
|
||||||
|
if enabled {
|
||||||
|
if err := os.MkdirAll(unitDir, 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
unit := fmt.Sprintf(`[Unit]
|
||||||
|
Description=PySentry desktop scheduler
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
ExecStart=%s
|
||||||
|
Restart=on-failure
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=default.target
|
||||||
|
`, executablePath)
|
||||||
|
if err := os.WriteFile(unitPath, []byte(unit), 0o644); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := exec.Command("systemctl", "--user", "daemon-reload").Run(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return exec.Command("systemctl", "--user", "enable", "--now", autostartUnitName).Run()
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = exec.Command("systemctl", "--user", "disable", "--now", autostartUnitName).Run()
|
||||||
|
if err := os.Remove(unitPath); err != nil && !os.IsNotExist(err) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return exec.Command("systemctl", "--user", "daemon-reload").Run()
|
||||||
|
}
|
||||||
|
|
||||||
|
func AutostartStatus(expectedEnabled bool, executablePath string) (bool, string) {
|
||||||
|
unitDir, err := userSystemdDir()
|
||||||
|
if err != nil {
|
||||||
|
return false, "Cannot resolve user systemd directory"
|
||||||
|
}
|
||||||
|
unitPath := filepath.Join(unitDir, autostartUnitName)
|
||||||
|
data, readErr := os.ReadFile(unitPath)
|
||||||
|
enabledErr := exec.Command("systemctl", "--user", "is-enabled", "--quiet", autostartUnitName).Run()
|
||||||
|
|
||||||
|
if !expectedEnabled {
|
||||||
|
if os.IsNotExist(readErr) && enabledErr != nil {
|
||||||
|
return true, "Autostart is off"
|
||||||
|
}
|
||||||
|
return false, "Autostart unit exists while setting is off"
|
||||||
|
}
|
||||||
|
if readErr != nil {
|
||||||
|
return false, "Autostart unit is missing"
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(data), executablePath) {
|
||||||
|
return false, "Autostart unit points to another executable"
|
||||||
|
}
|
||||||
|
if enabledErr != nil {
|
||||||
|
return false, "Autostart unit is not enabled"
|
||||||
|
}
|
||||||
|
return true, "Autostart is configured"
|
||||||
|
}
|
||||||
|
|
||||||
|
func userSystemdDir() (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"), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
//go:build !windows && !linux
|
||||||
|
|
||||||
|
package core
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
func SetAutostart(enabled bool, executablePath 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,49 @@
|
|||||||
|
package core
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
const autostartName = "PySentry"
|
||||||
|
|
||||||
|
func SetAutostart(enabled bool, executablePath 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", fmt.Sprintf("%q", executablePath), "/f")
|
||||||
|
configureHiddenWindow(command)
|
||||||
|
return command.Run()
|
||||||
|
}
|
||||||
|
command := exec.Command("reg.exe", "delete", `HKCU\Software\Microsoft\Windows\CurrentVersion\Run`, "/v", autostartName, "/f")
|
||||||
|
configureHiddenWindow(command)
|
||||||
|
_ = 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"
|
||||||
|
}
|
||||||
|
|
||||||
|
text := strings.ReplaceAll(string(output), `"`, "")
|
||||||
|
if !strings.Contains(text, executablePath) {
|
||||||
|
return false, "Autostart points to another executable"
|
||||||
|
}
|
||||||
|
return true, "Autostart is configured"
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ type Config struct {
|
|||||||
LogsDir string `yaml:"logs_dir"`
|
LogsDir string `yaml:"logs_dir"`
|
||||||
MaxLogFiles int `yaml:"max_log_files"`
|
MaxLogFiles int `yaml:"max_log_files"`
|
||||||
MaxLogAgeDays int `yaml:"max_log_age_days"`
|
MaxLogAgeDays int `yaml:"max_log_age_days"`
|
||||||
|
StartOnLogin bool `yaml:"start_on_login"`
|
||||||
KeepRunningInTray bool `yaml:"keep_running_in_tray"`
|
KeepRunningInTray bool `yaml:"keep_running_in_tray"`
|
||||||
NotifyOnFailure bool `yaml:"notify_on_failure"`
|
NotifyOnFailure bool `yaml:"notify_on_failure"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ const (
|
|||||||
// storage locations. Keeping resolved paths in one struct prevents the GUI and
|
// storage locations. Keeping resolved paths in one struct prevents the GUI and
|
||||||
// scheduler from interpreting relative directories differently.
|
// scheduler from interpreting relative directories differently.
|
||||||
type Paths struct {
|
type Paths struct {
|
||||||
|
ExecutablePath string
|
||||||
AppDir string
|
AppDir string
|
||||||
ConfigPath string
|
ConfigPath string
|
||||||
JobsDir string
|
JobsDir string
|
||||||
@@ -39,6 +40,7 @@ func ResolvePaths() (Paths, error) {
|
|||||||
appDir := filepath.Dir(executable)
|
appDir := filepath.Dir(executable)
|
||||||
configPath := filepath.Join(appDir, ConfigFileName)
|
configPath := filepath.Join(appDir, ConfigFileName)
|
||||||
return Paths{
|
return Paths{
|
||||||
|
ExecutablePath: executable,
|
||||||
AppDir: appDir,
|
AppDir: appDir,
|
||||||
ConfigPath: configPath,
|
ConfigPath: configPath,
|
||||||
JobsDir: appDir,
|
JobsDir: appDir,
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ func RunJob(ctx context.Context, job *Job, trigger string, logsDir string) RunRe
|
|||||||
// than argv-based execution, but it is the expected behavior for a cron-like
|
// than argv-based execution, but it is the expected behavior for a cron-like
|
||||||
// tool that supports redirection, environment expansion, and shell builtins.
|
// tool that supports redirection, environment expansion, and shell builtins.
|
||||||
command := shellCommand(runCtx, job.Command)
|
command := shellCommand(runCtx, job.Command)
|
||||||
|
configureHiddenWindow(command)
|
||||||
var stdout bytes.Buffer
|
var stdout bytes.Buffer
|
||||||
var stderr bytes.Buffer
|
var stderr bytes.Buffer
|
||||||
command.Stdout = &stdout
|
command.Stdout = &stdout
|
||||||
|
|||||||
@@ -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,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,
|
||||||
|
}
|
||||||
|
}
|
||||||
+46
-15
@@ -85,22 +85,17 @@ func (s *Scheduler) SetPaused(paused bool) {
|
|||||||
_ = s.store.SaveJobs(*s.jobs)
|
_ = s.store.SaveJobs(*s.jobs)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Scheduler) RunNow(index int) RunRecord {
|
func (s *Scheduler) RunNow(index int) bool {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
if index < 0 || index >= len(*s.jobs) {
|
if index < 0 || index >= len(*s.jobs) {
|
||||||
return RunRecord{}
|
return false
|
||||||
}
|
}
|
||||||
job := &(*s.jobs)[index]
|
|
||||||
// Manual runs share the same runner and log writer as scheduled runs. The
|
// Manual runs share the same runner and log writer as scheduled runs. The
|
||||||
// Trigger field is the only difference, which keeps History comparable and
|
// Trigger field is the only difference, which keeps History comparable and
|
||||||
// prevents "Run now" from becoming a separate behavior path.
|
// prevents "Run now" from becoming a separate behavior path.
|
||||||
record := RunJob(s.ctx, job, "Manual", s.store.Paths.LogsDir)
|
return s.startRunLocked(index, "Manual")
|
||||||
s.prepareNextRun(job, time.Now())
|
|
||||||
_ = CleanupLogs(s.store.Paths.LogsDir, s.store.Config.MaxLogFiles, s.store.Config.MaxLogAgeDays)
|
|
||||||
_ = s.store.SaveJobs(*s.jobs)
|
|
||||||
return record
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Scheduler) RefreshSchedule(index int) {
|
func (s *Scheduler) RefreshSchedule(index int) {
|
||||||
@@ -123,7 +118,6 @@ func (s *Scheduler) RefreshSchedule(index int) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Scheduler) tick(now time.Time) {
|
func (s *Scheduler) tick(now time.Time) {
|
||||||
var record RunRecord
|
|
||||||
var changed bool
|
var changed bool
|
||||||
|
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
@@ -137,21 +131,58 @@ func (s *Scheduler) tick(now time.Time) {
|
|||||||
// commands in the GUI process and keeps the first version predictable;
|
// commands in the GUI process and keeps the first version predictable;
|
||||||
// a future worker pool can add concurrency once cancellation and status
|
// a future worker pool can add concurrency once cancellation and status
|
||||||
// reporting are more explicit.
|
// reporting are more explicit.
|
||||||
record = RunJob(s.ctx, job, "Schedule", s.store.Paths.LogsDir)
|
changed = s.startRunLocked(index, "Schedule")
|
||||||
s.prepareNextRun(job, time.Now())
|
|
||||||
_ = CleanupLogs(s.store.Paths.LogsDir, s.store.Config.MaxLogFiles, s.store.Config.MaxLogAgeDays)
|
|
||||||
changed = true
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if changed {
|
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.store.SaveJobs(*s.jobs)
|
||||||
}
|
}
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
|
|
||||||
if changed && s.onChange != nil {
|
if s.onChange != nil {
|
||||||
s.onChange(record)
|
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) {
|
func (s *Scheduler) resetNextRuns(now time.Time) {
|
||||||
|
|||||||
+2
-1
@@ -72,6 +72,7 @@ func loadOrCreateConfig(paths Paths) (Config, error) {
|
|||||||
LogsDir: "logs",
|
LogsDir: "logs",
|
||||||
MaxLogFiles: 100,
|
MaxLogFiles: 100,
|
||||||
MaxLogAgeDays: 30,
|
MaxLogAgeDays: 30,
|
||||||
|
StartOnLogin: false,
|
||||||
KeepRunningInTray: true,
|
KeepRunningInTray: true,
|
||||||
NotifyOnFailure: true,
|
NotifyOnFailure: true,
|
||||||
}
|
}
|
||||||
@@ -207,7 +208,7 @@ func defaultJobs() []Job {
|
|||||||
ID: 1,
|
ID: 1,
|
||||||
Name: "Hello scheduler",
|
Name: "Hello scheduler",
|
||||||
Folder: "Examples",
|
Folder: "Examples",
|
||||||
Schedule: "@every 10s",
|
Schedule: "@every 1m",
|
||||||
Command: echoCommand("PySentry test job: scheduler is alive"),
|
Command: echoCommand("PySentry test job: scheduler is alive"),
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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.1.0"
|
||||||
+31
-9
@@ -35,7 +35,7 @@ func Run() {
|
|||||||
a := app.NewWithID(appID)
|
a := app.NewWithID(appID)
|
||||||
a.SetIcon(loadAppIcon())
|
a.SetIcon(loadAppIcon())
|
||||||
|
|
||||||
w := a.NewWindow("PySentry")
|
w := a.NewWindow("PySentry " + core.Version)
|
||||||
configureSystemTray(a, w)
|
configureSystemTray(a, w)
|
||||||
w.Resize(fyne.NewSize(1120, 720))
|
w.Resize(fyne.NewSize(1120, 720))
|
||||||
w.SetContent(newMainView(w))
|
w.SetContent(newMainView(w))
|
||||||
@@ -104,16 +104,14 @@ func newMainView(w fyne.Window) fyne.CanvasObject {
|
|||||||
// against the theme when it is placed inside a scroll container.
|
// against the theme when it is placed inside a scroll container.
|
||||||
commandOutputScroll.SetMinSize(fyne.NewSize(520, 160))
|
commandOutputScroll.SetMinSize(fyne.NewSize(520, 160))
|
||||||
history := newHistoryView(&events)
|
history := newHistoryView(&events)
|
||||||
|
selectedLogs := append([]event(nil), jobs[selected].Logs...)
|
||||||
jobLogs := widget.NewList(
|
jobLogs := widget.NewList(
|
||||||
func() int {
|
func() int {
|
||||||
if selected < 0 || selected >= len(jobs) {
|
return len(selectedLogs)
|
||||||
return 0
|
|
||||||
}
|
|
||||||
return len(jobs[selected].Logs)
|
|
||||||
},
|
},
|
||||||
func() fyne.CanvasObject { return widget.NewLabel("log") },
|
func() fyne.CanvasObject { return widget.NewLabel("log") },
|
||||||
func(id widget.ListItemID, item fyne.CanvasObject) {
|
func(id widget.ListItemID, item fyne.CanvasObject) {
|
||||||
item.(*widget.Label).SetText(eventText(jobs[selected].Logs[id]))
|
item.(*widget.Label).SetText(eventText(selectedLogs[id]))
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -129,6 +127,7 @@ func newMainView(w fyne.Window) fyne.CanvasObject {
|
|||||||
nextRun.SetText("")
|
nextRun.SetText("")
|
||||||
state.SetText("")
|
state.SetText("")
|
||||||
commandOutput.SetText("")
|
commandOutput.SetText("")
|
||||||
|
selectedLogs = nil
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
selected = index
|
selected = index
|
||||||
@@ -141,6 +140,7 @@ func newMainView(w fyne.Window) fyne.CanvasObject {
|
|||||||
nextRun.SetText(current.NextRun)
|
nextRun.SetText(current.NextRun)
|
||||||
state.SetText(current.LastState)
|
state.SetText(current.LastState)
|
||||||
commandOutput.SetText(current.Output)
|
commandOutput.SetText(current.Output)
|
||||||
|
selectedLogs = append(selectedLogs[:0], current.Logs...)
|
||||||
}
|
}
|
||||||
refresh := func() {
|
refresh := func() {
|
||||||
// Several callbacks mutate jobs, filters, and event history. A single
|
// Several callbacks mutate jobs, filters, and event history. A single
|
||||||
@@ -261,11 +261,9 @@ func newMainView(w fyne.Window) fyne.CanvasObject {
|
|||||||
dialog.ShowInformation("Scheduler paused", "Global pause is active. Resume the scheduler before running jobs.", w)
|
dialog.ShowInformation("Scheduler paused", "Global pause is active. Resume the scheduler before running jobs.", w)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
ran := scheduler.RunNow(selected)
|
if !scheduler.RunNow(selected) {
|
||||||
if ran.Time == "" {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
events = append([]event{ran}, events...)
|
|
||||||
list.Refresh()
|
list.Refresh()
|
||||||
refresh()
|
refresh()
|
||||||
})
|
})
|
||||||
@@ -589,6 +587,21 @@ func newHistoryView(events *[]event) *fyne.Container {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func settingsView(w fyne.Window, store *core.Store, jobs *[]job) fyne.CanvasObject {
|
func settingsView(w fyne.Window, store *core.Store, jobs *[]job) fyne.CanvasObject {
|
||||||
|
startOnLogin := widget.NewCheck("Start PySentry when I sign in", nil)
|
||||||
|
startOnLogin.SetChecked(store.Config.StartOnLogin)
|
||||||
|
autostartStatus := widget.NewLabel("")
|
||||||
|
refreshAutostartStatus := func() {
|
||||||
|
ok, message := core.AutostartStatus(startOnLogin.Checked, store.Paths.ExecutablePath)
|
||||||
|
if ok {
|
||||||
|
autostartStatus.SetText("OK: " + message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
autostartStatus.SetText("Problem: " + message)
|
||||||
|
}
|
||||||
|
startOnLogin.OnChanged = func(bool) {
|
||||||
|
refreshAutostartStatus()
|
||||||
|
}
|
||||||
|
refreshAutostartStatus()
|
||||||
minimizeToTray := widget.NewCheck("Keep running in the system tray", nil)
|
minimizeToTray := widget.NewCheck("Keep running in the system tray", nil)
|
||||||
minimizeToTray.SetChecked(store.Config.KeepRunningInTray)
|
minimizeToTray.SetChecked(store.Config.KeepRunningInTray)
|
||||||
notifications := widget.NewCheck("Show desktop notifications for failed jobs", nil)
|
notifications := widget.NewCheck("Show desktop notifications for failed jobs", nil)
|
||||||
@@ -632,12 +645,19 @@ func settingsView(w fyne.Window, store *core.Store, jobs *[]job) fyne.CanvasObje
|
|||||||
store.Config.JobsDir = strings.TrimSpace(jobsDir.Text)
|
store.Config.JobsDir = strings.TrimSpace(jobsDir.Text)
|
||||||
store.Config.MaxLogFiles = files
|
store.Config.MaxLogFiles = files
|
||||||
store.Config.MaxLogAgeDays = days
|
store.Config.MaxLogAgeDays = days
|
||||||
|
store.Config.StartOnLogin = startOnLogin.Checked
|
||||||
store.Config.KeepRunningInTray = minimizeToTray.Checked
|
store.Config.KeepRunningInTray = minimizeToTray.Checked
|
||||||
store.Config.NotifyOnFailure = notifications.Checked
|
store.Config.NotifyOnFailure = notifications.Checked
|
||||||
if err := store.SaveConfig(); err != nil {
|
if err := store.SaveConfig(); err != nil {
|
||||||
settingsStatus.SetText("Save failed: " + err.Error())
|
settingsStatus.SetText("Save failed: " + err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if err := core.SetAutostart(store.Config.StartOnLogin, store.Paths.ExecutablePath); err != nil {
|
||||||
|
refreshAutostartStatus()
|
||||||
|
settingsStatus.SetText("Saved, autostart failed: " + err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
refreshAutostartStatus()
|
||||||
// When the jobs directory changes, save the currently loaded jobs to the
|
// When the jobs directory changes, save the currently loaded jobs to the
|
||||||
// newly resolved path immediately. That makes the setting visible on disk
|
// newly resolved path immediately. That makes the setting visible on disk
|
||||||
// without requiring a restart or a separate migration command.
|
// without requiring a restart or a separate migration command.
|
||||||
@@ -656,6 +676,8 @@ func settingsView(w fyne.Window, store *core.Store, jobs *[]job) fyne.CanvasObje
|
|||||||
|
|
||||||
return container.NewPadded(container.NewVBox(
|
return container.NewPadded(container.NewVBox(
|
||||||
widget.NewLabelWithStyle("Application", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
|
widget.NewLabelWithStyle("Application", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}),
|
||||||
|
detailRow("Version", widget.NewLabel(core.Version)),
|
||||||
|
detailRow("Start on login", container.NewBorder(nil, nil, nil, autostartStatus, startOnLogin)),
|
||||||
minimizeToTray,
|
minimizeToTray,
|
||||||
notifications,
|
notifications,
|
||||||
widget.NewSeparator(),
|
widget.NewSeparator(),
|
||||||
|
|||||||
Reference in New Issue
Block a user