mirror of
https://github.com/Xevion/HATray.git
synced 2025-12-06 15:15:16 -06:00
feat!: add linux cross-platform support with systemd unit file
This commit is contained in:
44
build/unix/HATray.service
Normal file
44
build/unix/HATray.service
Normal 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
|
||||||
@@ -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
2
go.mod
@@ -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
2
go.sum
@@ -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=
|
||||||
|
|||||||
@@ -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
90
internal/service/linux.go
Normal 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
28
tasks/Taskfile_linux.yml
Normal 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
|
||||||
Reference in New Issue
Block a user