feat!: add linux cross-platform support with systemd unit file

This commit is contained in:
2025-06-23 03:48:44 -05:00
parent 0aba8b4bfa
commit a6a774aac7
7 changed files with 181 additions and 15 deletions

44
build/unix/HATray.service Normal file
View File

@@ -0,0 +1,44 @@
[Unit]
Description=HATray - Home Assistant Tray Utility
Documentation=https://github.com/Xevion/HATray
After=network-online.target
Wants=network-online.target
[Service]
Type=notify
NotifyAccess=main
ExecStart=$BINARY_PATH
ExecReload=/bin/kill -HUP $MAINPID
Restart=on-failure
RestartSec=5
# CPUAccounting=yes
# MemoryAccounting=yes
# StandardOutput=journal
# StandardError=journal
# SyslogIdentifier=HATray
# Environment variables
# Environment=HOME=/home/%i
# Security settings
# NoNewPrivileges=true
# PrivateTmp=true
# ProtectSystem=strict
# ProtectHome=true
# ReadWritePaths=/home/%i/.config/HATray
# Resource limits (cgroups v2)
# MemoryMax=128M
# MemoryHigh=96M
# MemorySwapMax=0
# CPUQuota=10%
# CPUWeight=100
# IOWeight=100
# TasksMax=100
# LimitNOFILE=1024
# LimitCORE=0
[Install]
WantedBy=default.target

View File

@@ -14,25 +14,25 @@ import (
func main() { func main() {
logger, logFile, err := setupLogging() logger, logFile, err := setupLogging()
if err != nil { if err != nil {
log.Fatalf("Failed to setup logging: %v", err) log.Fatalf("failed to setup logging: %v", err)
} }
defer logFile.Sync() defer logFile.Sync()
defer logFile.Close() defer logFile.Close()
defer func() { defer func() {
if r := recover(); r != nil { if r := recover(); r != nil {
logger.Error("Uncaught panic recovered", "panic", r) logger.Error("uncaught panic recovered", "panic", r)
} }
}() }()
// Create service layer // Create service layer
svc := service.NewService(logger) svc := service.NewService(logger)
logger.Info("HATray initialized, running service") logger.Info("service initialized")
// Main loop // Main loop
if err := svc.Run(); err != nil { if err := svc.Run(); err != nil {
fmt.Fprintf(os.Stderr, "Application error: %v\n", err) fmt.Fprintf(os.Stderr, "application error: %v\n", err)
os.Exit(1) os.Exit(1)
} }
} }

2
go.mod
View File

@@ -3,3 +3,5 @@ module ha-tray
go 1.21 go 1.21
require golang.org/x/sys v0.15.0 require golang.org/x/sys v0.15.0
require github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf // indirect

2
go.sum
View File

@@ -1,2 +1,4 @@
github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf h1:iW4rZ826su+pqaw19uhpSCzhj44qo35pNgKFGqzDKkU=
github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc=
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=

View File

@@ -34,7 +34,7 @@ func (app *App) Pause() error {
app.mu.Lock() app.mu.Lock()
defer app.mu.Unlock() defer app.mu.Unlock()
app.logger.Info("Pausing application", app.logger.Info("pausing application",
"action", "pause", "action", "pause",
"previous_state", app.state, "previous_state", app.state,
"new_state", StatePaused) "new_state", StatePaused)
@@ -46,7 +46,7 @@ func (app *App) Pause() error {
app.state = StatePaused app.state = StatePaused
app.logger.Info("Application paused successfully", app.logger.Info("paused successfully",
"action", "pause", "action", "pause",
"state", app.state) "state", app.state)
@@ -58,7 +58,7 @@ func (app *App) Resume() error {
app.mu.Lock() app.mu.Lock()
defer app.mu.Unlock() defer app.mu.Unlock()
app.logger.Info("Resuming application", app.logger.Info("resuming application",
"action", "resume", "action", "resume",
"previous_state", app.state, "previous_state", app.state,
"new_state", StateRunning) "new_state", StateRunning)
@@ -70,7 +70,7 @@ func (app *App) Resume() error {
app.state = StateRunning app.state = StateRunning
app.logger.Info("Application resumed successfully", app.logger.Info("resumed successfully",
"action", "resume", "action", "resume",
"state", app.state) "state", app.state)
@@ -79,14 +79,14 @@ func (app *App) Resume() error {
// Reload pauses the application, re-reads configuration files, then resumes // Reload pauses the application, re-reads configuration files, then resumes
func (a *App) Reload() error { func (a *App) Reload() error {
a.logger.Info("Starting application reload", a.logger.Info("starting application reload",
"action", "reload", "action", "reload",
"current_state", a.state) "current_state", a.state)
// Pause if not already paused // Pause if not already paused
if a.state != StatePaused { if a.state != StatePaused {
if err := a.Pause(); err != nil { if err := a.Pause(); err != nil {
a.logger.Error("Failed to pause during reload", a.logger.Error("failed to pause during reload",
"action", "reload", "action", "reload",
"error", err) "error", err)
return err return err
@@ -98,18 +98,18 @@ func (a *App) Reload() error {
// - Validate configuration // - Validate configuration
// - Update internal state with new configuration // - Update internal state with new configuration
a.logger.Info("Configuration reloaded successfully", a.logger.Info("configuration reloaded successfully",
"action", "reload") "action", "reload")
// Resume the application // Resume the application
if err := a.Resume(); err != nil { if err := a.Resume(); err != nil {
a.logger.Error("Failed to resume after reload", a.logger.Error("failed to resume after reload",
"action", "reload", "action", "reload",
"error", err) "error", err)
return err return err
} }
a.logger.Info("Application reload completed successfully", a.logger.Info("application reload completed successfully",
"action", "reload", "action", "reload",
"final_state", a.state) "final_state", a.state)
@@ -128,7 +128,7 @@ func (app *App) Stop() error {
app.mu.Lock() app.mu.Lock()
defer app.mu.Unlock() defer app.mu.Unlock()
app.logger.Info("Stopping application", app.logger.Info("stopping application",
"action", "stop", "action", "stop",
"previous_state", app.state, "previous_state", app.state,
"new_state", StateStopped) "new_state", StateStopped)
@@ -140,7 +140,7 @@ func (app *App) Stop() error {
app.state = StateStopped app.state = StateStopped
app.logger.Info("Application stopped successfully", app.logger.Info("application stopped successfully",
"action", "stop", "action", "stop",
"state", app.state) "state", app.state)

90
internal/service/linux.go Normal file
View File

@@ -0,0 +1,90 @@
//go:build linux
package service
import (
"log/slog"
"os"
"os/signal"
"syscall"
"time"
"ha-tray/internal/app"
"github.com/coreos/go-systemd/daemon"
)
const serviceName = "HATray"
// linuxService implements the Service interface for Linux
// It integrates with systemd and controls the app layer
// according to systemd signals (start, stop, reload)
type linuxService struct {
logger *slog.Logger
app *app.App
}
// NewService creates a new Linux service instance
func NewService(logger *slog.Logger) Service {
return &linuxService{
logger: logger,
app: app.NewApp(logger),
}
}
// Run implements the Service interface for Linux
func (s *linuxService) Run() error {
s.logger.Info("starting service")
// Notify systemd that we are starting
daemon.SdNotify(false, "STATUS=Starting HATray...\n")
// Setup signal handling for systemd
sigs := make(chan os.Signal, 1)
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP, syscall.SIGUSR1, syscall.SIGUSR2)
done := make(chan struct{})
// Notify systemd that we are ready
daemon.SdNotify(false, "READY=1")
daemon.SdNotify(false, "STATUS=HATray running\n")
go func() {
for {
sig := <-sigs
s.logger.Info("signal received", "signal", sig)
switch sig {
case syscall.SIGINT, syscall.SIGTERM:
daemon.SdNotify(false, "STOPPING=1")
s.logger.Info("stopping service")
s.app.Stop()
close(done)
return
case syscall.SIGHUP:
s.logger.Info("reloading service")
daemon.SdNotify(false, "RELOADING=1")
s.app.Reload()
daemon.SdNotify(false, "READY=1")
case syscall.SIGUSR1:
s.logger.Info("pausing service")
s.app.Pause()
case syscall.SIGUSR2:
s.logger.Info("resuming service")
s.app.Resume()
}
}
}()
// Main loop: heartbeat to systemd
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for {
select {
case <-done:
s.logger.Info("service stopped")
return nil
case <-ticker.C:
daemon.SdNotify(false, "WATCHDOG=1")
s.logger.Debug("heartbeat")
}
}
}

28
tasks/Taskfile_linux.yml Normal file
View File

@@ -0,0 +1,28 @@
version: '3'
vars:
BINARY_NAME: '{{.APP_NAME}}'
tasks:
build:
cmds:
- go build -o ./bin/{{.BINARY_NAME}} ./cmd/main.go
service:
desc: "Install the service"
deps: [build]
cmds:
- mkdir -p $HOME/.config/systemd/user
- cmd: systemctl stop HATray --user
ignore_error: true
- cp ./bin/{{.BINARY_NAME}} $HOME/.local/bin/{{.BINARY_NAME}}
# super hacky way of fixing MAINPID being replaced
- BINARY_PATH="$HOME/.local/bin/{{.BINARY_NAME}}" MAINPID='$MAINPID' envsubst < ./build/unix/HATray.service > $HOME/.config/systemd/user/HATray.service
- systemctl daemon-reload --user
# - systemctl enable HATray --user
- systemctl start HATray --user
tail:
desc: "Tail the log file"
cmds:
- sudo journalctl -u HATray -f