package main import ( "fmt" "os" "os/exec" "path/filepath" "runtime" "sort" "strings" "time" "git.i0t.app/lwoodard/streamdeck-go/internal/config" "git.i0t.app/lwoodard/streamdeck-go/internal/device" "git.i0t.app/lwoodard/streamdeck-go/internal/modules" ) const daemonUnit = "streamdeck-go.service" // Status is the full snapshot consumed by front-ends. Everything a panel needs // to render in one call — polling several small commands from QML would be both // slower and racier. type Status struct { OK bool `json:"ok"` ConfigPath string `json:"configPath"` ModulesPath string `json:"modulesPath"` IconsDir string `json:"iconsDir"` Brightness int `json:"brightness"` ConfigError string `json:"configError,omitempty"` Daemon DaemonStatus `json:"daemon"` Device DeviceStatus `json:"device"` Keys []KeyStatus `json:"keys"` Icons []string `json:"icons"` Modules []ModuleStatus `json:"modules"` Warnings []string `json:"warnings,omitempty"` } type DaemonStatus struct { Unit string `json:"unit"` Active bool `json:"active"` Enabled bool `json:"enabled"` State string `json:"state"` // systemd ActiveState, e.g. "active", "failed" Sub string `json:"sub"` // systemd SubState, e.g. "running" SinceSec int64 `json:"sinceSec"` // seconds since it entered the active state, 0 if unknown } type DeviceStatus struct { Connected bool `json:"connected"` Known bool `json:"known"` // product ID is in the supported-models table Model string `json:"model"` VendorID string `json:"vendorId"` ProductID string `json:"productId"` KeyCount int `json:"keyCount"` Cols int `json:"cols"` Rows int `json:"rows"` ImageWidth int `json:"imageWidth"` ImageHeight int `json:"imageHeight"` Error string `json:"error,omitempty"` } // KeyStatus describes one configured key. Only populated slots appear; the grid // shape comes from Device.Cols/Rows so a front-end can lay out empty slots. type KeyStatus struct { Index int `json:"index"` Kind string `json:"kind"` // empty|static|text|toggle|module Label string `json:"label"` // short human label for a grid cell Icon string `json:"icon,omitempty"` IconPath string `json:"iconPath,omitempty"` // absolute, for previews IconTrue string `json:"iconTrue,omitempty"` IconFalse string `json:"iconFalse,omitempty"` Text string `json:"text,omitempty"` TextColor string `json:"textColor,omitempty"` TextCommand string `json:"textCommand,omitempty"` Refresh string `json:"refresh,omitempty"` Command string `json:"command,omitempty"` Module string `json:"module,omitempty"` Function string `json:"function,omitempty"` Params map[string]string `json:"params,omitempty"` Privileged bool `json:"privileged"` Poll *PollStatus `json:"poll,omitempty"` } type PollStatus struct { Command string `json:"command,omitempty"` Match string `json:"match,omitempty"` Interval string `json:"interval,omitempty"` Module string `json:"module,omitempty"` Function string `json:"function,omitempty"` } type ModuleStatus struct { Name string `json:"name"` Functions []FunctionStatus `json:"functions"` } type FunctionStatus struct { Name string `json:"name"` Params map[string]string `json:"params,omitempty"` } func cmdStatus(cfgPath string) error { status := Status{ OK: true, ConfigPath: cfgPath, ModulesPath: config.ModulesPath(cfgPath), Keys: []KeyStatus{}, Icons: []string{}, Modules: []ModuleStatus{}, } cfg, err := config.Load(cfgPath) if err != nil { // A missing or malformed config is worth reporting in the panel rather // than failing the whole call — daemon and device state are still useful. status.ConfigError = err.Error() status.Daemon = daemonState() status.Device = deviceState(device.VendorID, 0x00ba) emit(status) return nil } status.IconsDir = cfg.IconsDir status.Brightness = cfg.Brightness status.Daemon = daemonState() status.Device = deviceState(cfg.Device.VendorID, cfg.Device.ProductID) reg, err := modules.LoadRegistry(status.ModulesPath) if err != nil { status.Warnings = append(status.Warnings, fmt.Sprintf("modules.yaml: %v", err)) reg = &modules.Registry{} } status.Modules = moduleList(reg) status.Keys = keyList(cfg, reg) status.Icons = iconList(cfg.IconsDir) emit(status) return nil } func keyList(cfg *config.Config, reg *modules.Registry) []KeyStatus { indices := make([]int, 0, len(cfg.Keys)) for index := range cfg.Keys { indices = append(indices, index) } sort.Ints(indices) out := make([]KeyStatus, 0, len(indices)) for _, index := range indices { key := cfg.Keys[index] entry := KeyStatus{ Index: index, Icon: key.Icon, IconTrue: key.IconTrue, IconFalse: key.IconFalse, Text: key.Text, TextColor: key.TextColor, TextCommand: key.TextCommand, Refresh: key.Refresh, Command: key.Command, Module: key.Module, Function: key.Function, Params: key.Params, } if icon := firstNonEmpty(key.Icon, key.IconTrue, key.IconFalse); icon != "" { entry.IconPath = resolveIcon(cfg.IconsDir, icon) } // The rendered template can inline secrets ({{env "TOKEN"}}), so it never // leaves this process — it is only inspected for the priv: prefix. entry.Privileged = strings.HasPrefix(entry.Command, "priv:") if !entry.Privileged && key.Module != "" && key.Function != "" { if resolved, err := reg.Resolve(key.Module, key.Function, key.Params); err == nil { entry.Privileged = strings.HasPrefix(resolved, "priv:") } } if key.Poll != nil { entry.Poll = &PollStatus{ Command: key.Poll.Command, Match: key.Poll.Match, Interval: key.Poll.Interval, Module: key.Poll.Module, Function: key.Poll.Function, } } entry.Kind = keyKind(key) entry.Label = keyLabel(key) out = append(out, entry) } return out } func keyKind(key config.KeyConfig) string { switch { case key.Poll != nil: return "toggle" case key.Module != "": return "module" case key.Icon == "" && (key.Text != "" || key.TextCommand != ""): return "text" case key.Icon != "": return "static" default: return "empty" } } // keyLabel produces a short label for a grid cell: whatever identifies the key // most directly to the person who wrote it. func keyLabel(key config.KeyConfig) string { if key.Text != "" { if line, _, _ := strings.Cut(key.Text, "\n"); strings.TrimSpace(line) != "" { return strings.TrimSpace(line) } } if key.Function != "" { return strings.ReplaceAll(key.Function, "_", " ") } if icon := firstNonEmpty(key.Icon, key.IconTrue, key.IconFalse); icon != "" { base := filepath.Base(icon) return strings.TrimSuffix(base, filepath.Ext(base)) } if key.Command != "" { command := strings.TrimPrefix(key.Command, "priv:") if field := strings.Fields(command); len(field) > 0 { return filepath.Base(field[0]) } } if key.TextCommand != "" { return "live text" } return "key" } func moduleList(reg *modules.Registry) []ModuleStatus { if reg == nil || reg.Modules == nil { return []ModuleStatus{} } names := make([]string, 0, len(reg.Modules)) for name := range reg.Modules { names = append(names, name) } sort.Strings(names) out := make([]ModuleStatus, 0, len(names)) for _, name := range names { def := reg.Modules[name] fnNames := make([]string, 0, len(def)) for fnName := range def { fnNames = append(fnNames, fnName) } sort.Strings(fnNames) functions := make([]FunctionStatus, 0, len(fnNames)) for _, fnName := range fnNames { functions = append(functions, FunctionStatus{Name: fnName, Params: def[fnName].Params}) } out = append(out, ModuleStatus{Name: name, Functions: functions}) } return out } var iconExtensions = map[string]bool{ ".png": true, ".jpg": true, ".jpeg": true, ".svg": true, ".gif": true, } func iconList(dir string) []string { entries, err := os.ReadDir(dir) if err != nil { return []string{} } out := make([]string, 0, len(entries)) for _, entry := range entries { if entry.IsDir() { continue } if iconExtensions[strings.ToLower(filepath.Ext(entry.Name()))] { out = append(out, entry.Name()) } } sort.Strings(out) return out } // resolveIcon mirrors the daemon's lookup: names are relative to icons_dir // unless they are already absolute. func resolveIcon(iconsDir, name string) string { if filepath.IsAbs(name) { return name } return filepath.Join(iconsDir, name) } func deviceState(vendorID, productID uint16) DeviceStatus { status := DeviceStatus{ VendorID: fmt.Sprintf("0x%04x", vendorID), ProductID: fmt.Sprintf("0x%04x", productID), } if model, ok := device.Lookup(productID); ok { status.Known = true status.Model = model.Name status.KeyCount = model.KeyCount status.Cols = model.Cols status.Rows = model.Rows status.ImageWidth = model.ImageWidth status.ImageHeight = model.ImageHeight } else { status.Model = "Unknown model" } present, err := device.Present(vendorID, productID) if err != nil { status.Error = err.Error() return status } status.Connected = present return status } func daemonState() DaemonStatus { status := DaemonStatus{Unit: daemonUnit} if runtime.GOOS == "darwin" { // launchd has no equivalent of ActiveState; `launchctl list` printing the // label at all means it is loaded, and a PID in column one means running. label := "com.woodarddigital.streamdeck-go" out, err := exec.Command("launchctl", "list").Output() if err != nil { status.State = "unknown" return status } for _, line := range strings.Split(string(out), "\n") { if !strings.HasSuffix(strings.TrimSpace(line), label) { continue } status.Enabled = true fields := strings.Fields(line) if len(fields) > 0 && fields[0] != "-" { status.Active = true status.State = "active" status.Sub = "running" } else { status.State = "inactive" } return status } status.State = "inactive" return status } out, err := exec.Command("systemctl", "--user", "show", daemonUnit, "-p", "ActiveState", "-p", "SubState", "-p", "UnitFileState", "-p", "ActiveEnterTimestamp").Output() if err != nil { status.State = "unknown" return status } fields := map[string]string{} for _, line := range strings.Split(string(out), "\n") { name, value, found := strings.Cut(strings.TrimSpace(line), "=") if found { fields[name] = value } } status.State = fields["ActiveState"] status.Sub = fields["SubState"] status.Active = status.State == "active" status.Enabled = strings.HasPrefix(fields["UnitFileState"], "enabled") // Realtime rather than the *Monotonic pair: systemd's monotonic clock // excludes suspend while /proc/uptime includes it, so mixing them overstated // uptime by the suspended duration after every sleep. The timestamp is // rendered in the local zone ("Sat 2026-08-16 10:26:11 MDT"), where the // abbreviation parses unambiguously. if ts := fields["ActiveEnterTimestamp"]; ts != "" && ts != "n/a" { if entered, err := time.ParseInLocation("Mon 2006-01-02 15:04:05 MST", ts, time.Local); err == nil { if since := time.Since(entered); since > 0 { status.SinceSec = int64(since.Seconds()) } } } return status } func firstNonEmpty(values ...string) string { for _, value := range values { if value != "" { return value } } return "" }