adding init program
This commit is contained in:
6
Makefile
6
Makefile
@@ -1,5 +1,6 @@
|
|||||||
BINARY := streamdeck-go
|
BINARY := streamdeck-go
|
||||||
HELPER := streamdeck-helper
|
HELPER := streamdeck-helper
|
||||||
|
INIT := streamdeck-init
|
||||||
PREFIX ?= $(HOME)/.local
|
PREFIX ?= $(HOME)/.local
|
||||||
CONFIG_DIR := $(HOME)/.config/streamdeck-go
|
CONFIG_DIR := $(HOME)/.config/streamdeck-go
|
||||||
GROUP := streamdeck
|
GROUP := streamdeck
|
||||||
@@ -23,7 +24,7 @@ else
|
|||||||
UDEV_RULE := /etc/udev/rules.d/99-streamdeck.rules
|
UDEV_RULE := /etc/udev/rules.d/99-streamdeck.rules
|
||||||
endif
|
endif
|
||||||
|
|
||||||
.PHONY: build build-helper install install-helper uninstall uninstall-helper udev
|
.PHONY: build build-helper build-init install install-helper uninstall uninstall-helper udev
|
||||||
|
|
||||||
# ── Build ─────────────────────────────────────────────────────────────────────
|
# ── Build ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -33,6 +34,9 @@ build:
|
|||||||
build-helper:
|
build-helper:
|
||||||
go build -o $(HELPER) ./cmd/streamdeck-helper/
|
go build -o $(HELPER) ./cmd/streamdeck-helper/
|
||||||
|
|
||||||
|
build-init:
|
||||||
|
go build -o $(INIT) ./cmd/streamdeck-init/
|
||||||
|
|
||||||
# ── Install ───────────────────────────────────────────────────────────────────
|
# ── Install ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
# Interactive install — prompts for dotfiles directory, installs binary + service.
|
# Interactive install — prompts for dotfiles directory, installs binary + service.
|
||||||
|
|||||||
82
README.md
82
README.md
@@ -22,6 +22,7 @@ No Elgato software required — communicates directly with the device over USB H
|
|||||||
- No Stream Deck app, no Node.js, no Electron
|
- 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).
|
- **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).
|
||||||
|
- **Interactive config builder** — TUI tool (`streamdeck-init`) that walks you through key setup: pick a slot, pick a module/function, customize params, choose an icon. No YAML editing required. See [Config builder](#config-builder).
|
||||||
|
|
||||||
**Planned:** text/label overlays on keys, multi-page layouts, AUR package — see [Roadmap](#roadmap)
|
**Planned:** text/label overlays on keys, multi-page layouts, AUR package — see [Roadmap](#roadmap)
|
||||||
|
|
||||||
@@ -797,6 +798,87 @@ Two helpers are available in `exec` templates:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### Config builder
|
||||||
|
|
||||||
|
Instead of editing YAML by hand, use the interactive TUI to configure keys:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make build-init
|
||||||
|
./streamdeck-init
|
||||||
|
```
|
||||||
|
|
||||||
|
Or with a custom config path:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./streamdeck-init -config ~/dotfiles/.config/streamdeck-go/config.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
The tool shows your 8x4 key grid, walks you through each step, and appends the result to your config.yaml:
|
||||||
|
|
||||||
|
```
|
||||||
|
Stream Deck XL — 8×4 grid
|
||||||
|
|
||||||
|
╭─────────╮╭─────────╮╭─────────╮╭─────────╮╭─────────╮╭─────────╮╭─────────╮╭─────────╮
|
||||||
|
│Rootshell││ lights ││set_stat ││ WHH ││Dumpster ││ [5] ││ [6] ││ [7] │
|
||||||
|
╰─────────╯╰─────────╯╰─────────╯╰─────────╯╰─────────╯╰─────────╯╰─────────╯╰─────────╯
|
||||||
|
...
|
||||||
|
|
||||||
|
[n] = free dim = occupied
|
||||||
|
|
||||||
|
? Pick a key slot
|
||||||
|
> 5 (free)
|
||||||
|
6 (free)
|
||||||
|
7 (free)
|
||||||
|
...
|
||||||
|
|
||||||
|
? What should this key do?
|
||||||
|
> Module function (Slack, etc.)
|
||||||
|
Shell command
|
||||||
|
|
||||||
|
? Pick a module
|
||||||
|
> slack
|
||||||
|
|
||||||
|
? Pick a function from slack
|
||||||
|
> set_status
|
||||||
|
clear_status
|
||||||
|
set_presence
|
||||||
|
go_offline
|
||||||
|
snooze
|
||||||
|
end_snooze
|
||||||
|
|
||||||
|
? emoji
|
||||||
|
> :coffee:
|
||||||
|
|
||||||
|
? text
|
||||||
|
> Coffee break
|
||||||
|
|
||||||
|
? expiry
|
||||||
|
> 30m
|
||||||
|
|
||||||
|
? Pick an icon from ~/.config/streamdeck-go/icons
|
||||||
|
> coffee.png
|
||||||
|
|
||||||
|
Summary:
|
||||||
|
Key: 5
|
||||||
|
Icon: coffee.png
|
||||||
|
Module: slack
|
||||||
|
Function: set_status
|
||||||
|
Params:
|
||||||
|
emoji: :coffee:
|
||||||
|
text: Coffee break
|
||||||
|
expiry: 30m
|
||||||
|
|
||||||
|
? Write this key to config? Yes
|
||||||
|
✓ Key 5 added to config.
|
||||||
|
|
||||||
|
? Add another key? No
|
||||||
|
✓ Done — the daemon will auto-reload your config.
|
||||||
|
```
|
||||||
|
|
||||||
|
On a fresh install with no config files, the tool creates `config.yaml` and `modules.yaml` automatically.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### Supported icon formats
|
### Supported icon formats
|
||||||
|
|
||||||
| Format | Notes |
|
| Format | Notes |
|
||||||
|
|||||||
126
cmd/streamdeck-init/grid.go
Normal file
126
cmd/streamdeck-init/grid.go
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
// grid.go — Renders the 8x4 Stream Deck key grid in the terminal.
|
||||||
|
//
|
||||||
|
// Uses lipgloss for styling: free slots are green with [index], occupied slots
|
||||||
|
// are dimmed with a short label (function name, icon name, or command prefix).
|
||||||
|
//
|
||||||
|
// ## Layout
|
||||||
|
//
|
||||||
|
// The grid mirrors the physical Stream Deck XL layout:
|
||||||
|
//
|
||||||
|
// 0 1 2 3 4 5 6 7
|
||||||
|
// 8 9 10 11 12 13 14 15
|
||||||
|
// 16 17 18 19 20 21 22 23
|
||||||
|
// 24 25 26 27 28 29 30 31
|
||||||
|
//
|
||||||
|
// ## Expanding
|
||||||
|
//
|
||||||
|
// To support non-XL models (e.g. MK.2 with 15 keys in a 5x3 grid), make
|
||||||
|
// gridCols and gridRows parameters instead of constants, and derive them
|
||||||
|
// from the device config's product_id. The device model → key count mapping
|
||||||
|
// is in internal/device/streamdeck.go.
|
||||||
|
//
|
||||||
|
// To add richer labels (e.g. showing params or the icon filename alongside
|
||||||
|
// the function name), modify keyLabel(). Keep labels under 8 chars to fit
|
||||||
|
// the cell width.
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/WoodardDigital/streamdeck-go/internal/config"
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Grid dimensions — hardcoded for Stream Deck XL (8 columns × 4 rows = 32 keys).
|
||||||
|
const (
|
||||||
|
gridCols = 8
|
||||||
|
gridRows = 4
|
||||||
|
gridKeys = gridCols * gridRows
|
||||||
|
)
|
||||||
|
|
||||||
|
// Styles for the grid cells. Color numbers are ANSI 256-color palette indices:
|
||||||
|
//
|
||||||
|
// 10 = bright green (free slots — draws the eye)
|
||||||
|
// 8 = dark gray (occupied slots — visually recedes)
|
||||||
|
// 12 = bright blue (title)
|
||||||
|
var (
|
||||||
|
freeStyle = lipgloss.NewStyle().Width(9).Height(2).Align(lipgloss.Center, lipgloss.Center).Bold(true).Foreground(lipgloss.Color("10"))
|
||||||
|
occupiedStyle = lipgloss.NewStyle().Width(9).Height(2).Align(lipgloss.Center, lipgloss.Center).Foreground(lipgloss.Color("8"))
|
||||||
|
borderStyle = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(lipgloss.Color("8"))
|
||||||
|
titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12"))
|
||||||
|
)
|
||||||
|
|
||||||
|
// renderGrid builds a string containing the full 8x4 key grid with a legend.
|
||||||
|
// Free slots show [index] in green; occupied slots show a short label in gray.
|
||||||
|
// The grid is printed to stdout before the key-picking form.
|
||||||
|
func renderGrid(keys map[int]config.KeyConfig) string {
|
||||||
|
var b strings.Builder
|
||||||
|
|
||||||
|
b.WriteString(titleStyle.Render(" Stream Deck XL — 8×4 grid"))
|
||||||
|
b.WriteString("\n\n")
|
||||||
|
|
||||||
|
for row := 0; row < gridRows; row++ {
|
||||||
|
cells := make([]string, gridCols)
|
||||||
|
for col := 0; col < gridCols; col++ {
|
||||||
|
idx := row*gridCols + col
|
||||||
|
keyCfg, occupied := keys[idx]
|
||||||
|
|
||||||
|
if occupied {
|
||||||
|
label := keyLabel(keyCfg)
|
||||||
|
cells[col] = borderStyle.Render(occupiedStyle.Render(label))
|
||||||
|
} else {
|
||||||
|
cells[col] = borderStyle.Render(freeStyle.Render(fmt.Sprintf("[%d]", idx)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// JoinHorizontal places cells side by side, aligned at the top.
|
||||||
|
b.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, cells...))
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legend explaining the color coding.
|
||||||
|
b.WriteString("\n")
|
||||||
|
b.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("10")).Render("[n]"))
|
||||||
|
b.WriteString(" = free ")
|
||||||
|
b.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Render("dim"))
|
||||||
|
b.WriteString(" = occupied\n")
|
||||||
|
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// keyLabel returns a short display label for an occupied key (max 8 chars).
|
||||||
|
//
|
||||||
|
// Priority order:
|
||||||
|
// 1. Module function name (if module-based key)
|
||||||
|
// 2. Icon filename without extension (if static key)
|
||||||
|
// 3. Command prefix (fallback)
|
||||||
|
// 4. "???" (shouldn't happen with valid config)
|
||||||
|
func keyLabel(k config.KeyConfig) string {
|
||||||
|
if k.Module != "" {
|
||||||
|
label := k.Function
|
||||||
|
if len(label) > 8 {
|
||||||
|
label = label[:8]
|
||||||
|
}
|
||||||
|
return label
|
||||||
|
}
|
||||||
|
if k.Icon != "" {
|
||||||
|
label := k.Icon
|
||||||
|
// Strip file extension for brevity (e.g. "firefox.png" → "firefox").
|
||||||
|
if dot := strings.LastIndex(label, "."); dot > 0 {
|
||||||
|
label = label[:dot]
|
||||||
|
}
|
||||||
|
if len(label) > 8 {
|
||||||
|
label = label[:8]
|
||||||
|
}
|
||||||
|
return label
|
||||||
|
}
|
||||||
|
if k.Command != "" {
|
||||||
|
label := k.Command
|
||||||
|
if len(label) > 8 {
|
||||||
|
label = label[:8]
|
||||||
|
}
|
||||||
|
return label
|
||||||
|
}
|
||||||
|
return "???"
|
||||||
|
}
|
||||||
518
cmd/streamdeck-init/main.go
Normal file
518
cmd/streamdeck-init/main.go
Normal file
@@ -0,0 +1,518 @@
|
|||||||
|
// streamdeck-init — Interactive TUI for configuring Stream Deck keys.
|
||||||
|
//
|
||||||
|
// This is a config builder, not an installer. It reads and appends to
|
||||||
|
// ~/.config/streamdeck-go/config.yaml (or a custom path via -config).
|
||||||
|
// It does NOT touch services, binaries, udev rules, or dependencies.
|
||||||
|
//
|
||||||
|
// ## How it works
|
||||||
|
//
|
||||||
|
// 1. Bootstrap: ensures config.yaml and modules.yaml exist (creates from
|
||||||
|
// defaults/embedded example if missing).
|
||||||
|
// 2. Loads both files using the same internal/config and internal/modules
|
||||||
|
// packages the daemon uses.
|
||||||
|
// 3. Renders an 8x4 key grid showing which slots are free vs occupied.
|
||||||
|
// 4. Walks the user through a series of huh forms:
|
||||||
|
// - Pick a key slot (0-31)
|
||||||
|
// - Choose action type: module function or raw shell command
|
||||||
|
// - If module: pick module → function → customize params (with defaults)
|
||||||
|
// - If command: type a shell command
|
||||||
|
// - Pick an icon from icons_dir (or type a custom filename)
|
||||||
|
// - Confirm and append to config.yaml
|
||||||
|
// 5. Loops: asks "Add another key?" after each addition.
|
||||||
|
//
|
||||||
|
// ## Expanding this tool
|
||||||
|
//
|
||||||
|
// To add new TUI steps (e.g. toggle key setup with poll config, or a
|
||||||
|
// "delete key" flow), add a new function following the pattern of
|
||||||
|
// configureModuleKey/configureCommandKey: build huh forms, collect values
|
||||||
|
// into a keyEntry, return it for the confirm/append step.
|
||||||
|
//
|
||||||
|
// To support new key types in the YAML output, update keyEntry and
|
||||||
|
// renderKeySnippet() in yaml.go.
|
||||||
|
//
|
||||||
|
// The grid rendering is in grid.go — it's hardcoded to 8x4 (XL). To
|
||||||
|
// support other models, make gridCols/gridRows dynamic based on the
|
||||||
|
// device config's product_id.
|
||||||
|
//
|
||||||
|
// ## Dependencies
|
||||||
|
//
|
||||||
|
// - github.com/charmbracelet/huh — form/prompt library (select, input, confirm)
|
||||||
|
// - github.com/charmbracelet/lipgloss — terminal styling (grid rendering, summary output)
|
||||||
|
// - internal/config — shared config loader and DefaultConfigPath()
|
||||||
|
// - internal/modules — shared module registry loader
|
||||||
|
// - internal/defaults — embedded modules.example.yaml for bootstrapping
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/WoodardDigital/streamdeck-go/internal/config"
|
||||||
|
"github.com/WoodardDigital/streamdeck-go/internal/defaults"
|
||||||
|
"github.com/WoodardDigital/streamdeck-go/internal/modules"
|
||||||
|
"github.com/charmbracelet/huh"
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
cfgPath := flag.String("config", config.DefaultConfigPath(), "path to config file")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
// Bootstrap: ensure config dir, config.yaml, and modules.yaml exist.
|
||||||
|
// On a fresh install this creates everything from scratch so the user
|
||||||
|
// doesn't need to manually create files before running the TUI.
|
||||||
|
if err := bootstrap(*cfgPath); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "setup error: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main loop — each iteration configures one key, then asks to continue.
|
||||||
|
for {
|
||||||
|
if err := runOnce(*cfgPath); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
var again bool
|
||||||
|
form := huh.NewForm(
|
||||||
|
huh.NewGroup(
|
||||||
|
huh.NewConfirm().
|
||||||
|
Title("Add another key?").
|
||||||
|
Value(&again),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if err := form.Run(); err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if !again {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println(lipgloss.NewStyle().Foreground(lipgloss.Color("10")).Bold(true).Render("✓") +
|
||||||
|
" Done — the daemon will auto-reload your config.")
|
||||||
|
}
|
||||||
|
|
||||||
|
// bootstrap ensures the config directory, config.yaml, and modules.yaml exist.
|
||||||
|
//
|
||||||
|
// Creates:
|
||||||
|
// - ~/.config/streamdeck-go/ (config dir)
|
||||||
|
// - ~/.config/streamdeck-go/icons/ (icon storage)
|
||||||
|
// - ~/.config/streamdeck-go/config.yaml (from inline default if missing)
|
||||||
|
// - ~/.config/streamdeck-go/modules.yaml (from embedded example if missing)
|
||||||
|
//
|
||||||
|
// The embedded modules.example.yaml comes from internal/defaults, which uses
|
||||||
|
// //go:embed so the init binary works standalone without the repo present.
|
||||||
|
func bootstrap(cfgPath string) error {
|
||||||
|
dir := filepath.Dir(cfgPath)
|
||||||
|
iconsDir := filepath.Join(dir, "icons")
|
||||||
|
if err := os.MkdirAll(iconsDir, 0755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a minimal config.yaml if one doesn't exist yet.
|
||||||
|
// Uses "keys: {}" (empty map) which appendKeyToConfig handles specially —
|
||||||
|
// it replaces the {} with actual key entries on first append.
|
||||||
|
if _, err := os.Stat(cfgPath); os.IsNotExist(err) {
|
||||||
|
defaultCfg := fmt.Sprintf(`# streamdeck-go configuration
|
||||||
|
icons_dir: %s
|
||||||
|
brightness: 70
|
||||||
|
|
||||||
|
device:
|
||||||
|
vendor_id: 0x0fd9
|
||||||
|
product_id: 0x00ba
|
||||||
|
|
||||||
|
keys: {}
|
||||||
|
`, iconsDir)
|
||||||
|
if err := os.WriteFile(cfgPath, []byte(defaultCfg), 0644); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Printf("Created %s\n", cfgPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy the embedded modules.example.yaml so module functions are available
|
||||||
|
// immediately. Users can edit this file to add custom modules later.
|
||||||
|
modPath := config.ModulesPath(cfgPath)
|
||||||
|
if _, err := os.Stat(modPath); os.IsNotExist(err) {
|
||||||
|
if err := os.WriteFile(modPath, defaults.ModulesExampleYAML, 0644); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Printf("Created %s\n", modPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// runOnce performs one complete key configuration cycle:
|
||||||
|
//
|
||||||
|
// load config → show grid → pick slot → pick action → pick icon → confirm → write
|
||||||
|
//
|
||||||
|
// Config is re-loaded at the start of each cycle so that keys added in the
|
||||||
|
// previous iteration show up as occupied in the grid.
|
||||||
|
func runOnce(cfgPath string) error {
|
||||||
|
// Re-read config each iteration to pick up keys added in previous cycles.
|
||||||
|
cfg, err := config.Load(cfgPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("load config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load the module registry to enumerate available modules/functions.
|
||||||
|
reg, err := modules.LoadRegistry(config.ModulesPath(cfgPath))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("load modules: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show the 8x4 key grid so the user can see which slots are taken.
|
||||||
|
fmt.Print(renderGrid(cfg.Keys))
|
||||||
|
|
||||||
|
// Step 1: Pick a key slot (0-31).
|
||||||
|
keyIdx, err := pickKeySlot(cfg.Keys)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Choose between module function or raw shell command.
|
||||||
|
actionType, err := pickActionType()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Configure the key based on action type.
|
||||||
|
var entry keyEntry
|
||||||
|
entry.Index = keyIdx
|
||||||
|
|
||||||
|
if actionType == "module" {
|
||||||
|
// Module path: module → function → params (with defaults pre-filled).
|
||||||
|
entry, err = configureModuleKey(keyIdx, reg)
|
||||||
|
} else {
|
||||||
|
// Command path: just a shell command string.
|
||||||
|
entry, err = configureCommandKey(keyIdx)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 4: Pick an icon file.
|
||||||
|
icon, err := pickIcon(cfg.IconsDir)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
entry.Icon = icon
|
||||||
|
|
||||||
|
// Step 5: Show summary and confirm before writing.
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println(lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12")).Render(" Summary:"))
|
||||||
|
fmt.Printf(" Key: %d\n", entry.Index)
|
||||||
|
fmt.Printf(" Icon: %s\n", entry.Icon)
|
||||||
|
if entry.Module != "" {
|
||||||
|
fmt.Printf(" Module: %s\n", entry.Module)
|
||||||
|
fmt.Printf(" Function: %s\n", entry.Function)
|
||||||
|
if len(entry.Params) > 0 {
|
||||||
|
fmt.Println(" Params:")
|
||||||
|
for k, v := range entry.Params {
|
||||||
|
fmt.Printf(" %s: %s\n", k, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fmt.Printf(" Command: %s\n", entry.Command)
|
||||||
|
}
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
var confirm bool
|
||||||
|
form := huh.NewForm(
|
||||||
|
huh.NewGroup(
|
||||||
|
huh.NewConfirm().
|
||||||
|
Title("Write this key to config?").
|
||||||
|
Value(&confirm),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if err := form.Run(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !confirm {
|
||||||
|
fmt.Println(" Skipped.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 6: Append the YAML snippet to config.yaml.
|
||||||
|
// This uses raw text append (not marshal/unmarshal) to preserve comments.
|
||||||
|
if err := appendKeyToConfig(cfgPath, entry); err != nil {
|
||||||
|
return fmt.Errorf("write config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println(lipgloss.NewStyle().Foreground(lipgloss.Color("10")).Bold(true).Render(" ✓") +
|
||||||
|
fmt.Sprintf(" Key %d added to config.", entry.Index))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// pickKeySlot presents a select list of all 32 key slots, showing which are
|
||||||
|
// occupied and what they're mapped to. Returns the selected key index.
|
||||||
|
//
|
||||||
|
// FUTURE: filter to show only free keys, or add a "overwrite?" confirmation
|
||||||
|
// when an occupied slot is picked.
|
||||||
|
func pickKeySlot(keys map[int]config.KeyConfig) (int, error) {
|
||||||
|
options := make([]huh.Option[int], gridKeys)
|
||||||
|
for i := 0; i < gridKeys; i++ {
|
||||||
|
label := fmt.Sprintf("%-3d", i)
|
||||||
|
if k, ok := keys[i]; ok {
|
||||||
|
label += fmt.Sprintf(" (occupied — %s)", keyLabel(k))
|
||||||
|
} else {
|
||||||
|
label += " (free)"
|
||||||
|
}
|
||||||
|
options[i] = huh.NewOption(label, i)
|
||||||
|
}
|
||||||
|
|
||||||
|
var keyIdx int
|
||||||
|
form := huh.NewForm(
|
||||||
|
huh.NewGroup(
|
||||||
|
huh.NewSelect[int]().
|
||||||
|
Title("Pick a key slot").
|
||||||
|
Options(options...).
|
||||||
|
Value(&keyIdx),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if err := form.Run(); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return keyIdx, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// pickActionType asks whether to configure a module function or a raw shell command.
|
||||||
|
//
|
||||||
|
// FUTURE: add "Toggle key (with poll)" as a third option, which would walk
|
||||||
|
// through icon_true/icon_false and poll config setup.
|
||||||
|
func pickActionType() (string, error) {
|
||||||
|
var actionType string
|
||||||
|
form := huh.NewForm(
|
||||||
|
huh.NewGroup(
|
||||||
|
huh.NewSelect[string]().
|
||||||
|
Title("What should this key do?").
|
||||||
|
Options(
|
||||||
|
huh.NewOption("Module function (Slack, etc.)", "module"),
|
||||||
|
huh.NewOption("Shell command", "command"),
|
||||||
|
).
|
||||||
|
Value(&actionType),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if err := form.Run(); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return actionType, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// configureModuleKey walks the user through: module → function → params.
|
||||||
|
//
|
||||||
|
// Modules and functions are enumerated dynamically from the loaded registry,
|
||||||
|
// so any module added to modules.yaml automatically appears in the TUI.
|
||||||
|
//
|
||||||
|
// For each param defined in the function's FunctionDef.Params, an input field
|
||||||
|
// is shown pre-filled with the default value. The user can accept defaults or
|
||||||
|
// override per-key. Functions with no params (e.g. clear_status) skip this step.
|
||||||
|
//
|
||||||
|
// FUTURE: show a description/help text for each function (would require adding
|
||||||
|
// a "description" field to FunctionDef in internal/modules/modules.go).
|
||||||
|
func configureModuleKey(keyIdx int, reg *modules.Registry) (keyEntry, error) {
|
||||||
|
entry := keyEntry{Index: keyIdx}
|
||||||
|
|
||||||
|
if reg == nil || len(reg.Modules) == 0 {
|
||||||
|
return entry, fmt.Errorf("no modules defined in modules.yaml")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pick module — enumerate all top-level keys from modules.yaml.
|
||||||
|
modNames := make([]string, 0, len(reg.Modules))
|
||||||
|
for name := range reg.Modules {
|
||||||
|
modNames = append(modNames, name)
|
||||||
|
}
|
||||||
|
sort.Strings(modNames)
|
||||||
|
|
||||||
|
var modName string
|
||||||
|
modOpts := make([]huh.Option[string], len(modNames))
|
||||||
|
for i, name := range modNames {
|
||||||
|
modOpts[i] = huh.NewOption(name, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
form := huh.NewForm(
|
||||||
|
huh.NewGroup(
|
||||||
|
huh.NewSelect[string]().
|
||||||
|
Title("Pick a module").
|
||||||
|
Options(modOpts...).
|
||||||
|
Value(&modName),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if err := form.Run(); err != nil {
|
||||||
|
return entry, err
|
||||||
|
}
|
||||||
|
entry.Module = modName
|
||||||
|
|
||||||
|
// Pick function — enumerate all functions within the chosen module.
|
||||||
|
mod := reg.Modules[modName]
|
||||||
|
fnNames := make([]string, 0, len(mod))
|
||||||
|
for name := range mod {
|
||||||
|
fnNames = append(fnNames, name)
|
||||||
|
}
|
||||||
|
sort.Strings(fnNames)
|
||||||
|
|
||||||
|
var fnName string
|
||||||
|
fnOpts := make([]huh.Option[string], len(fnNames))
|
||||||
|
for i, name := range fnNames {
|
||||||
|
fnOpts[i] = huh.NewOption(name, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
form = huh.NewForm(
|
||||||
|
huh.NewGroup(
|
||||||
|
huh.NewSelect[string]().
|
||||||
|
Title(fmt.Sprintf("Pick a function from %s", modName)).
|
||||||
|
Options(fnOpts...).
|
||||||
|
Value(&fnName),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if err := form.Run(); err != nil {
|
||||||
|
return entry, err
|
||||||
|
}
|
||||||
|
entry.Function = fnName
|
||||||
|
|
||||||
|
// Customize params — one input per param, pre-filled with the module default.
|
||||||
|
// paramPtrs is a []string (not []*string) so that ¶mPtrs[i] gives a stable
|
||||||
|
// pointer to each slice element — huh.NewInput().Value() needs a *string.
|
||||||
|
fn := mod[fnName]
|
||||||
|
if len(fn.Params) > 0 {
|
||||||
|
paramKeys := make([]string, 0, len(fn.Params))
|
||||||
|
for k := range fn.Params {
|
||||||
|
paramKeys = append(paramKeys, k)
|
||||||
|
}
|
||||||
|
sort.Strings(paramKeys)
|
||||||
|
|
||||||
|
paramPtrs := make([]string, len(paramKeys))
|
||||||
|
paramFields := make([]huh.Field, len(paramKeys))
|
||||||
|
for i, k := range paramKeys {
|
||||||
|
paramPtrs[i] = fn.Params[k] // pre-fill with default
|
||||||
|
paramFields[i] = huh.NewInput().
|
||||||
|
Title(k).
|
||||||
|
Value(¶mPtrs[i]).
|
||||||
|
Placeholder(fn.Params[k])
|
||||||
|
}
|
||||||
|
|
||||||
|
form = huh.NewForm(
|
||||||
|
huh.NewGroup(paramFields...),
|
||||||
|
)
|
||||||
|
if err := form.Run(); err != nil {
|
||||||
|
return entry, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect the (possibly edited) values back into the entry.
|
||||||
|
entry.Params = make(map[string]string, len(paramKeys))
|
||||||
|
for i, k := range paramKeys {
|
||||||
|
entry.Params[k] = paramPtrs[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return entry, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// configureCommandKey asks for a raw shell command string.
|
||||||
|
// This is the non-module path — the command is written directly to config.yaml.
|
||||||
|
func configureCommandKey(keyIdx int) (keyEntry, error) {
|
||||||
|
entry := keyEntry{Index: keyIdx}
|
||||||
|
|
||||||
|
var cmd string
|
||||||
|
form := huh.NewForm(
|
||||||
|
huh.NewGroup(
|
||||||
|
huh.NewInput().
|
||||||
|
Title("Shell command").
|
||||||
|
Value(&cmd).
|
||||||
|
Placeholder("e.g. open -a Firefox"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if err := form.Run(); err != nil {
|
||||||
|
return entry, err
|
||||||
|
}
|
||||||
|
entry.Command = cmd
|
||||||
|
return entry, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// pickIcon lets the user select an icon file.
|
||||||
|
//
|
||||||
|
// If icons_dir has image files, shows a select list of them plus a "type custom"
|
||||||
|
// option. If the directory is empty, falls back to a plain text input.
|
||||||
|
//
|
||||||
|
// Supported extensions: .png, .jpg, .jpeg, .gif, .svg
|
||||||
|
//
|
||||||
|
// FUTURE: show icon previews (would require a terminal image protocol like
|
||||||
|
// sixel or kitty graphics). For now, filenames are enough.
|
||||||
|
func pickIcon(iconsDir string) (string, error) {
|
||||||
|
// Scan icons_dir for image files.
|
||||||
|
entries, err := os.ReadDir(iconsDir)
|
||||||
|
if err != nil && !os.IsNotExist(err) {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
var iconFiles []string
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ext := strings.ToLower(filepath.Ext(e.Name()))
|
||||||
|
switch ext {
|
||||||
|
case ".png", ".jpg", ".jpeg", ".gif", ".svg":
|
||||||
|
iconFiles = append(iconFiles, e.Name())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No icons in directory — just ask for a filename.
|
||||||
|
if len(iconFiles) == 0 {
|
||||||
|
var icon string
|
||||||
|
form := huh.NewForm(
|
||||||
|
huh.NewGroup(
|
||||||
|
huh.NewInput().
|
||||||
|
Title("Icon filename").
|
||||||
|
Value(&icon).
|
||||||
|
Placeholder("e.g. myicon.png"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if err := form.Run(); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return icon, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show available icons as a select list with a "custom" escape hatch.
|
||||||
|
sort.Strings(iconFiles)
|
||||||
|
options := make([]huh.Option[string], 0, len(iconFiles)+1)
|
||||||
|
options = append(options, huh.NewOption("[ Type a custom filename ]", "__custom__"))
|
||||||
|
for _, f := range iconFiles {
|
||||||
|
options = append(options, huh.NewOption(f, f))
|
||||||
|
}
|
||||||
|
|
||||||
|
var icon string
|
||||||
|
form := huh.NewForm(
|
||||||
|
huh.NewGroup(
|
||||||
|
huh.NewSelect[string]().
|
||||||
|
Title(fmt.Sprintf("Pick an icon from %s", iconsDir)).
|
||||||
|
Options(options...).
|
||||||
|
Value(&icon),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if err := form.Run(); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if icon == "__custom__" {
|
||||||
|
form = huh.NewForm(
|
||||||
|
huh.NewGroup(
|
||||||
|
huh.NewInput().
|
||||||
|
Title("Icon filename").
|
||||||
|
Value(&icon).
|
||||||
|
Placeholder("e.g. myicon.png"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if err := form.Run(); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return icon, nil
|
||||||
|
}
|
||||||
148
cmd/streamdeck-init/yaml.go
Normal file
148
cmd/streamdeck-init/yaml.go
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
// yaml.go — Appends key entries to config.yaml without destroying comments.
|
||||||
|
//
|
||||||
|
// ## Why raw text append instead of yaml.Marshal?
|
||||||
|
//
|
||||||
|
// yaml.v3's Marshal destroys comments, reorders map keys, and normalises
|
||||||
|
// formatting. Users keep the default config's comments (key layout diagram,
|
||||||
|
// examples, etc.), so we must preserve them. The approach:
|
||||||
|
//
|
||||||
|
// - Parse config.yaml with config.Load() for DATA (which keys exist, icons_dir, etc.)
|
||||||
|
// - Write new keys by appending raw YAML text to the end of the file
|
||||||
|
//
|
||||||
|
// This works because keys: is always the last top-level block in config.yaml,
|
||||||
|
// and YAML maps are unordered, so appending a new 2-space-indented key entry
|
||||||
|
// to the end of the file is valid.
|
||||||
|
//
|
||||||
|
// ## Three cases handled by appendKeyToConfig
|
||||||
|
//
|
||||||
|
// Case A: "keys: {}" — the empty map literal from the default config generator.
|
||||||
|
//
|
||||||
|
// Replace {} with a newline, then insert the key block. This is the most
|
||||||
|
// common case on a fresh install.
|
||||||
|
//
|
||||||
|
// Case B: "keys:" with existing children — the normal case after keys exist.
|
||||||
|
//
|
||||||
|
// Append the new key block to the end of the file. It lands inside the
|
||||||
|
// keys: block because it's indented with 2 spaces.
|
||||||
|
//
|
||||||
|
// Case C: no "keys:" block at all — a malformed or minimal config.
|
||||||
|
//
|
||||||
|
// Append "keys:\n" then the key block.
|
||||||
|
//
|
||||||
|
// ## Expanding
|
||||||
|
//
|
||||||
|
// To support toggle keys (icon_true/icon_false + poll block), add those fields
|
||||||
|
// to keyEntry and extend renderKeySnippet() to emit the extra YAML lines.
|
||||||
|
//
|
||||||
|
// To support deleting or editing existing keys, you'd need a different strategy
|
||||||
|
// (e.g. yaml.v3 node-level manipulation). That's not in scope for the current
|
||||||
|
// append-only tool.
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// keyEntry holds the data collected from the TUI forms, ready to be rendered
|
||||||
|
// as a YAML snippet and appended to config.yaml.
|
||||||
|
//
|
||||||
|
// Two mutually exclusive modes:
|
||||||
|
// - Module key: Module + Function + Params are set, Command is empty
|
||||||
|
// - Command key: Command is set, Module/Function/Params are empty
|
||||||
|
type keyEntry struct {
|
||||||
|
Index int // key slot (0-31)
|
||||||
|
Icon string // icon filename relative to icons_dir
|
||||||
|
Module string // module name from modules.yaml (e.g. "slack")
|
||||||
|
Function string // function name within the module (e.g. "set_status")
|
||||||
|
Params map[string]string // param overrides (merged with module defaults at runtime)
|
||||||
|
Command string // raw shell command (non-module keys only)
|
||||||
|
}
|
||||||
|
|
||||||
|
// appendKeyToConfig reads config.yaml, appends a key entry as raw YAML text,
|
||||||
|
// and writes the file back. Preserves all existing content including comments.
|
||||||
|
func appendKeyToConfig(path string, entry keyEntry) error {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
content := string(data)
|
||||||
|
snippet := renderKeySnippet(entry)
|
||||||
|
|
||||||
|
// Case A: keys: {} — empty map literal from the default config generator.
|
||||||
|
// Replace the {} with a newline so the snippet becomes the first child.
|
||||||
|
emptyKeys := regexp.MustCompile(`(?m)^keys:\s*\{\}\s*$`)
|
||||||
|
if emptyKeys.MatchString(content) {
|
||||||
|
content = emptyKeys.ReplaceAllString(content, "keys:\n"+snippet)
|
||||||
|
return os.WriteFile(path, []byte(content), 0644)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Case B: keys: exists with children — append snippet to end of file.
|
||||||
|
// The 2-space indent on the snippet places it inside the keys: block.
|
||||||
|
hasKeys := regexp.MustCompile(`(?m)^keys:\s*$`)
|
||||||
|
if hasKeys.MatchString(content) {
|
||||||
|
if !strings.HasSuffix(content, "\n") {
|
||||||
|
content += "\n"
|
||||||
|
}
|
||||||
|
content += snippet
|
||||||
|
return os.WriteFile(path, []byte(content), 0644)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Case C: no keys: block at all — append both the block header and snippet.
|
||||||
|
if !strings.HasSuffix(content, "\n") {
|
||||||
|
content += "\n"
|
||||||
|
}
|
||||||
|
content += "\nkeys:\n" + snippet
|
||||||
|
return os.WriteFile(path, []byte(content), 0644)
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderKeySnippet formats a keyEntry as an indented YAML block (2-space indent
|
||||||
|
// for the key index, 4-space for fields, 6-space for params).
|
||||||
|
//
|
||||||
|
// Module key output:
|
||||||
|
//
|
||||||
|
// 3:
|
||||||
|
// icon: status.png
|
||||||
|
// module: slack
|
||||||
|
// function: set_status
|
||||||
|
// params:
|
||||||
|
// emoji: ":brb:"
|
||||||
|
// text: "BRB"
|
||||||
|
//
|
||||||
|
// Command key output:
|
||||||
|
//
|
||||||
|
// 3:
|
||||||
|
// icon: firefox.png
|
||||||
|
// command: "open -a Firefox"
|
||||||
|
func renderKeySnippet(e keyEntry) string {
|
||||||
|
var b strings.Builder
|
||||||
|
|
||||||
|
fmt.Fprintf(&b, " %d:\n", e.Index)
|
||||||
|
fmt.Fprintf(&b, " icon: %s\n", e.Icon)
|
||||||
|
|
||||||
|
if e.Module != "" {
|
||||||
|
fmt.Fprintf(&b, " module: %s\n", e.Module)
|
||||||
|
fmt.Fprintf(&b, " function: %s\n", e.Function)
|
||||||
|
if len(e.Params) > 0 {
|
||||||
|
b.WriteString(" params:\n")
|
||||||
|
// Sort param keys for deterministic, diffable output.
|
||||||
|
keys := make([]string, 0, len(e.Params))
|
||||||
|
for k := range e.Params {
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
for _, k := range keys {
|
||||||
|
fmt.Fprintf(&b, " %s: \"%s\"\n", k, e.Params[k])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fmt.Fprintf(&b, " command: \"%s\"\n", e.Command)
|
||||||
|
}
|
||||||
|
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
@@ -595,12 +595,7 @@ func loadPrivilegedCommands(path string) (map[string]string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func defaultConfigPath() string {
|
func defaultConfigPath() string {
|
||||||
base := os.Getenv("XDG_CONFIG_HOME")
|
return config.DefaultConfigPath()
|
||||||
if base == "" {
|
|
||||||
home, _ := os.UserHomeDir()
|
|
||||||
base = filepath.Join(home, ".config")
|
|
||||||
}
|
|
||||||
return filepath.Join(base, "streamdeck-go", "config.yaml")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func ensureConfigDir(cfgPath string) error {
|
func ensureConfigDir(cfgPath string) error {
|
||||||
|
|||||||
27
go.mod
27
go.mod
@@ -10,9 +10,34 @@ require (
|
|||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/atotto/clipboard v0.1.4 // indirect
|
||||||
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||||
|
github.com/catppuccin/go v0.3.0 // indirect
|
||||||
|
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect
|
||||||
|
github.com/charmbracelet/bubbletea v1.3.6 // indirect
|
||||||
|
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
|
||||||
|
github.com/charmbracelet/huh v1.0.0 // indirect
|
||||||
|
github.com/charmbracelet/lipgloss v1.1.0 // indirect
|
||||||
|
github.com/charmbracelet/x/ansi v0.9.3 // indirect
|
||||||
|
github.com/charmbracelet/x/cellbuf v0.0.13 // indirect
|
||||||
|
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
|
||||||
|
github.com/charmbracelet/x/term v0.2.1 // indirect
|
||||||
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||||
|
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||||
|
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||||
|
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
|
||||||
|
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||||
|
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||||
|
github.com/muesli/termenv v0.16.0 // indirect
|
||||||
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c // indirect
|
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c // indirect
|
||||||
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef // indirect
|
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef // indirect
|
||||||
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||||
golang.org/x/net v0.0.0-20211118161319-6a13c67c3ce4 // indirect
|
golang.org/x/net v0.0.0-20211118161319-6a13c67c3ce4 // indirect
|
||||||
golang.org/x/sys v0.13.0 // indirect
|
golang.org/x/sync v0.20.0 // indirect
|
||||||
|
golang.org/x/sys v0.33.0 // indirect
|
||||||
golang.org/x/text v0.35.0 // indirect
|
golang.org/x/text v0.35.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
55
go.sum
55
go.sum
@@ -1,17 +1,72 @@
|
|||||||
|
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||||
|
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
||||||
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||||
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||||
|
github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY=
|
||||||
|
github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
|
||||||
|
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws=
|
||||||
|
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7/go.mod h1:ISC1gtLcVilLOf23wvTfoQuYbW2q0JevFxPfUzZ9Ybw=
|
||||||
|
github.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGsK6et5taU=
|
||||||
|
github.com/charmbracelet/bubbletea v1.3.6/go.mod h1:oQD9VCRQFF8KplacJLo28/jofOI2ToOfGYeFgBBxHOc=
|
||||||
|
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
|
||||||
|
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
|
||||||
|
github.com/charmbracelet/huh v1.0.0 h1:wOnedH8G4qzJbmhftTqrpppyqHakl/zbbNdXIWJyIxw=
|
||||||
|
github.com/charmbracelet/huh v1.0.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4=
|
||||||
|
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
|
||||||
|
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
|
||||||
|
github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0=
|
||||||
|
github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
|
||||||
|
github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k=
|
||||||
|
github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
|
||||||
|
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4=
|
||||||
|
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ=
|
||||||
|
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
|
||||||
|
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
|
||||||
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||||
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||||
|
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
||||||
|
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
||||||
|
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
||||||
|
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||||
|
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||||
|
github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
|
||||||
|
github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
|
||||||
|
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
||||||
|
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
|
||||||
|
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||||
|
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||||
|
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||||
|
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||||
|
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||||
|
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||||
|
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||||
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiYWEedbTT0qnSxrCjsVbb7yKY1KE=
|
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiYWEedbTT0qnSxrCjsVbb7yKY1KE=
|
||||||
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c/go.mod h1:cNQ3dwVJtS5Hmnjxy6AgTPd0Inb3pW05ftPSX7NZO7Q=
|
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c/go.mod h1:cNQ3dwVJtS5Hmnjxy6AgTPd0Inb3pW05ftPSX7NZO7Q=
|
||||||
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef h1:Ch6Q+AZUxDBCVqdkI8FSpFyZDtCVBc2VmejdNrm5rRQ=
|
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef h1:Ch6Q+AZUxDBCVqdkI8FSpFyZDtCVBc2VmejdNrm5rRQ=
|
||||||
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef/go.mod h1:nXTWP6+gD5+LUJ8krVhhoeHjvHTutPxMYl5SvkcnJNE=
|
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef/go.mod h1:nXTWP6+gD5+LUJ8krVhhoeHjvHTutPxMYl5SvkcnJNE=
|
||||||
github.com/sstallion/go-hid v0.15.0 h1:WERW/VW3Us6N73V2qa7HjdqWQvwHd0CoRDOP/N707/w=
|
github.com/sstallion/go-hid v0.15.0 h1:WERW/VW3Us6N73V2qa7HjdqWQvwHd0CoRDOP/N707/w=
|
||||||
github.com/sstallion/go-hid v0.15.0/go.mod h1:fPKp4rqx0xuoTV94gwKojsPG++KNKhxuU88goGuGM7I=
|
github.com/sstallion/go-hid v0.15.0/go.mod h1:fPKp4rqx0xuoTV94gwKojsPG++KNKhxuU88goGuGM7I=
|
||||||
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||||
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||||
golang.org/x/image v0.37.0 h1:ZiRjArKI8GwxZOoEtUfhrBtaCN+4b/7709dlT6SSnQA=
|
golang.org/x/image v0.37.0 h1:ZiRjArKI8GwxZOoEtUfhrBtaCN+4b/7709dlT6SSnQA=
|
||||||
golang.org/x/image v0.37.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY=
|
golang.org/x/image v0.37.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY=
|
||||||
golang.org/x/net v0.0.0-20211118161319-6a13c67c3ce4 h1:DZshvxDdVoeKIbudAdFEKi+f70l51luSy/7b76ibTY0=
|
golang.org/x/net v0.0.0-20211118161319-6a13c67c3ce4 h1:DZshvxDdVoeKIbudAdFEKi+f70l51luSy/7b76ibTY0=
|
||||||
golang.org/x/net v0.0.0-20211118161319-6a13c67c3ce4/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
golang.org/x/net v0.0.0-20211118161319-6a13c67c3ce4/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||||
|
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||||
|
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
|
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE=
|
golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE=
|
||||||
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
||||||
|
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||||
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||||
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
|
|||||||
@@ -36,6 +36,16 @@ type KeyConfig struct {
|
|||||||
Params map[string]string `yaml:"params"`
|
Params map[string]string `yaml:"params"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DefaultConfigPath returns the default config file path, respecting XDG_CONFIG_HOME.
|
||||||
|
func DefaultConfigPath() string {
|
||||||
|
base := os.Getenv("XDG_CONFIG_HOME")
|
||||||
|
if base == "" {
|
||||||
|
home, _ := os.UserHomeDir()
|
||||||
|
base = filepath.Join(home, ".config")
|
||||||
|
}
|
||||||
|
return filepath.Join(base, "streamdeck-go", "config.yaml")
|
||||||
|
}
|
||||||
|
|
||||||
// ModulesPath returns the path to the modules.yaml file next to the given config file.
|
// ModulesPath returns the path to the modules.yaml file next to the given config file.
|
||||||
func ModulesPath(cfgPath string) string {
|
func ModulesPath(cfgPath string) string {
|
||||||
return filepath.Join(filepath.Dir(cfgPath), "modules.yaml")
|
return filepath.Join(filepath.Dir(cfgPath), "modules.yaml")
|
||||||
|
|||||||
6
internal/defaults/defaults.go
Normal file
6
internal/defaults/defaults.go
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
package defaults
|
||||||
|
|
||||||
|
import _ "embed"
|
||||||
|
|
||||||
|
//go:embed modules.example.yaml
|
||||||
|
var ModulesExampleYAML []byte
|
||||||
58
internal/defaults/modules.example.yaml
Normal file
58
internal/defaults/modules.example.yaml
Normal file
@@ -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"}}"
|
||||||
BIN
streamdeck-init
Executable file
BIN
streamdeck-init
Executable file
Binary file not shown.
Reference in New Issue
Block a user