diff --git a/internal/app/app.go b/internal/app/app.go index 7a04f97..91db45a 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -154,24 +154,24 @@ func (a *App) Run(ctx context.Context) error { uiUpdateT := time.NewTicker(uiUpdateInterval) defer uiUpdateT.Stop() - startupCheckC := doStartupCheck(ctx, containerClient, ui.ShowStartupCheckModal) + startMediaServerC := make(chan struct{}, 1) + if ok, startupErr := doStartupCheck(ctx, containerClient, a.eventBus); startupErr != nil { + startupErr = fmt.Errorf("startup check: %w", startupErr) + a.eventBus.Send(event.FatalErrorOccurredEvent{Message: startupErr.Error()}) + <-ui.C() + return startupErr + } else if ok { + startMediaServerC <- struct{}{} + } 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) - } - - a.eventBus.Send(event.MediaServerStartedEvent{RTMPURL: srv.RTMPURL(), RTMPSURL: srv.RTMPSURL()}) + case <-startMediaServerC: + if err = srv.Start(ctx); err != nil { + return fmt.Errorf("start mediaserver: %w", err) } + + a.eventBus.Send(event.MediaServerStartedEvent{RTMPURL: srv.RTMPURL(), RTMPSURL: srv.RTMPSURL()}) case <-a.configService.C(): // No-op, config updates are handled synchronously for now. case cmd, ok := <-ui.C(): @@ -181,7 +181,9 @@ func (a *App) Run(ctx context.Context) error { return nil } - if !a.handleCommand(cmd, state, repl) { + if ok, err := a.handleCommand(ctx, cmd, state, repl, containerClient, startMediaServerC); err != nil { + return fmt.Errorf("handle command: %w", err) + } else if !ok { return nil } case <-uiUpdateT.C: @@ -206,10 +208,13 @@ func (a *App) Run(ctx context.Context) error { // handleCommand handles an incoming command. It returns false if the app // should not continue, i.e. quit. func (a *App) handleCommand( + ctx context.Context, cmd domain.Command, state *domain.AppState, repl *replicator.Actor, -) bool { + containerClient *container.Client, + startMediaServerC chan struct{}, +) (bool, error) { a.logger.Debug("Command received", "cmd", cmd.Name()) switch c := cmd.(type) { case domain.CommandAddDestination: @@ -249,11 +254,17 @@ func (a *App) handleCommand( repl.StartDestination(c.URL) case domain.CommandStopDestination: repl.StopDestination(c.URL) + case domain.CommandCloseOtherInstance: + if err := closeOtherInstances(ctx, containerClient); err != nil { + return false, fmt.Errorf("close other instances: %w", err) + } + + startMediaServerC <- struct{}{} case domain.CommandQuit: - return false + return false, nil } - return true + return true, nil } // handleConfigUpdate applies the config to the app state, and sends an AppStateChangedEvent. @@ -341,40 +352,34 @@ func resolveDestinations(destinations []domain.Destination, inDestinations []con 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) +// It returns a bool if the check is clear. If the bool is false, then +// startup should be paused until the choice selected by the user is received +// via a command. +func doStartupCheck(ctx context.Context, containerClient *container.Client, eventBus *event.Bus) (bool, error) { + if exists, err := containerClient.ContainerRunning(ctx, container.AllContainers()); err != nil { + return false, fmt.Errorf("check existing containers: %w", err) + } else if exists { + eventBus.Send(event.OtherInstanceDetectedEvent{}) - go func() { - defer close(ch) + return false, nil + } - 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 true, nil +} - return ch +func closeOtherInstances(ctx context.Context, containerClient *container.Client) error { + if err := containerClient.RemoveContainers(ctx, container.AllContainers()); err != nil { + return fmt.Errorf("remove existing containers: %w", err) + } + + if err := containerClient.RemoveUnusedNetworks(ctx); err != nil { + return fmt.Errorf("remove unused networks: %w", err) + } + + return nil } // buildNetAddr builds a [mediaserver.OptionalNetAddr] from the config. diff --git a/internal/domain/commands.go b/internal/domain/commands.go index 1793e8d..f852b5d 100644 --- a/internal/domain/commands.go +++ b/internal/domain/commands.go @@ -41,6 +41,14 @@ func (c CommandStopDestination) Name() string { return "stop_destination" } +// CommandCloseOtherInstance closes the other instance of the application. +type CommandCloseOtherInstance struct{} + +// Name implements the Command interface. +func (c CommandCloseOtherInstance) Name() string { + return "close_other_instance" +} + // CommandQuit quits the app. type CommandQuit struct{} diff --git a/internal/event/events.go b/internal/event/events.go index 933a037..c7c8ff8 100644 --- a/internal/event/events.go +++ b/internal/event/events.go @@ -12,6 +12,7 @@ const ( EventNameDestinationRemoved Name = "destination_removed" EventNameRemoveDestinationFailed Name = "remove_destination_failed" EventNameFatalErrorOccurred Name = "fatal_error_occurred" + EventNameOtherInstanceDetected Name = "other_instance_detected" EventNameMediaServerStarted Name = "media_server_started" ) @@ -80,6 +81,13 @@ type FatalErrorOccurredEvent struct { Message string } +// OtherInstanceDetectedEvent is emitted when the app launches and detects another instance. +type OtherInstanceDetectedEvent struct{} + +func (e OtherInstanceDetectedEvent) name() Name { + return EventNameOtherInstanceDetected +} + func (e FatalErrorOccurredEvent) name() Name { return "fatal_error_occurred" } diff --git a/internal/terminal/terminal.go b/internal/terminal/terminal.go index 2929282..1a6ef4a 100644 --- a/internal/terminal/terminal.go +++ b/internal/terminal/terminal.go @@ -285,6 +285,7 @@ func (ui *UI) run(ctx context.Context) { startDestinationFailedC := ui.eventBus.Register(event.EventNameStartDestinationFailed) destinationRemovedC := ui.eventBus.Register(event.EventNameDestinationRemoved) removeDestinationFailedC := ui.eventBus.Register(event.EventNameRemoveDestinationFailed) + existingAppDetectedC := ui.eventBus.Register(event.EventNameOtherInstanceDetected) mediaServerStartedC := ui.eventBus.Register(event.EventNameMediaServerStarted) fatalErrorOccurredC := ui.eventBus.Register(event.EventNameFatalErrorOccurred) @@ -325,6 +326,10 @@ func (ui *UI) run(ctx context.Context) { ui.app.QueueUpdateDraw(func() { ui.handleDestinationEventError(evt.(event.RemoveDestinationFailedEvent).Err) }) + case evt := <-existingAppDetectedC: + ui.app.QueueUpdateDraw(func() { + ui.handleOtherInstanceDetected(evt.(event.OtherInstanceDetectedEvent)) + }) case evt := <-mediaServerStartedC: ui.app.QueueUpdateDraw(func() { ui.handleMediaServerStarted(evt.(event.MediaServerStartedEvent)) @@ -428,32 +433,20 @@ func (ui *UI) handleStartDestinationFailed(event.StartDestinationFailedEvent) { ) } -// ShowStartupCheckModal shows a modal dialog to the user, asking if they want -// to kill a running instance of Octoplex. -// -// The method will block until the user has made a choice, after which the -// channel will receive true if the user wants to quit the other instance, or -// false to quit this instance. -func (ui *UI) ShowStartupCheckModal() bool { - done := make(chan bool) - - ui.app.QueueUpdateDraw(func() { - ui.showModal( - pageNameModalStartupCheck, - "Another instance of Octoplex may already be running.\n\nPressing continue will close that instance. Continue?", - []string{"Continue", "Exit"}, - false, - func(buttonIndex int, _ string) { - if buttonIndex == 0 { - done <- true - } else { - done <- false - } - }, - ) - }) - - return <-done +func (ui *UI) handleOtherInstanceDetected(event.OtherInstanceDetectedEvent) { + ui.showModal( + pageNameModalStartupCheck, + "Another instance of Octoplex may already be running.\n\nPressing continue will close that instance. Continue?", + []string{"Continue", "Exit"}, + false, + func(buttonIndex int, _ string) { + if buttonIndex == 0 { + ui.commandC <- domain.CommandCloseOtherInstance{} + } else { + ui.commandC <- domain.CommandQuit{} + } + }, + ) } func (ui *UI) ShowDestinationErrorModal(name string, err error) {