service.go
Rama main · réplica de lectura del repositorio original.
package main
import (
"errors"
"fmt"
"net/url"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"github.com/kardianos/service"
"github.com/ulfdalen/scalebridge-sync/internal/store"
)
// newService builds the OS service handle. dir must already be absolute.
// serviceArgs is the command line baked into the OS service at install time.
// The absolute config dir goes in because on Windows the service runs as
// LocalSystem, whose os.UserConfigDir() is not the installing user's AppData,
// so it would sync from an empty state file. --public-url goes in so the
// service comes back with the identity the proxy and Withings expect.
func serviceArgs(dir string, public *url.URL) []string {
args := []string{"run", "--config", dir}
if public != nil {
args = append(args, "--public-url", public.String())
}
return args
}
func newService(dir string, public *url.URL) (service.Service, error) {
cfg := &service.Config{
Name: "scalebridge-sync",
DisplayName: "ScaleBridge Sync",
Description: "Syncs Withings scale measurements to Garmin Connect.",
Arguments: serviceArgs(dir, public),
}
switch runtime.GOOS {
case "darwin":
// LaunchAgent in ~/Library/LaunchAgents: runs as the user, at login.
// RunAtLoad is off by default in kardianos, so set it explicitly rather
// than rely on KeepAlive to start the job.
cfg.Option = service.KeyValue{"UserService": true, "RunAtLoad": true}
case "linux":
// Only systemd supports user units; sysv/upstart/openrc reject the option
// outright, so there we fall back to a system-level unit.
if service.Platform() == "linux-systemd" {
cfg.Option = service.KeyValue{"UserService": true}
}
}
return service.New(&program{dir: dir, public: public}, cfg)
}
// program is the kardianos side of the same server "run" starts.
type program struct {
dir string
public *url.URL
app *app
}
func (p *program) Start(s service.Service) error {
a, err := newApp(p.dir, "127.0.0.1", p.public)
if err != nil {
return err
}
if err := a.listen(); err != nil {
return fmt.Errorf("listen on 127.0.0.1:%d: %w", a.port, err)
}
p.app = a
warnPublic(p.public)
a.start() // must return promptly: the serving happens in goroutines
return nil
}
func (p *program) Stop(s service.Service) error {
if p.app != nil {
p.app.stop()
}
return nil
}
func runUnderServiceManager(dir string, public *url.URL) int {
svc, err := newService(dir, public)
if err != nil {
fmt.Fprintln(os.Stderr, err)
return 1
}
if err := svc.Run(); err != nil {
fmt.Fprintln(os.Stderr, err)
return 1
}
return 0
}
// ── control commands ──
func cmdInstall(dir string, public *url.URL) int {
// Create the config dir first: the absolute path baked into the unit has to
// exist before the service ever starts.
if _, err := store.Open(dir); err != nil {
fmt.Fprintln(os.Stderr, err)
return 1
}
svc, err := newService(dir, public)
if err != nil {
fmt.Fprintln(os.Stderr, err)
return 1
}
if err := svc.Install(); err != nil {
fmt.Fprintf(os.Stderr, "install failed: %v\n", err)
if runtime.GOOS == "windows" {
fmt.Fprintln(os.Stderr, "registering a Windows Service needs an Administrator command prompt")
}
if runtime.GOOS == "linux" && service.Platform() != "linux-systemd" {
fmt.Fprintf(os.Stderr, "on %s the unit is system-level; try again with sudo\n", service.Platform())
}
return 1
}
switch {
case runtime.GOOS == "darwin":
fmt.Println("Installed as a LaunchAgent in ~/Library/LaunchAgents - it starts at login.")
case runtime.GOOS == "windows":
fmt.Println("Installed as a Windows Service (LocalSystem) - it starts at boot.")
fmt.Printf("Config dir baked into the service: %s\n", dir)
case service.Platform() == "linux-systemd":
fixUserUnitTarget()
fmt.Println("Installed as a systemd user unit - it starts when you log in.")
fmt.Println("loginctl enable-linger $USER # keeps it running when you log out")
default:
fmt.Printf("Installed as a %s service.\n", service.Platform())
}
fmt.Println("Start it now with: scalebridge-sync start")
return 0
}
func cmdUninstall(dir string) int {
svc, err := newService(dir, nil)
if err != nil {
fmt.Fprintln(os.Stderr, err)
return 1
}
svc.Stop() // it may not be running; the uninstall is what matters
if err := svc.Uninstall(); err != nil {
fmt.Fprintf(os.Stderr, "uninstall failed: %v\n", err)
return 1
}
fmt.Println("Service removed. Your configuration and tokens are untouched:")
fmt.Println(" " + dir)
return 0
}
func cmdControl(dir, action string) int {
svc, err := newService(dir, nil)
if err != nil {
fmt.Fprintln(os.Stderr, err)
return 1
}
// Ask first: launchctl's failure for a missing plist is "Load failed: 5:
// Input/output error", which tells the user nothing.
if _, serr := svc.Status(); notInstalled(serr) {
fmt.Fprintln(os.Stderr, "the service is not installed - run: scalebridge-sync install")
return 1
}
if action == "start" {
err = svc.Start()
} else {
err = svc.Stop()
}
if err != nil {
if notInstalled(err) {
fmt.Fprintln(os.Stderr, "the service is not installed - run: scalebridge-sync install")
return 1
}
fmt.Fprintf(os.Stderr, "%s failed: %v\n", action, err)
return 1
}
fmt.Printf("Service %sed.\n", action)
return 0
}
func cmdStatus(dir string) int {
st, port, err := openStore(dir)
if err != nil {
fmt.Fprintln(os.Stderr, err)
return 1
}
svc, err := newService(dir, nil)
if err != nil {
fmt.Fprintln(os.Stderr, err)
return 1
}
switch status, err := svc.Status(); {
case notInstalled(err):
fmt.Println("service: not installed")
case err != nil:
fmt.Printf("service: unknown (%v)\n", err)
case status == service.StatusRunning:
fmt.Println("service: running")
case status == service.StatusStopped:
fmt.Println("service: stopped")
default:
fmt.Println("service: unknown")
}
if dialable(port) {
fmt.Printf("web UI: answering at %s\n", localURL(port))
} else {
fmt.Printf("web UI: not answering on port %d\n", port)
}
fmt.Printf("config: %s\n", st.FilePath())
return 0
}
// Repoints the freshly installed unit at default.target: kardianos hardcodes
// WantedBy=multi-user.target, which only exists in the system manager, so as a
// user unit it enables cleanly and then never starts at login. Best effort - a
// failure here costs the login start, not the install.
func fixUserUnitTarget() {
home, err := os.UserHomeDir()
if err != nil {
return
}
unit := filepath.Join(home, ".config/systemd/user/scalebridge-sync.service")
body, err := os.ReadFile(unit)
if err != nil {
return
}
fixed := strings.Replace(string(body), "WantedBy=multi-user.target", "WantedBy=default.target", 1)
if fixed == string(body) {
return
}
if err := os.WriteFile(unit, []byte(fixed), 0o644); err != nil {
return
}
exec.Command("systemctl", "--user", "daemon-reload").Run()
// reenable = disable + enable: drops the stale multi-user.target.wants link.
exec.Command("systemctl", "--user", "reenable", "scalebridge-sync.service").Run()
}
// Covers the systems that return ErrNotInstalled and the ones (launchd, some
// systemd versions) that only say so in the error text.
func notInstalled(err error) bool {
if err == nil {
return false
}
if errors.Is(err, service.ErrNotInstalled) {
return true
}
return strings.Contains(strings.ToLower(err.Error()), "not installed")
}