Rebuild the interactive configurator on opentui behind a JSON bridge
The TUI is now a TypeScript/opentui app in tui/ rather than Bubble Tea.
opentui is a Zig core with TypeScript bindings and no Go bindings, so this half
of the tool can't live in the Go binary; it compiles with Bun into a sibling
executable (rtsp-streamer-tui) that `rtsp-streamer tui` execs.
Everything that isn't presentation stays in Go, reached over three JSON
commands. The configurator holds no credentials and never writes the config
itself:
config export the config, plus limits like max_tiles
config apply (stdin) merge cameras/layouts/active_layout, validate, save
atomically, reload the daemon
discover --json Protect discovery, writing nothing
Two properties of that split are deliberate:
- The controller password never crosses the bridge. It's json:"-" on the way
out, and apply only merges the three keys the TUI edits, so it can't be
clobbered on the way back in either.
- apply re-reads the file before merging, so an editor left open for an hour can
no longer overwrite a `views import`, a `layout set`, or a hand edit made in
the meantime.
Discovery is previewable as a result: `discover --json` writes nothing, the
merge happens in the TUI, and nothing reaches disk until you save. Only
--enable-rtsp has a side effect, and it's on the controller.
Config structs gain json tags mirroring their yaml ones so the config
round-trips through the bridge under the same key names it has on disk, and
maxGridDim moves to config.MaxGridDim so the CLI and both configurators enforce
one ceiling. The write path is byte-for-byte identical to `layout set`, checked
against a copy of a live config.
Visible change: the grid editor draws real bordered boxes, so a spanning tile is
one box instead of an origin cell plus "·" continuation marks, and the
header-offset arithmetic in mouse.go is gone — the framework hit-tests list
rows. Keybindings, the lipgloss palette and the screen flow are carried over
unchanged; S now saves from anywhere.
The Bubble Tea version stays as `tui --legacy`. It's compiled into the Go binary
and needs no Bun, and on a headless Pi the TUI is the only config UI there is,
so a fallback is worth its weight. The cost of the new one is size: ~120 MB
against ~13 MB, since Bun embeds its runtime and opentui's native library.
Tests: 67 bun tests drive the real (in-memory) opentui renderer, including mouse
click and drag, plus tsc --noEmit. `make test-tui` runs both, and
scripts/preview.ts dumps every screen as text without needing a terminal.
Three bugs found during the port are documented in tui/README.md, since none are
apparent from the code: overlapping cell borders render as ┌ where a lattice
needs ┬; a drag dies after the first resize if the tree is rebuilt, because the
renderer captures the press-target renderable; and a rebuilt box has no computed
layout until the next frame, so its screenX reads 0.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
257
cmd/rtsp-streamer/bridge.go
Normal file
257
cmd/rtsp-streamer/bridge.go
Normal file
@@ -0,0 +1,257 @@
|
||||
// JSON bridge between the Go core and the opentui configurator in tui/.
|
||||
//
|
||||
// The TypeScript TUI owns presentation and editing; every piece of domain
|
||||
// logic — the config schema, validation, atomic save, Protect discovery, the
|
||||
// daemon reload handshake — stays here. The bridge is deliberately three
|
||||
// commands with a stable JSON contract:
|
||||
//
|
||||
// config export → the current config, for the TUI to edit
|
||||
// config apply (stdin) → merge the edited fields back, validate, save, reload
|
||||
// discover --json → Protect discovery results, without touching disk
|
||||
//
|
||||
// Two properties are worth preserving if this contract ever changes:
|
||||
//
|
||||
// - The controller password never crosses the bridge. It is `json:"-"` on the
|
||||
// way out, and `apply` merges only the fields the TUI edits, so it cannot
|
||||
// be clobbered on the way back in either.
|
||||
// - `apply` re-reads the config from disk before merging. A TUI session left
|
||||
// open for an hour can no longer overwrite a `views import`, a
|
||||
// `layout set`, or a hand edit made in the meantime — it only replaces the
|
||||
// keys it actually owns.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/lwoodard/rtsp-streamer/internal/config"
|
||||
"github.com/lwoodard/rtsp-streamer/internal/ipc"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// exportDoc is what `config export` emits: the config plus the few constants
|
||||
// the TUI would otherwise have to hardcode and keep in sync with Go.
|
||||
type exportDoc struct {
|
||||
ConfigPath string `json:"config_path"`
|
||||
MaxTiles int `json:"max_tiles"`
|
||||
MaxGridDim int `json:"max_grid_dim"`
|
||||
Qualities []string `json:"qualities"`
|
||||
DaemonRunning bool `json:"daemon_running"`
|
||||
Config *config.Config `json:"config"`
|
||||
}
|
||||
|
||||
// applyDoc is the subset of the config the TUI is allowed to write. Pointer
|
||||
// fields distinguish "absent" from "explicitly empty", so a payload that omits
|
||||
// a key leaves the on-disk value alone rather than wiping it.
|
||||
type applyDoc struct {
|
||||
Cameras *[]config.Camera `json:"cameras"`
|
||||
Layouts *[]config.Layout `json:"layouts"`
|
||||
ActiveLayout *string `json:"active_layout"`
|
||||
}
|
||||
|
||||
// applyResult reports the outcome of a save. Reloaded is best-effort: a
|
||||
// missing daemon is normal (nothing is running yet) and not an error.
|
||||
type applyResult struct {
|
||||
OK bool `json:"ok"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Path string `json:"path,omitempty"`
|
||||
Reloaded bool `json:"reloaded"`
|
||||
ReloadError string `json:"reload_error,omitempty"`
|
||||
}
|
||||
|
||||
// discoveredCamera is one camera from Protect, with its RTSP-enabled channels
|
||||
// already resolved to full stream URLs. Merging into the catalog is the TUI's
|
||||
// job, so it can show what changed before anything is written.
|
||||
type discoveredCamera struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Streams map[string]string `json:"streams"`
|
||||
}
|
||||
|
||||
type discoverDoc struct {
|
||||
OK bool `json:"ok"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Cameras []discoveredCamera `json:"cameras"`
|
||||
Enabled int `json:"enabled"`
|
||||
Warnings []string `json:"warnings,omitempty"`
|
||||
}
|
||||
|
||||
// emitJSON writes v as indented JSON on stdout. Every bridge command prints a
|
||||
// JSON document on stdout whether it succeeded or failed, so the TUI can parse
|
||||
// one shape and read `ok`; the exit code carries the same signal for shell use.
|
||||
func emitJSON(v any) error {
|
||||
enc := json.NewEncoder(os.Stdout)
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(v)
|
||||
}
|
||||
|
||||
// configExportCmd prints the current config as JSON for the opentui TUI.
|
||||
func configExportCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "export",
|
||||
Short: "Print the config as JSON (used by the opentui configurator)",
|
||||
Long: "Print the config as JSON, with the controller password omitted.\n" +
|
||||
"This is the read half of the bridge the tui/ configurator uses.",
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
cfg, err := config.Load(cfgPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// A reachable control socket means a reload on save will land, so
|
||||
// the TUI can say "saved and reloaded" rather than guessing.
|
||||
_, ipcErr := ipc.Send(ipc.Request{Cmd: "status"})
|
||||
return emitJSON(exportDoc{
|
||||
ConfigPath: cfgPath,
|
||||
MaxTiles: config.MaxTiles,
|
||||
MaxGridDim: config.MaxGridDim,
|
||||
Qualities: config.Qualities,
|
||||
DaemonRunning: ipcErr == nil,
|
||||
Config: cfg,
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// configApplyCmd merges an edited camera/layout set from stdin into the config
|
||||
// on disk, then asks a running daemon to reload.
|
||||
func configApplyCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "apply",
|
||||
Short: "Merge cameras/layouts/active_layout from stdin JSON, save, and reload",
|
||||
Long: "Read a JSON object with any of \"cameras\", \"layouts\" and \"active_layout\"\n" +
|
||||
"from stdin, merge it into the config on disk, validate, save atomically,\n" +
|
||||
"and signal a running daemon to reload. Keys that are absent are left as\n" +
|
||||
"they are on disk; the controller section is never touched.",
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
raw, err := io.ReadAll(os.Stdin)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var doc applyDoc
|
||||
if err := json.Unmarshal(raw, &doc); err != nil {
|
||||
_ = emitJSON(applyResult{OK: false, Error: fmt.Sprintf("parsing stdin: %v", err)})
|
||||
return errQuiet
|
||||
}
|
||||
|
||||
// Re-read from disk so we merge onto current state, not onto
|
||||
// whatever the TUI loaded when it started.
|
||||
cfg, err := config.Load(cfgPath)
|
||||
if err != nil {
|
||||
_ = emitJSON(applyResult{OK: false, Error: err.Error()})
|
||||
return errQuiet
|
||||
}
|
||||
if doc.Cameras != nil {
|
||||
cfg.Cameras = *doc.Cameras
|
||||
}
|
||||
if doc.Layouts != nil {
|
||||
cfg.Layouts = *doc.Layouts
|
||||
}
|
||||
if doc.ActiveLayout != nil {
|
||||
cfg.ActiveLayout = *doc.ActiveLayout
|
||||
}
|
||||
cfg.Defaults()
|
||||
|
||||
if err := config.Save(cfgPath, cfg); err != nil {
|
||||
_ = emitJSON(applyResult{OK: false, Error: err.Error()})
|
||||
return errQuiet
|
||||
}
|
||||
|
||||
res := applyResult{OK: true, Path: cfgPath}
|
||||
if _, err := ipc.Send(ipc.Request{Cmd: "reload"}); err != nil {
|
||||
res.ReloadError = err.Error()
|
||||
} else {
|
||||
res.Reloaded = true
|
||||
}
|
||||
return emitJSON(res)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// discoverJSON runs Protect discovery and prints the result without writing
|
||||
// the config. The TUI merges the cameras itself and saves via `config apply`,
|
||||
// so discovery stays previewable and undoable.
|
||||
func discoverJSON(enableRTSP string) error {
|
||||
// Always emit a well-formed camera list, even on failure, so the caller can
|
||||
// read the document without special-casing a null.
|
||||
fail := func(err error) error {
|
||||
_ = emitJSON(discoverDoc{OK: false, Error: err.Error(), Cameras: []discoveredCamera{}})
|
||||
return errQuiet
|
||||
}
|
||||
|
||||
cfg, err := config.Load(cfgPath)
|
||||
if err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
cl, ctx, err := loginClient(cfg)
|
||||
if err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
cams, err := cl.Cameras(ctx)
|
||||
if err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
|
||||
var want []string
|
||||
for _, q := range strings.Split(enableRTSP, ",") {
|
||||
if q = strings.TrimSpace(strings.ToLower(q)); q != "" {
|
||||
want = append(want, q)
|
||||
}
|
||||
}
|
||||
|
||||
doc := discoverDoc{OK: true, Cameras: []discoveredCamera{}}
|
||||
for i := range cams {
|
||||
cam := &cams[i]
|
||||
for _, q := range want {
|
||||
target := cam.ChannelByPreference(q)
|
||||
if target == nil || (target.RTSPEnabled && target.RTSPAlias != "") {
|
||||
continue
|
||||
}
|
||||
alias, err := cl.EnableRTSP(ctx, cam.ID, target.ID)
|
||||
if err != nil {
|
||||
doc.Warnings = append(doc.Warnings,
|
||||
fmt.Sprintf("%s: enabling %q failed: %v", cam.Name, q, err))
|
||||
continue
|
||||
}
|
||||
target.RTSPEnabled, target.RTSPAlias = true, alias
|
||||
doc.Enabled++
|
||||
}
|
||||
streams := map[string]string{}
|
||||
for _, ch := range cam.Channels {
|
||||
if ch.RTSPEnabled && ch.RTSPAlias != "" {
|
||||
streams[strings.ToLower(ch.Name)] = cl.StreamURL(ch.RTSPAlias)
|
||||
}
|
||||
}
|
||||
if len(streams) == 0 {
|
||||
doc.Warnings = append(doc.Warnings,
|
||||
fmt.Sprintf("%s: no RTSP-enabled channel (try enabling high+low)", cam.Name))
|
||||
continue
|
||||
}
|
||||
doc.Cameras = append(doc.Cameras, discoveredCamera{
|
||||
ID: cam.ID, Name: cam.Name, Streams: streams,
|
||||
})
|
||||
}
|
||||
return emitJSON(doc)
|
||||
}
|
||||
|
||||
// errQuiet signals "the failure is already reported as JSON on stdout, exit
|
||||
// non-zero without printing a second Go-style error line".
|
||||
var errQuiet = quietError{}
|
||||
|
||||
type quietError struct{}
|
||||
|
||||
func (quietError) Error() string { return "" }
|
||||
|
||||
// runTUI launches the opentui configurator, replacing the Go/Bubble Tea one.
|
||||
// It is a separate executable (a Bun-compiled binary) because opentui is a
|
||||
// TypeScript library; see tui/README.md.
|
||||
func runTUI(ctx context.Context) error {
|
||||
bin, err := findTUIBinary()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return execTUI(ctx, bin, cfgPath)
|
||||
}
|
||||
Reference in New Issue
Block a user