// 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) }