Ctrl K
ComunidadGuías…

service.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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")
}