Rob Watson d332a78af1 fix(ui): further key handling improvements
Avoids losing user destination selection when re-rendering the page,
especially after adding or removing a destination.
2025-04-10 07:37:24 +02:00

313 lines
9.3 KiB
Go

package app
import (
"context"
"errors"
"fmt"
"log/slog"
"slices"
"time"
"git.netflux.io/rob/octoplex/internal/config"
"git.netflux.io/rob/octoplex/internal/container"
"git.netflux.io/rob/octoplex/internal/domain"
"git.netflux.io/rob/octoplex/internal/mediaserver"
"git.netflux.io/rob/octoplex/internal/replicator"
"git.netflux.io/rob/octoplex/internal/terminal"
)
// RunParams holds the parameters for running the application.
type RunParams struct {
ConfigService *config.Service
DockerClient container.DockerClient
Screen *terminal.Screen // Screen may be nil.
ClipboardAvailable bool
ConfigFilePath string
BuildInfo domain.BuildInfo
Logger *slog.Logger
}
// Run starts the application, and blocks until it exits.
func Run(ctx context.Context, params RunParams) error {
// cfg is the current configuration of the application, as reflected in the
// config file.
cfg := params.ConfigService.Current()
// state is the current state of the application, as reflected in the UI.
state := new(domain.AppState)
applyConfig(cfg, state)
// While RTMP is the only source, it doesn't make sense to disable it.
if !cfg.Sources.RTMP.Enabled {
return errors.New("config: sources.rtmp.enabled must be set to true")
}
logger := params.Logger
ui, err := terminal.StartUI(ctx, terminal.StartParams{
Screen: params.Screen,
ClipboardAvailable: params.ClipboardAvailable,
ConfigFilePath: params.ConfigFilePath,
BuildInfo: params.BuildInfo,
Logger: logger.With("component", "ui"),
})
if err != nil {
return fmt.Errorf("start terminal user interface: %w", err)
}
defer ui.Close()
// emptyUI is a dummy function that sets the UI state to an empty state, and
// re-renders the screen.
//
// This is a workaround for a weird interaction between tview and
// tcell.SimulationScreen which leads to newly-added pages not rendering if
// the UI is not re-rendered for a second time.
// It is only needed for integration tests when rendering modals before the
// main loop starts. It would be nice to remove this but the risk/impact on
// non-test code is pretty low.
emptyUI := func() { ui.SetState(domain.AppState{}) }
containerClient, err := container.NewClient(ctx, params.DockerClient, logger.With("component", "container_client"))
if err != nil {
err = fmt.Errorf("create container client: %w", err)
ui.ShowFatalErrorModal(err)
emptyUI()
<-ui.C()
return err
}
defer containerClient.Close()
updateUI := func() { ui.SetState(*state) }
updateUI()
srv, err := mediaserver.NewActor(ctx, mediaserver.NewActorParams{
StreamKey: mediaserver.StreamKey(cfg.Sources.RTMP.StreamKey),
ContainerClient: containerClient,
Logger: logger.With("component", "mediaserver"),
})
if err != nil {
err = fmt.Errorf("create mediaserver: %w", err)
ui.ShowFatalErrorModal(err)
emptyUI()
<-ui.C()
return err
}
defer srv.Close()
repl := replicator.StartActor(ctx, replicator.StartActorParams{
SourceURL: srv.RTMPInternalURL(),
ContainerClient: containerClient,
Logger: logger.With("component", "replicator"),
})
defer repl.Close()
const uiUpdateInterval = time.Second
uiUpdateT := time.NewTicker(uiUpdateInterval)
defer uiUpdateT.Stop()
startupCheckC := doStartupCheck(ctx, containerClient, ui.ShowStartupCheckModal)
for {
select {
case err := <-startupCheckC:
if errors.Is(err, errStartupCheckUserQuit) {
return nil
} else if err != nil {
return fmt.Errorf("startup check: %w", err)
} else {
startupCheckC = nil
if err = srv.Start(ctx); err != nil {
return fmt.Errorf("start mediaserver: %w", err)
}
}
case <-params.ConfigService.C():
// No-op, config updates are handled synchronously for now.
case cmd, ok := <-ui.C():
if !ok {
// TODO: keep UI open until all containers have closed
logger.Info("UI closed")
return nil
}
logger.Debug("Command received", "cmd", cmd.Name())
switch c := cmd.(type) {
case terminal.CommandAddDestination:
newCfg := cfg
newCfg.Destinations = append(newCfg.Destinations, config.Destination{
Name: c.DestinationName,
URL: c.URL,
})
if err := params.ConfigService.SetConfig(newCfg); err != nil {
logger.Error("Config update failed", "err", err)
ui.ConfigUpdateFailed(err)
continue
}
cfg = newCfg
handleConfigUpdate(cfg, state, ui)
ui.DestinationAdded()
case terminal.CommandRemoveDestination:
repl.StopDestination(c.URL) // no-op if not live
newCfg := cfg
newCfg.Destinations = slices.DeleteFunc(newCfg.Destinations, func(dest config.Destination) bool {
return dest.URL == c.URL
})
if err := params.ConfigService.SetConfig(newCfg); err != nil {
logger.Error("Config update failed", "err", err)
ui.ConfigUpdateFailed(err)
continue
}
cfg = newCfg
handleConfigUpdate(cfg, state, ui)
ui.DestinationRemoved()
case terminal.CommandStartDestination:
if !state.Source.Live {
ui.ShowSourceNotLiveModal()
continue
}
repl.StartDestination(c.URL)
case terminal.CommandStopDestination:
repl.StopDestination(c.URL)
case terminal.CommandQuit:
return nil
}
case <-uiUpdateT.C:
updateUI()
case serverState := <-srv.C():
logger.Debug("Server state received", "state", serverState)
applyServerState(serverState, state)
updateUI()
case replState := <-repl.C():
logger.Debug("Replicator state received", "state", replState)
destErrors := applyReplicatorState(replState, state)
for _, destError := range destErrors {
handleDestError(destError, repl, ui)
}
updateUI()
}
}
}
// handleConfigUpdate applies the config to the app state, and updates the UI.
func handleConfigUpdate(cfg config.Config, appState *domain.AppState, ui *terminal.UI) {
applyConfig(cfg, appState)
ui.SetState(*appState)
}
// applyServerState applies the current server state to the app state.
func applyServerState(serverState domain.Source, appState *domain.AppState) {
appState.Source = serverState
}
// destinationError holds the information needed to display a destination
// error.
type destinationError struct {
name string
url string
err error
}
// applyReplicatorState applies the current replicator state to the app state.
//
// It returns a list of destination errors that should be displayed to the user.
func applyReplicatorState(replState replicator.State, appState *domain.AppState) []destinationError {
var errorsToDisplay []destinationError
for i := range appState.Destinations {
dest := &appState.Destinations[i]
if dest.URL != replState.URL {
continue
}
if dest.Container.Err == nil && replState.Container.Err != nil {
errorsToDisplay = append(errorsToDisplay, destinationError{
name: dest.Name,
url: dest.URL,
err: replState.Container.Err,
})
}
dest.Container = replState.Container
dest.Status = replState.Status
break
}
return errorsToDisplay
}
// handleDestError displays a modal to the user, and stops the destination.
func handleDestError(destError destinationError, repl *replicator.Actor, ui *terminal.UI) {
ui.ShowDestinationErrorModal(destError.name, destError.err)
repl.StopDestination(destError.url)
}
// applyConfig applies the config to the app state. For now we only set the
// destinations.
func applyConfig(cfg config.Config, appState *domain.AppState) {
appState.Destinations = resolveDestinations(appState.Destinations, cfg.Destinations)
}
// resolveDestinations merges the current destinations with newly configured
// destinations.
func resolveDestinations(destinations []domain.Destination, inDestinations []config.Destination) []domain.Destination {
destinations = slices.DeleteFunc(destinations, func(dest domain.Destination) bool {
return !slices.ContainsFunc(inDestinations, func(inDest config.Destination) bool {
return inDest.URL == dest.URL
})
})
for i, inDest := range inDestinations {
if i < len(destinations) && destinations[i].URL == inDest.URL {
continue
}
destinations = slices.Insert(destinations, i, domain.Destination{
Name: inDest.Name,
URL: inDest.URL,
})
}
return destinations[:len(inDestinations)]
}
var errStartupCheckUserQuit = errors.New("user quit startup check modal")
// doStartupCheck performs a startup check to see if there are any existing app
// containers.
//
// It returns a channel that will be closed, possibly after receiving an error.
// If the error is non-nil the app must not be started. If the error is
// [errStartupCheckUserQuit], the user voluntarily quit the startup check
// modal.
func doStartupCheck(ctx context.Context, containerClient *container.Client, showModal func() bool) <-chan error {
ch := make(chan error, 1)
go func() {
defer close(ch)
if exists, err := containerClient.ContainerRunning(ctx, container.AllContainers()); err != nil {
ch <- fmt.Errorf("check existing containers: %w", err)
} else if exists {
if showModal() {
if err = containerClient.RemoveContainers(ctx, container.AllContainers()); err != nil {
ch <- fmt.Errorf("remove existing containers: %w", err)
return
}
if err = containerClient.RemoveUnusedNetworks(ctx); err != nil {
ch <- fmt.Errorf("remove unused networks: %w", err)
return
}
} else {
ch <- errStartupCheckUserQuit
}
}
}()
return ch
}