refactor(app): internalize dispatch channel
Some checks are pending
ci-build / lint (push) Waiting to run
ci-build / build (push) Blocked by required conditions
ci-build / release (push) Blocked by required conditions
ci-scan / Analyze (go) (push) Waiting to run
ci-scan / Analyze (actions) (push) Waiting to run

This commit is contained in:
Rob Watson 2025-04-29 22:30:34 +02:00
parent caa543703e
commit 2f263b5725
2 changed files with 29 additions and 34 deletions

View File

@ -1,6 +1,7 @@
package app package app
import ( import (
"cmp"
"context" "context"
"errors" "errors"
"fmt" "fmt"
@ -23,6 +24,7 @@ type App struct {
cfg config.Config cfg config.Config
configService *config.Service configService *config.Service
eventBus *event.Bus eventBus *event.Bus
dispatchC chan event.Command
dockerClient container.DockerClient dockerClient container.DockerClient
screen *terminal.Screen // Screen may be nil. screen *terminal.Screen // Screen may be nil.
clipboardAvailable bool clipboardAvailable bool
@ -35,6 +37,7 @@ type App struct {
type Params struct { type Params struct {
ConfigService *config.Service ConfigService *config.Service
DockerClient container.DockerClient DockerClient container.DockerClient
ChanSize int
Screen *terminal.Screen // Screen may be nil. Screen *terminal.Screen // Screen may be nil.
ClipboardAvailable bool ClipboardAvailable bool
ConfigFilePath string ConfigFilePath string
@ -42,12 +45,16 @@ type Params struct {
Logger *slog.Logger Logger *slog.Logger
} }
// defaultChanSize is the default size of the dispatch channel.
const defaultChanSize = 64
// New creates a new application instance. // New creates a new application instance.
func New(params Params) *App { func New(params Params) *App {
return &App{ return &App{
cfg: params.ConfigService.Current(), cfg: params.ConfigService.Current(),
configService: params.ConfigService, configService: params.ConfigService,
eventBus: event.NewBus(params.Logger.With("component", "event_bus")), eventBus: event.NewBus(params.Logger.With("component", "event_bus")),
dispatchC: make(chan event.Command, cmp.Or(params.ChanSize, defaultChanSize)),
dockerClient: params.DockerClient, dockerClient: params.DockerClient,
screen: params.Screen, screen: params.Screen,
clipboardAvailable: params.ClipboardAvailable, clipboardAvailable: params.ClipboardAvailable,
@ -70,6 +77,7 @@ func (a *App) Run(ctx context.Context) error {
ui, err := terminal.StartUI(ctx, terminal.StartParams{ ui, err := terminal.StartUI(ctx, terminal.StartParams{
EventBus: a.eventBus, EventBus: a.eventBus,
Dispatcher: func(cmd event.Command) { a.dispatchC <- cmd },
Screen: a.screen, Screen: a.screen,
ClipboardAvailable: a.clipboardAvailable, ClipboardAvailable: a.clipboardAvailable,
ConfigFilePath: a.configFilePath, ConfigFilePath: a.configFilePath,
@ -107,7 +115,7 @@ func (a *App) Run(ctx context.Context) error {
a.eventBus.Send(event.FatalErrorOccurredEvent{Message: msg}) a.eventBus.Send(event.FatalErrorOccurredEvent{Message: msg})
emptyUI() emptyUI()
<-ui.C() <-a.dispatchC
return err return err
} }
defer containerClient.Close() defer containerClient.Close()
@ -139,7 +147,7 @@ func (a *App) Run(ctx context.Context) error {
err = fmt.Errorf("create mediaserver: %w", err) err = fmt.Errorf("create mediaserver: %w", err)
a.eventBus.Send(event.FatalErrorOccurredEvent{Message: err.Error()}) a.eventBus.Send(event.FatalErrorOccurredEvent{Message: err.Error()})
emptyUI() emptyUI()
<-ui.C() <-a.dispatchC
return err return err
} }
defer srv.Close() defer srv.Close()
@ -159,7 +167,7 @@ func (a *App) Run(ctx context.Context) error {
if ok, startupErr := doStartupCheck(ctx, containerClient, a.eventBus); startupErr != nil { if ok, startupErr := doStartupCheck(ctx, containerClient, a.eventBus); startupErr != nil {
startupErr = fmt.Errorf("startup check: %w", startupErr) startupErr = fmt.Errorf("startup check: %w", startupErr)
a.eventBus.Send(event.FatalErrorOccurredEvent{Message: startupErr.Error()}) a.eventBus.Send(event.FatalErrorOccurredEvent{Message: startupErr.Error()})
<-ui.C() <-a.dispatchC
return startupErr return startupErr
} else if ok { } else if ok {
startMediaServerC <- struct{}{} startMediaServerC <- struct{}{}
@ -175,13 +183,7 @@ func (a *App) Run(ctx context.Context) error {
a.eventBus.Send(event.MediaServerStartedEvent{RTMPURL: srv.RTMPURL(), RTMPSURL: srv.RTMPSURL()}) a.eventBus.Send(event.MediaServerStartedEvent{RTMPURL: srv.RTMPURL(), RTMPSURL: srv.RTMPSURL()})
case <-a.configService.C(): case <-a.configService.C():
// No-op, config updates are handled synchronously for now. // No-op, config updates are handled synchronously for now.
case cmd, ok := <-ui.C(): case cmd := <-a.dispatchC:
if !ok {
// TODO: keep UI open until all containers have closed
a.logger.Info("UI closed")
return nil
}
if ok, err := a.handleCommand(ctx, cmd, state, repl, containerClient, startMediaServerC); err != nil { if ok, err := a.handleCommand(ctx, cmd, state, repl, containerClient, startMediaServerC); err != nil {
return fmt.Errorf("handle command: %w", err) return fmt.Errorf("handle command: %w", err)
} else if !ok { } else if !ok {

View File

@ -42,7 +42,7 @@ const (
// UI is responsible for managing the terminal user interface. // UI is responsible for managing the terminal user interface.
type UI struct { type UI struct {
eventBus *event.Bus eventBus *event.Bus
commandC chan event.Command dispatch func(event.Command)
clipboardAvailable bool clipboardAvailable bool
configFilePath string configFilePath string
rtmpURL, rtmpsURL string rtmpURL, rtmpsURL string
@ -96,7 +96,7 @@ type ScreenCapture struct {
// interface. // interface.
type StartParams struct { type StartParams struct {
EventBus *event.Bus EventBus *event.Bus
ChanSize int Dispatcher func(event.Command)
Logger *slog.Logger Logger *slog.Logger
ClipboardAvailable bool ClipboardAvailable bool
ConfigFilePath string ConfigFilePath string
@ -104,13 +104,8 @@ type StartParams struct {
Screen *Screen // Screen may be nil. Screen *Screen // Screen may be nil.
} }
const defaultChanSize = 64
// StartUI starts the terminal user interface. // StartUI starts the terminal user interface.
func StartUI(ctx context.Context, params StartParams) (*UI, error) { func StartUI(ctx context.Context, params StartParams) (*UI, error) {
chanSize := cmp.Or(params.ChanSize, defaultChanSize)
commandCh := make(chan event.Command, chanSize)
app := tview.NewApplication() app := tview.NewApplication()
var screen tcell.Screen var screen tcell.Screen
@ -213,8 +208,8 @@ func StartUI(ctx context.Context, params StartParams) (*UI, error) {
app.EnableMouse(false) app.EnableMouse(false)
ui := &UI{ ui := &UI{
commandC: commandCh,
eventBus: params.EventBus, eventBus: params.EventBus,
dispatch: params.Dispatcher,
clipboardAvailable: params.ClipboardAvailable, clipboardAvailable: params.ClipboardAvailable,
configFilePath: params.ConfigFilePath, configFilePath: params.ConfigFilePath,
buildInfo: params.BuildInfo, buildInfo: params.BuildInfo,
@ -271,13 +266,11 @@ func (ui *UI) renderAboutView() {
ui.aboutView.AddItem(tview.NewTextView().SetDynamicColors(true).SetText("[grey]?[-] About"), 1, 0, false) ui.aboutView.AddItem(tview.NewTextView().SetDynamicColors(true).SetText("[grey]?[-] About"), 1, 0, false)
} }
// C returns a channel that receives commands from the user interface.
func (ui *UI) C() <-chan event.Command {
return ui.commandC
}
func (ui *UI) run(ctx context.Context) { func (ui *UI) run(ctx context.Context) {
defer close(ui.commandC) defer func() {
// Ensure the application is stopped when the UI is closed.
ui.dispatch(event.CommandQuit{})
}()
eventC := ui.eventBus.Register() eventC := ui.eventBus.Register()
@ -425,9 +418,9 @@ func (ui *UI) handleOtherInstanceDetected(event.OtherInstanceDetectedEvent) {
false, false,
func(buttonIndex int, _ string) { func(buttonIndex int, _ string) {
if buttonIndex == 0 { if buttonIndex == 0 {
ui.commandC <- event.CommandCloseOtherInstance{} ui.dispatch(event.CommandCloseOtherInstance{})
} else { } else {
ui.commandC <- event.CommandQuit{} ui.dispatch(event.CommandQuit{})
} }
}, },
) )
@ -457,7 +450,7 @@ func (ui *UI) handleFatalErrorOccurred(evt event.FatalErrorOccurredEvent) {
[]string{"Quit"}, []string{"Quit"},
false, false,
func(int, string) { func(int, string) {
ui.commandC <- event.CommandQuit{} ui.dispatch(event.CommandQuit{})
}, },
) )
} }
@ -702,7 +695,7 @@ func (ui *UI) handleMediaServerClosed(exitReason string) {
SetBackgroundColor(tcell.ColorBlack). SetBackgroundColor(tcell.ColorBlack).
SetTextColor(tcell.ColorWhite). SetTextColor(tcell.ColorWhite).
SetDoneFunc(func(int, string) { SetDoneFunc(func(int, string) {
ui.commandC <- event.CommandQuit{} ui.dispatch(event.CommandQuit{})
}) })
modal.SetBorderStyle(tcell.StyleDefault.Background(tcell.ColorBlack).Foreground(tcell.ColorWhite)) modal.SetBorderStyle(tcell.StyleDefault.Background(tcell.ColorBlack).Foreground(tcell.ColorWhite))
@ -873,10 +866,10 @@ func (ui *UI) addDestination() {
AddInputField(inputLabelName, "My stream", inputLen, nil, nil). AddInputField(inputLabelName, "My stream", inputLen, nil, nil).
AddInputField(inputLabelURL, "rtmp://", inputLen, nil, nil). AddInputField(inputLabelURL, "rtmp://", inputLen, nil, nil).
AddButton("Add", func() { AddButton("Add", func() {
ui.commandC <- event.CommandAddDestination{ ui.dispatch(event.CommandAddDestination{
DestinationName: form.GetFormItemByLabel(inputLabelName).(*tview.InputField).GetText(), DestinationName: form.GetFormItemByLabel(inputLabelName).(*tview.InputField).GetText(),
URL: form.GetFormItemByLabel(inputLabelURL).(*tview.InputField).GetText(), URL: form.GetFormItemByLabel(inputLabelURL).(*tview.InputField).GetText(),
} })
}). }).
AddButton("Cancel", func() { AddButton("Cancel", func() {
ui.closeAddDestinationForm() ui.closeAddDestinationForm()
@ -931,7 +924,7 @@ func (ui *UI) removeDestination() {
false, false,
func(buttonIndex int, _ string) { func(buttonIndex int, _ string) {
if buttonIndex == 0 { if buttonIndex == 0 {
ui.commandC <- event.CommandRemoveDestination{URL: url} ui.dispatch(event.CommandRemoveDestination{URL: url})
} }
}, },
) )
@ -1007,12 +1000,12 @@ func (ui *UI) toggleDestination() {
switch ss { switch ss {
case startStateNotStarted: case startStateNotStarted:
ui.urlsToStartState[url] = startStateStarting ui.urlsToStartState[url] = startStateStarting
ui.commandC <- event.CommandStartDestination{URL: url} ui.dispatch(event.CommandStartDestination{URL: url})
case startStateStarting: case startStateStarting:
// do nothing // do nothing
return return
case startStateStarted: case startStateStarted:
ui.commandC <- event.CommandStopDestination{URL: url} ui.dispatch(event.CommandStopDestination{URL: url})
} }
} }
@ -1065,7 +1058,7 @@ func (ui *UI) confirmQuit() {
false, false,
func(buttonIndex int, _ string) { func(buttonIndex int, _ string) {
if buttonIndex == 0 { if buttonIndex == 0 {
ui.commandC <- event.CommandQuit{} ui.dispatch(event.CommandQuit{})
} }
}, },
) )