From 4a2857e310ded1cae4c14458da8f0bfecfe773fe Mon Sep 17 00:00:00 2001
From: Rob Watson <rob@netflux.io>
Date: Tue, 22 Apr 2025 16:06:27 +0200
Subject: [PATCH] refactor(app): add App type

---
 internal/app/app.go                      | 68 ++++++++++++++++--------
 internal/app/integration_helpers_test.go |  4 +-
 internal/app/integration_test.go         | 25 +++++----
 main.go                                  | 29 +++++-----
 4 files changed, 73 insertions(+), 53 deletions(-)

diff --git a/internal/app/app.go b/internal/app/app.go
index b3c86ee..8e80b65 100644
--- a/internal/app/app.go
+++ b/internal/app/app.go
@@ -18,8 +18,19 @@ import (
 	"github.com/docker/docker/client"
 )
 
-// RunParams holds the parameters for running the application.
-type RunParams struct {
+// App is an instance of the app.
+type App struct {
+	configService      *config.Service
+	dockerClient       container.DockerClient
+	screen             *terminal.Screen // Screen may be nil.
+	clipboardAvailable bool
+	configFilePath     string
+	buildInfo          domain.BuildInfo
+	logger             *slog.Logger
+}
+
+// Params holds the parameters for running the application.
+type Params struct {
 	ConfigService      *config.Service
 	DockerClient       container.DockerClient
 	Screen             *terminal.Screen // Screen may be nil.
@@ -29,14 +40,25 @@ type RunParams struct {
 	Logger             *slog.Logger
 }
 
+func New(params Params) *App {
+	return &App{
+		configService:      params.ConfigService,
+		dockerClient:       params.DockerClient,
+		screen:             params.Screen,
+		clipboardAvailable: params.ClipboardAvailable,
+		configFilePath:     params.ConfigFilePath,
+		buildInfo:          params.BuildInfo,
+		logger:             params.Logger,
+	}
+}
+
 // Run starts the application, and blocks until it exits.
-func Run(ctx context.Context, params RunParams) error {
-	logger := params.Logger
-	eventBus := event.NewBus(logger.With("component", "event_bus"))
+func (a *App) Run(ctx context.Context) error {
+	eventBus := event.NewBus(a.logger.With("component", "event_bus"))
 
 	// cfg is the current configuration of the application, as reflected in the
 	// config file.
-	cfg := params.ConfigService.Current()
+	cfg := a.configService.Current()
 
 	// state is the current state of the application, as reflected in the UI.
 	state := new(domain.AppState)
@@ -49,11 +71,11 @@ func Run(ctx context.Context, params RunParams) error {
 
 	ui, err := terminal.StartUI(ctx, terminal.StartParams{
 		EventBus:           eventBus,
-		Screen:             params.Screen,
-		ClipboardAvailable: params.ClipboardAvailable,
-		ConfigFilePath:     params.ConfigFilePath,
-		BuildInfo:          params.BuildInfo,
-		Logger:             logger.With("component", "ui"),
+		Screen:             a.screen,
+		ClipboardAvailable: a.clipboardAvailable,
+		ConfigFilePath:     a.configFilePath,
+		BuildInfo:          a.buildInfo,
+		Logger:             a.logger.With("component", "ui"),
 	})
 	if err != nil {
 		return fmt.Errorf("start terminal user interface: %w", err)
@@ -71,7 +93,7 @@ func Run(ctx context.Context, params RunParams) error {
 	// non-test code is pretty low.
 	emptyUI := func() { ui.SetState(domain.AppState{}) }
 
-	containerClient, err := container.NewClient(ctx, params.DockerClient, logger.With("component", "container_client"))
+	containerClient, err := container.NewClient(ctx, a.dockerClient, a.logger.With("component", "container_client"))
 	if err != nil {
 		err = fmt.Errorf("create container client: %w", err)
 
@@ -106,7 +128,7 @@ func Run(ctx context.Context, params RunParams) error {
 		TLSKeyPath:      tlsKeyPath,
 		StreamKey:       mediaserver.StreamKey(cfg.Sources.MediaServer.StreamKey),
 		ContainerClient: containerClient,
-		Logger:          logger.With("component", "mediaserver"),
+		Logger:          a.logger.With("component", "mediaserver"),
 	})
 	if err != nil {
 		err = fmt.Errorf("create mediaserver: %w", err)
@@ -120,7 +142,7 @@ func Run(ctx context.Context, params RunParams) error {
 	repl := replicator.StartActor(ctx, replicator.StartActorParams{
 		SourceURL:       srv.RTMPInternalURL(),
 		ContainerClient: containerClient,
-		Logger:          logger.With("component", "replicator"),
+		Logger:          a.logger.With("component", "replicator"),
 	})
 	defer repl.Close()
 
@@ -146,16 +168,16 @@ func Run(ctx context.Context, params RunParams) error {
 
 				eventBus.Send(event.MediaServerStartedEvent{RTMPURL: srv.RTMPURL(), RTMPSURL: srv.RTMPSURL()})
 			}
-		case <-params.ConfigService.C():
+		case <-a.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")
+				a.logger.Info("UI closed")
 				return nil
 			}
 
-			logger.Debug("Command received", "cmd", cmd.Name())
+			a.logger.Debug("Command received", "cmd", cmd.Name())
 			switch c := cmd.(type) {
 			case domain.CommandAddDestination:
 				newCfg := cfg
@@ -163,8 +185,8 @@ func Run(ctx context.Context, params RunParams) error {
 					Name: c.DestinationName,
 					URL:  c.URL,
 				})
-				if err := params.ConfigService.SetConfig(newCfg); err != nil {
-					logger.Error("Config update failed", "err", err)
+				if err := a.configService.SetConfig(newCfg); err != nil {
+					a.logger.Error("Config update failed", "err", err)
 					ui.ConfigUpdateFailed(err)
 					continue
 				}
@@ -177,8 +199,8 @@ func Run(ctx context.Context, params RunParams) error {
 				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)
+				if err := a.configService.SetConfig(newCfg); err != nil {
+					a.logger.Error("Config update failed", "err", err)
 					ui.ConfigUpdateFailed(err)
 					continue
 				}
@@ -200,11 +222,11 @@ func Run(ctx context.Context, params RunParams) error {
 		case <-uiUpdateT.C:
 			updateUI()
 		case serverState := <-srv.C():
-			logger.Debug("Server state received", "state", serverState)
+			a.logger.Debug("Server state received", "state", serverState)
 			applyServerState(serverState, state)
 			updateUI()
 		case replState := <-repl.C():
-			logger.Debug("Replicator state received", "state", replState)
+			a.logger.Debug("Replicator state received", "state", replState)
 			destErrors := applyReplicatorState(replState, state)
 
 			for _, destError := range destErrors {
diff --git a/internal/app/integration_helpers_test.go b/internal/app/integration_helpers_test.go
index be288b0..7ac4898 100644
--- a/internal/app/integration_helpers_test.go
+++ b/internal/app/integration_helpers_test.go
@@ -31,10 +31,10 @@ func buildAppParams(
 	screen tcell.SimulationScreen,
 	screenCaptureC chan<- terminal.ScreenCapture,
 	logger *slog.Logger,
-) app.RunParams {
+) app.Params {
 	t.Helper()
 
-	return app.RunParams{
+	return app.Params{
 		ConfigService: configService,
 		DockerClient:  dockerClient,
 		Screen: &terminal.Screen{
diff --git a/internal/app/integration_test.go b/internal/app/integration_test.go
index 9b47d3f..84a894f 100644
--- a/internal/app/integration_test.go
+++ b/internal/app/integration_test.go
@@ -132,7 +132,7 @@ func testIntegration(t *testing.T, mediaServerConfig config.MediaServerSource) {
 			done <- struct{}{}
 		}()
 
-		err := app.Run(ctx, app.RunParams{
+		require.NoError(t, app.New(app.Params{
 			ConfigService: configService,
 			DockerClient:  dockerClient,
 			Screen: &terminal.Screen{
@@ -144,8 +144,7 @@ func testIntegration(t *testing.T, mediaServerConfig config.MediaServerSource) {
 			ClipboardAvailable: false,
 			BuildInfo:          domain.BuildInfo{Version: "0.0.1", GoVersion: "go1.16.3"},
 			Logger:             logger,
-		})
-		require.NoError(t, err)
+		}).Run(ctx))
 	}()
 
 	require.EventuallyWithT(
@@ -320,7 +319,7 @@ func TestIntegrationCustomHost(t *testing.T) {
 			done <- struct{}{}
 		}()
 
-		require.NoError(t, app.Run(ctx, buildAppParams(t, configService, dockerClient, screen, screenCaptureC, logger)))
+		require.NoError(t, app.New(buildAppParams(t, configService, dockerClient, screen, screenCaptureC, logger)).Run(ctx))
 	}()
 
 	time.Sleep(time.Second)
@@ -391,7 +390,7 @@ func TestIntegrationCustomTLSCerts(t *testing.T) {
 			done <- struct{}{}
 		}()
 
-		require.NoError(t, app.Run(ctx, buildAppParams(t, configService, dockerClient, screen, screenCaptureC, logger)))
+		require.NoError(t, app.New(buildAppParams(t, configService, dockerClient, screen, screenCaptureC, logger)).Run(ctx))
 	}()
 
 	require.EventuallyWithT(
@@ -472,7 +471,7 @@ func TestIntegrationRestartDestination(t *testing.T) {
 			done <- struct{}{}
 		}()
 
-		require.NoError(t, app.Run(ctx, buildAppParams(t, configService, dockerClient, screen, screenCaptureC, logger)))
+		require.NoError(t, app.New(buildAppParams(t, configService, dockerClient, screen, screenCaptureC, logger)).Run(ctx))
 	}()
 
 	require.EventuallyWithT(
@@ -609,7 +608,7 @@ func TestIntegrationStartDestinationFailed(t *testing.T) {
 			done <- struct{}{}
 		}()
 
-		require.NoError(t, app.Run(ctx, buildAppParams(t, configService, dockerClient, screen, screenCaptureC, logger)))
+		require.NoError(t, app.New(buildAppParams(t, configService, dockerClient, screen, screenCaptureC, logger)).Run(ctx))
 	}()
 
 	require.EventuallyWithT(
@@ -682,7 +681,7 @@ func TestIntegrationDestinationValidations(t *testing.T) {
 			done <- struct{}{}
 		}()
 
-		require.NoError(t, app.Run(ctx, buildAppParams(t, configService, dockerClient, screen, screenCaptureC, logger)))
+		require.NoError(t, app.New(buildAppParams(t, configService, dockerClient, screen, screenCaptureC, logger)).Run(ctx))
 	}()
 
 	require.EventuallyWithT(
@@ -824,7 +823,7 @@ func TestIntegrationStartupCheck(t *testing.T) {
 			done <- struct{}{}
 		}()
 
-		require.NoError(t, app.Run(ctx, buildAppParams(t, configService, dockerClient, screen, screenCaptureC, logger)))
+		require.NoError(t, app.New(buildAppParams(t, configService, dockerClient, screen, screenCaptureC, logger)).Run(ctx))
 	}()
 
 	require.EventuallyWithT(
@@ -893,7 +892,7 @@ func TestIntegrationMediaServerError(t *testing.T) {
 			done <- struct{}{}
 		}()
 
-		require.NoError(t, app.Run(ctx, buildAppParams(t, configService, dockerClient, screen, screenCaptureC, logger)))
+		require.NoError(t, app.New(buildAppParams(t, configService, dockerClient, screen, screenCaptureC, logger)).Run(ctx))
 	}()
 
 	require.EventuallyWithT(
@@ -934,7 +933,7 @@ func TestIntegrationDockerClientError(t *testing.T) {
 
 		require.EqualError(
 			t,
-			app.Run(ctx, buildAppParams(t, configService, &dockerClient, screen, screenCaptureC, logger)),
+			app.New(buildAppParams(t, configService, &dockerClient, screen, screenCaptureC, logger)).Run(ctx),
 			"create container client: network create: boom",
 		)
 	}()
@@ -974,7 +973,7 @@ func TestIntegrationDockerConnectionError(t *testing.T) {
 			done <- struct{}{}
 		}()
 
-		err := app.Run(ctx, buildAppParams(t, configService, dockerClient, screen, screenCaptureC, logger))
+		err := app.New(buildAppParams(t, configService, dockerClient, screen, screenCaptureC, logger)).Run(ctx)
 		require.ErrorContains(t, err, "dial tcp: lookup docker.example.com")
 		require.ErrorContains(t, err, "no such host")
 	}()
@@ -1070,7 +1069,7 @@ func TestIntegrationCopyURLs(t *testing.T) {
 					done <- struct{}{}
 				}()
 
-				require.NoError(t, app.Run(ctx, buildAppParams(t, configService, dockerClient, screen, screenCaptureC, logger)))
+				require.NoError(t, app.New(buildAppParams(t, configService, dockerClient, screen, screenCaptureC, logger)).Run(ctx))
 			}()
 
 			time.Sleep(3 * time.Second)
diff --git a/main.go b/main.go
index 5f07d37..23420a5 100644
--- a/main.go
+++ b/main.go
@@ -97,22 +97,21 @@ func run(ctx context.Context) error {
 		return fmt.Errorf("read build info: %w", err)
 	}
 
-	return app.Run(
-		ctx,
-		app.RunParams{
-			ConfigService:      configService,
-			DockerClient:       dockerClient,
-			ClipboardAvailable: clipboardAvailable,
-			ConfigFilePath:     configService.Path(),
-			BuildInfo: domain.BuildInfo{
-				GoVersion: buildInfo.GoVersion,
-				Version:   version,
-				Commit:    commit,
-				Date:      date,
-			},
-			Logger: logger,
+	app := app.New(app.Params{
+		ConfigService:      configService,
+		DockerClient:       dockerClient,
+		ClipboardAvailable: clipboardAvailable,
+		ConfigFilePath:     configService.Path(),
+		BuildInfo: domain.BuildInfo{
+			GoVersion: buildInfo.GoVersion,
+			Version:   version,
+			Commit:    commit,
+			Date:      date,
 		},
-	)
+		Logger: logger,
+	})
+
+	return app.Run(ctx)
 }
 
 // editConfigFile opens the config file in the user's editor.