diff --git a/README.md b/README.md index 5825959..80aca0f 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,8 @@ No Elgato software required — communicates directly with the device over USB H - Runs as a systemd user service (Linux) or launchd agent (macOS), starts automatically at login - No Stream Deck app, no Node.js, no Electron +- **Modules** — define reusable, parameterised commands in `modules.yaml` with Go templates; secrets stay in env vars, config stays in dotfiles. First built-in example: Slack (status, presence, snooze). See [Modules](#modules). + **Planned:** text/label overlays on keys, multi-page layouts, AUR package — see [Roadmap](#roadmap) --- @@ -37,13 +39,16 @@ streamdeck-go/ ├── internal/ │ ├── config/ │ │ └── config.go # YAML parsing with XDG-aware defaults -│ └── device/ -│ └── streamdeck.go # USB HID communication, image encoding, button reads +│ ├── device/ +│ │ └── streamdeck.go # USB HID communication, image encoding, button reads +│ └── modules/ +│ └── modules.go # Module registry, template resolution, env/expiry helpers ├── systemd/ │ └── streamdeck-go.service # systemd user service (Linux) ├── launchd/ │ └── com.woodarddigital.streamdeck-go.plist # launchd agent (macOS) ├── config.example.yaml # Starter config (copied to ~/.config on install) +├── modules.example.yaml # Example modules file (Slack integration) ├── Makefile # build / install / uninstall ├── go.mod └── go.sum @@ -53,10 +58,15 @@ streamdeck-go/ ``` ~/.config/streamdeck-go/config.yaml +~/.config/streamdeck-go/modules.yaml (optional) │ - ├── fsnotify watcher ──── file saved? ──▶ cancel ctx → reload config → restart run() + ├── fsnotify watcher ──── file saved? ──▶ cancel ctx → reload both → restart run() │ - └──▶ run(ctx) + └──▶ run(ctx, registry) + │ + ├── module resolution ──▶ key has module/function? + │ └──▶ registry.Resolve() → rendered shell command + │ (templates expanded, env vars resolved) │ ├── static icon ──▶ device.SetKeyImage() (scale → flip → JPEG → HID) │ @@ -391,6 +401,94 @@ keys: --- +### Launching applications + +On Linux, GUI apps are typically launched by their binary name (`firefox`, `ghostty`, `nautilus`). On macOS, apps live in `/Applications/` as `.app` bundles and need to be opened differently. + +#### macOS — `open -a` + +The `open -a` command launches (or focuses) a macOS application by name: + +```yaml +keys: + 0: + icon: firefox.png + command: "open -a Firefox" + 1: + icon: slack.png + command: "open -a Slack" + 2: + icon: ghostty.png + command: "open -a Ghostty" + 3: + icon: finder.png + command: "open -a Finder ~/Documents" # open Finder to a specific folder + 4: + icon: vscode.png + command: "open -a 'Visual Studio Code'" # quote names with spaces +``` + +**How `open -a` works:** +- If the app is already running, it brings it to the front (no duplicate launched). +- If the app is not running, it launches it. +- The app name matches what's in `/Applications/` minus the `.app` extension. +- To find the exact name: `ls /Applications/` or `osascript -e 'tell application "System Events" to get name of every process whose background only is false'` + +**Opening files and URLs:** + +```yaml +keys: + 5: + icon: project.png + command: "open -a 'Visual Studio Code' ~/Projects/myproject" # open a folder in VS Code + 6: + icon: notes.png + command: "open ~/Documents/notes.txt" # opens in default app for .txt + 7: + icon: github.png + command: "open https://github.com" # opens in default browser +``` + +#### Linux — direct binary + +```yaml +keys: + 0: + icon: firefox.png + command: firefox + 1: + icon: slack.png + command: slack + 2: + icon: ghostty.png + command: ghostty + 3: + icon: files.png + command: nautilus ~/Documents +``` + +Most Linux desktop apps can also be launched with their `.desktop` file via `gtk-launch`: + +```yaml +keys: + 4: + icon: vscode.png + command: "gtk-launch code" # uses the .desktop file name (without .desktop) +``` + +#### Cross-platform keys + +If you use the same config on both platforms, you can use a shell one-liner: + +```yaml +keys: + 0: + icon: browser.png + command: "if [ \"$(uname)\" = \"Darwin\" ]; then open -a Firefox; else firefox; fi" +``` + +--- + ### Terminal & SSH commands Any shell command works — including launching terminals and SSH sessions. @@ -404,7 +502,7 @@ keys: command: ghostty # Linux 1: icon: terminal.png - command: open -a Terminal # macOS — or: open -a iTerm + command: open -a Ghostty # macOS — or: open -a Terminal, open -a iTerm ``` **SSH:** @@ -506,6 +604,199 @@ commands: --- +### Modules + +Modules let you define reusable commands in a separate `modules.yaml` file instead of inlining shell commands in your config. This is especially useful for API-driven integrations like Slack where commands are long `curl` calls with tokens and JSON payloads. + +#### How it works + +1. Create `~/.config/streamdeck-go/modules.yaml` (next to your `config.yaml`). +2. Define modules with named functions. Each function has an `exec` template and optional default `params`. +3. Reference them in `config.yaml` with `module`, `function`, and optional `params` overrides. + +The daemon watches `modules.yaml` for changes alongside `config.yaml` — edits to either file trigger a live reload. + +#### Module file format + +```yaml +# ~/.config/streamdeck-go/modules.yaml + +modules: + slack: + set_status: + params: + emoji: ":speech_balloon:" + text: "In a meeting" + expiry: "1h" + exec: | + curl -s -X POST https://slack.com/api/users.profile.set \ + -H "Authorization: Bearer {{env "SLACK_TOKEN"}}" \ + -H "Content-Type: application/json" \ + -d '{"profile":{"status_emoji":"{{.emoji}}","status_text":"{{.text}}","status_expiration":{{expiry .expiry}}}}' + + clear_status: + exec: | + curl -s -X POST https://slack.com/api/users.profile.set \ + -H "Authorization: Bearer {{env "SLACK_TOKEN"}}" \ + -H "Content-Type: application/json" \ + -d '{"profile":{"status_emoji":"","status_text":"","status_expiration":0}}' + + set_presence: + params: + presence: "away" + exec: | + curl -s -X POST https://slack.com/api/users.setPresence \ + -H "Authorization: Bearer {{env "SLACK_TOKEN"}}" \ + -H "Content-Type: application/json" \ + -d '{"presence":"{{.presence}}"}' + + snooze: + params: + minutes: "60" + exec: | + curl -s -X POST https://slack.com/api/dnd.setSnooze \ + -H "Authorization: Bearer {{env "SLACK_TOKEN"}}" \ + -H "Content-Type: application/json" \ + -d '{"num_minutes":{{.minutes}}}' + + end_snooze: + exec: | + curl -s -X POST https://slack.com/api/dnd.endSnooze \ + -H "Authorization: Bearer {{env "SLACK_TOKEN"}}" +``` + +A full example is included at [`modules.example.yaml`](modules.example.yaml) in the repo root. + +#### Using modules in config.yaml + +Reference a module function instead of writing inline commands: + +```yaml +keys: + # Set Slack status to "In a meeting" for 1 hour (uses module defaults) + 10: + icon: meeting.png + module: slack + function: set_status + + # Override default params — different emoji, text, and duration + 11: + icon: lunch.png + module: slack + function: set_status + params: + emoji: ":knife_fork_plate:" + text: "Out to lunch" + expiry: "30m" + + # Clear Slack status + 12: + icon: clear-status.png + module: slack + function: clear_status + + # Go away / come back + 13: + icon: away.png + module: slack + function: set_presence + params: + presence: "away" + + 14: + icon: active.png + module: slack + function: set_presence + params: + presence: "auto" + + # Snooze notifications for 60 minutes + 15: + icon: snooze.png + module: slack + function: snooze + + # End snooze + 16: + icon: unsnooze.png + module: slack + function: end_snooze +``` + +Poll commands also support modules — use `module`, `function`, and `params` inside the `poll` block: + +```yaml +keys: + 17: + icon_true: dnd-on.png + icon_false: dnd-off.png + module: slack + function: snooze + poll: + module: slack + function: check_dnd + interval: 10s + match: "snooze_enabled.*true" +``` + +#### Template helpers + +Two helpers are available in `exec` templates: + +| Helper | Usage | Description | +|---|---|---| +| `env` | `{{env "VAR_NAME"}}` | Returns the value of an environment variable | +| `expiry` | `{{expiry .duration}}` | Converts a Go duration string (e.g. `"1h"`, `"30m"`) to a Unix epoch timestamp. `"0"` or `""` returns `"0"` (no expiry) | + +#### Secrets and tokens + +**Tokens and secrets must never go in `modules.yaml` or `config.yaml`.** Use the `{{env "VAR_NAME"}}` template helper to read them from environment variables at runtime. + +**Setting up your Slack token:** + +1. Create a Slack app at [api.slack.com/apps](https://api.slack.com/apps) with these OAuth scopes: + - `users.profile:write` — set/clear status + - `users:write` — set presence (away/auto) + - `dnd:write` — snooze/unsnooze notifications + +2. Install the app to your workspace and copy the **User OAuth Token** (`xoxp-...`). + +3. Export the token in your shell profile (`~/.zshrc`, `~/.bash_profile`, etc.): + + ```bash + export SLACK_TOKEN="xoxp-your-token-here" + ``` + +4. Make sure the service can see the variable: + + **macOS** — add to `~/Library/LaunchAgents/com.woodarddigital.streamdeck-go.plist` inside the `` block: + ```xml + EnvironmentVariables + + SLACK_TOKEN + xoxp-your-token-here + + ``` + + **Linux** — import into the systemd user environment: + ```bash + systemctl --user import-environment SLACK_TOKEN + ``` + + Or add an `Environment=` line via `systemctl --user edit streamdeck-go`: + ```ini + [Service] + Environment=SLACK_TOKEN=xoxp-your-token-here + ``` + +> **Security notes:** +> - The `modules.yaml` and `config.yaml` files can safely live in your dotfiles repo — they contain no secrets. +> - On Linux, the systemd override file (`~/.config/systemd/user/streamdeck-go.service.d/override.conf`) is user-readable only (mode 600). Still, prefer `import-environment` over hardcoding tokens in unit files when possible. +> - On macOS, launchd plist files in `~/Library/LaunchAgents/` are user-readable only by default. +> - Never commit tokens to git. Add `.env` files to `.gitignore` if you use one. + +--- + ### Supported icon formats | Format | Notes | diff --git a/cmd/streamdeck/main.go b/cmd/streamdeck/main.go index 54c20a4..68ad09e 100644 --- a/cmd/streamdeck/main.go +++ b/cmd/streamdeck/main.go @@ -23,6 +23,7 @@ import ( "github.com/WoodardDigital/streamdeck-go/internal/config" "github.com/WoodardDigital/streamdeck-go/internal/device" + "github.com/WoodardDigital/streamdeck-go/internal/modules" "github.com/fsnotify/fsnotify" "github.com/srwiley/oksvg" "github.com/srwiley/rasterx" @@ -51,6 +52,11 @@ func main() { log.Fatalf("config: %v", err) } + reg, err := modules.LoadRegistry(config.ModulesPath(*cfgPath)) + if err != nil { + log.Fatalf("modules: %v", err) + } + // Open device — if not present at startup, wait for it. sd := mustConnect(cfg.Device.VendorID, cfg.Device.ProductID) @@ -61,7 +67,7 @@ func main() { startRun := func() { go func() { - deviceDied := run(ctx, sd, cfg) + deviceDied := run(ctx, sd, cfg, reg) runDone <- deviceDied }() } @@ -91,13 +97,15 @@ func main() { ctx, cancel = context.WithCancel(context.Background()) startRun() - // Config file changed — reload and restart run(). + // Config file or modules file changed — reload and restart run(). case event, ok := <-watcher.Events: if !ok { cancel() return } - if filepath.Clean(event.Name) != filepath.Clean(*cfgPath) { + cfgChanged := filepath.Clean(event.Name) == filepath.Clean(*cfgPath) + modChanged := filepath.Clean(event.Name) == filepath.Clean(config.ModulesPath(*cfgPath)) + if !cfgChanged && !modChanged { continue } if !event.Has(fsnotify.Write) && !event.Has(fsnotify.Create) { @@ -114,6 +122,12 @@ func main() { } else { cfg = newCfg } + newReg, err := modules.LoadRegistry(config.ModulesPath(*cfgPath)) + if err != nil { + log.Printf("reload: bad modules: %v — keeping current modules", err) + } else { + reg = newReg + } ctx, cancel = context.WithCancel(context.Background()) startRun() @@ -129,7 +143,7 @@ func main() { // run initialises the deck and handles button presses until ctx is cancelled // or the device dies. Returns true if the device died, false if ctx was cancelled. -func run(ctx context.Context, sd *device.StreamDeck, cfg *config.Config) (deviceDied bool) { +func run(ctx context.Context, sd *device.StreamDeck, cfg *config.Config, reg *modules.Registry) (deviceDied bool) { // Recover from any panic inside this goroutine tree. defer func() { if r := recover(); r != nil { @@ -157,6 +171,24 @@ func run(ctx context.Context, sd *device.StreamDeck, cfg *config.Config) (device triggers := make(map[int]chan struct{}) for keyIdx, keyCfg := range cfg.Keys { + // Resolve module-based commands to shell strings before any other logic. + if keyCfg.Module != "" { + resolved, err := reg.Resolve(keyCfg.Module, keyCfg.Function, keyCfg.Params) + if err != nil { + log.Printf("key %d: module: %v", keyIdx, err) + continue + } + keyCfg.Command = resolved + } + if keyCfg.Poll != nil && keyCfg.Poll.Module != "" { + resolved, err := reg.Resolve(keyCfg.Poll.Module, keyCfg.Poll.Function, keyCfg.Poll.Params) + if err != nil { + log.Printf("key %d: poll module: %v", keyIdx, err) + continue + } + keyCfg.Poll.Command = resolved + } + // Toggle/status key: managed by a polling goroutine. if keyCfg.Poll != nil { if keyCfg.IconTrue == "" || keyCfg.IconFalse == "" { diff --git a/internal/config/config.go b/internal/config/config.go index 3e76d21..113d127 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -13,6 +13,11 @@ type PollConfig struct { Command string `yaml:"command"` // shell command whose output is checked Interval string `yaml:"interval"` // how often to poll, e.g. "2s" (default: "2s") Match string `yaml:"match"` // substring to find in output → "on" state; omit to use exit code 0 + + // Module-based alternative to Command: resolved to a shell string at startup. + Module string `yaml:"module"` + Function string `yaml:"function"` + Params map[string]string `yaml:"params"` } // KeyConfig defines what a single Stream Deck key does. @@ -24,6 +29,16 @@ type KeyConfig struct { IconTrue string `yaml:"icon_true"` // icon when poll match is true IconFalse string `yaml:"icon_false"` // icon when poll match is false Poll *PollConfig `yaml:"poll"` + + // Module-based alternative to Command: resolved to a shell string at startup. + Module string `yaml:"module"` + Function string `yaml:"function"` + Params map[string]string `yaml:"params"` +} + +// ModulesPath returns the path to the modules.yaml file next to the given config file. +func ModulesPath(cfgPath string) string { + return filepath.Join(filepath.Dir(cfgPath), "modules.yaml") } // Config is the top-level structure of the YAML config file. diff --git a/internal/modules/modules.go b/internal/modules/modules.go new file mode 100644 index 0000000..066e540 --- /dev/null +++ b/internal/modules/modules.go @@ -0,0 +1,117 @@ +package modules + +import ( + "bytes" + "errors" + "fmt" + "os" + "os/exec" + "strconv" + "text/template" + "time" + + "gopkg.in/yaml.v3" +) + +// FunctionDef is a single callable function within a module. +type FunctionDef struct { + Exec string `yaml:"exec"` // Go text/template that renders to a shell command + Params map[string]string `yaml:"params"` // default parameter values +} + +// ModuleDef maps function names to their definitions. +type ModuleDef map[string]FunctionDef + +// Registry holds all loaded modules. +type Registry struct { + Modules map[string]ModuleDef `yaml:"modules"` +} + +// LoadRegistry reads a modules.yaml file. +// A missing file is not an error — returns an empty Registry so callers don't need to +// special-case systems without a modules.yaml. +func LoadRegistry(path string) (*Registry, error) { + data, err := os.ReadFile(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return &Registry{}, nil + } + return nil, fmt.Errorf("read modules %q: %w", path, err) + } + var r Registry + if err := yaml.Unmarshal(data, &r); err != nil { + return nil, fmt.Errorf("parse modules %q: %w", path, err) + } + return &r, nil +} + +// Resolve looks up module/function, merges params (defaults overridden by overrides), +// and renders the exec template. Returns the shell command string ready for sh -c. +func (r *Registry) Resolve(module, function string, overrides map[string]string) (string, error) { + if r == nil || r.Modules == nil { + return "", fmt.Errorf("no modules loaded") + } + mod, ok := r.Modules[module] + if !ok { + return "", fmt.Errorf("unknown module %q", module) + } + fn, ok := mod[function] + if !ok { + return "", fmt.Errorf("module %q has no function %q", module, function) + } + + // Merge: start with defaults, apply overrides on top. + params := make(map[string]string, len(fn.Params)+len(overrides)) + for k, v := range fn.Params { + params[k] = v + } + for k, v := range overrides { + params[k] = v + } + + tmpl, err := template.New("exec").Funcs(funcMap()).Parse(fn.Exec) + if err != nil { + return "", fmt.Errorf("module %q function %q: parse template: %w", module, function, err) + } + + var buf bytes.Buffer + if err := tmpl.Execute(&buf, params); err != nil { + return "", fmt.Errorf("module %q function %q: render template: %w", module, function, err) + } + return buf.String(), nil +} + +// Execute resolves the module/function and runs it via sh -c. +func (r *Registry) Execute(module, function string, overrides map[string]string) error { + shell, err := r.Resolve(module, function, overrides) + if err != nil { + return err + } + c := exec.Command("sh", "-c", shell) + c.Stdout = os.Stdout + c.Stderr = os.Stderr + return c.Run() +} + +// funcMap returns the template helper functions available in exec templates. +func funcMap() template.FuncMap { + return template.FuncMap{ + // env returns the value of an environment variable. + // Use this to keep secrets (tokens, passwords) out of modules.yaml. + "env": os.Getenv, + + // expiry converts a duration string like "+1h" to a Unix epoch timestamp string. + // Pass "" or "0" to get "0" (no expiry). Supports Go duration syntax (e.g. "30m", "2h"). + // Note: time.ParseDuration does not support days (d) or weeks (w). + "expiry": func(s string) string { + if s == "" || s == "0" { + return "0" + } + d, err := time.ParseDuration(s) + if err != nil { + return "0" + } + return strconv.FormatInt(time.Now().Add(d).Unix(), 10) + }, + } +} diff --git a/modules.example.yaml b/modules.example.yaml new file mode 100644 index 0000000..9d13b65 --- /dev/null +++ b/modules.example.yaml @@ -0,0 +1,58 @@ +# Example modules.yaml — copy to ~/.config/streamdeck-go/modules.yaml +# +# Required Slack token scopes: users.profile:write, users:write, dnd:write +# Export your token: export SLACK_TOKEN="xoxp-..." + +modules: + slack: + set_status: + params: + emoji: ":speech_balloon:" + text: "In a meeting" + expiry: "1h" + exec: | + curl -s -X POST https://slack.com/api/users.profile.set \ + -H "Authorization: Bearer {{env "SLACK_TOKEN"}}" \ + -H "Content-Type: application/json" \ + -d '{"profile":{"status_emoji":"{{.emoji}}","status_text":"{{.text}}","status_expiration":{{expiry .expiry}}}}' + + clear_status: + exec: | + curl -s -X POST https://slack.com/api/users.profile.set \ + -H "Authorization: Bearer {{env "SLACK_TOKEN"}}" \ + -H "Content-Type: application/json" \ + -d '{"profile":{"status_emoji":"","status_text":"","status_expiration":0}}' + + set_presence: + params: + presence: "away" + exec: | + curl -s -X POST https://slack.com/api/users.setPresence \ + -H "Authorization: Bearer {{env "SLACK_TOKEN"}}" \ + -H "Content-Type: application/json" \ + -d '{"presence":"{{.presence}}"}' + + snooze: + params: + minutes: "60" + exec: | + curl -s -X POST https://slack.com/api/dnd.setSnooze \ + -H "Authorization: Bearer {{env "SLACK_TOKEN"}}" \ + -H "Content-Type: application/json" \ + -d '{"num_minutes":{{.minutes}}}' + + go_offline: + exec: | + curl -s -X POST https://slack.com/api/users.setPresence \ + -H "Authorization: Bearer {{env "SLACK_TOKEN"}}" \ + -H "Content-Type: application/json" \ + -d '{"presence":"away"}' && \ + curl -s -X POST https://slack.com/api/users.profile.set \ + -H "Authorization: Bearer {{env "SLACK_TOKEN"}}" \ + -H "Content-Type: application/json" \ + -d '{"profile":{"status_emoji":"","status_text":"","status_expiration":0}}' + + end_snooze: + exec: | + curl -s -X POST https://slack.com/api/dnd.endSnooze \ + -H "Authorization: Bearer {{env "SLACK_TOKEN"}}"