fixup! wip: refactor: API

This commit is contained in:
Rob Watson 2025-05-07 20:32:27 +02:00
parent f67a456d1e
commit 0a6b9fad90
3 changed files with 100 additions and 98 deletions

78
cmd/client/main.go Normal file
View File

@ -0,0 +1,78 @@
package main
import (
"context"
"fmt"
"log/slog"
"os"
"runtime/debug"
"git.netflux.io/rob/octoplex/internal/domain"
"git.netflux.io/rob/octoplex/internal/event"
"git.netflux.io/rob/octoplex/internal/terminal"
"golang.design/x/clipboard"
)
var (
// version is the version of the application.
version string
// commit is the commit hash of the application.
commit string
// date is the date of the build.
date string
)
func main() {
if err := run(); err != nil {
os.Stderr.WriteString("Error: " + err.Error() + "\n")
}
}
func run() error {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// TODO: logger from config
fptr, err := os.OpenFile("octoplex.log", os.O_RDWR|os.O_CREATE|os.O_APPEND, 0666)
if err != nil {
return fmt.Errorf("open log file: %w", err)
}
logger := slog.New(slog.NewTextHandler(fptr, nil))
bus := event.NewBus(logger)
var clipboardAvailable bool
if err = clipboard.Init(); err != nil {
logger.Warn("Clipboard not available", "err", err)
} else {
clipboardAvailable = true
}
buildInfo, ok := debug.ReadBuildInfo()
if !ok {
return fmt.Errorf("read build info: %w", err)
}
ui, err := terminal.StartUI(ctx, terminal.StartParams{
EventBus: bus,
Dispatcher: func(cmd event.Command) {
// TODO: this must call the gRPC client
logger.Info("Command dispatched", "cmd", cmd)
},
ClipboardAvailable: clipboardAvailable,
ConfigFilePath: "TODO",
BuildInfo: domain.BuildInfo{
GoVersion: buildInfo.GoVersion,
Version: version,
Commit: commit,
Date: date,
},
Logger: logger.With("component", "ui"),
})
if err != nil {
return fmt.Errorf("start terminal user interface: %w", err)
}
defer ui.Close()
return nil
}

View File

@ -6,7 +6,6 @@ import (
"errors" "errors"
"flag" "flag"
"fmt" "fmt"
"io"
"log/slog" "log/slog"
"os" "os"
"os/exec" "os/exec"
@ -18,7 +17,6 @@ import (
"git.netflux.io/rob/octoplex/internal/config" "git.netflux.io/rob/octoplex/internal/config"
"git.netflux.io/rob/octoplex/internal/domain" "git.netflux.io/rob/octoplex/internal/domain"
dockerclient "github.com/docker/docker/client" dockerclient "github.com/docker/docker/client"
"golang.design/x/clipboard"
) )
var ( var (
@ -85,31 +83,21 @@ func run() error {
return fmt.Errorf("read or create config: %w", err) return fmt.Errorf("read or create config: %w", err)
} }
headless := os.Getenv("OCTO_HEADLESS") != "" logger, err := buildLogger(cfg.LogFile)
logger, err := buildLogger(cfg.LogFile, headless)
if err != nil { if err != nil {
return fmt.Errorf("build logger: %w", err) return fmt.Errorf("build logger: %w", err)
} }
if headless { // When running in headless mode tview doesn't handle SIGINT for us.
// When running in headless mode tview doesn't handle SIGINT for us. ch := make(chan os.Signal, 1)
ch := make(chan os.Signal, 1) signal.Notify(ch, syscall.SIGINT, syscall.SIGTERM)
signal.Notify(ch, syscall.SIGINT, syscall.SIGTERM)
go func() { go func() {
<-ch <-ch
logger.Info("Received interrupt signal, exiting") logger.Info("Received interrupt signal, exiting")
signal.Stop(ch) signal.Stop(ch)
cancel(errShutdown) cancel(errShutdown)
}() }()
}
var clipboardAvailable bool
if err = clipboard.Init(); err != nil {
logger.Warn("Clipboard not available", "err", err)
} else {
clipboardAvailable = true
}
dockerClient, err := dockerclient.NewClientWithOpts( dockerClient, err := dockerclient.NewClientWithOpts(
dockerclient.FromEnv, dockerclient.FromEnv,
@ -125,11 +113,9 @@ func run() error {
} }
app := app.New(app.Params{ app := app.New(app.Params{
ConfigService: configService, ConfigService: configService,
DockerClient: dockerClient, DockerClient: dockerClient,
Headless: headless, ConfigFilePath: configService.Path(),
ClipboardAvailable: clipboardAvailable,
ConfigFilePath: configService.Path(),
BuildInfo: domain.BuildInfo{ BuildInfo: domain.BuildInfo{
GoVersion: buildInfo.GoVersion, GoVersion: buildInfo.GoVersion,
Version: version, Version: version,
@ -189,32 +175,13 @@ func printUsage() {
os.Stderr.WriteString("\n") os.Stderr.WriteString("\n")
os.Stderr.WriteString("Additionally, Octoplex can be configured with the following environment variables:\n\n") os.Stderr.WriteString("Additionally, Octoplex can be configured with the following environment variables:\n\n")
os.Stderr.WriteString(" OCTO_DEBUG Enables debug logging if set\n") os.Stderr.WriteString(" OCTO_DEBUG Enables debug logging if set\n")
os.Stderr.WriteString(" OCTO_HEADLESS Enables headless mode if set (experimental)\n\n")
} }
// buildLogger builds the logger, which may be a no-op logger. // buildLogger builds the logger, which may be a no-op logger.
func buildLogger(cfg config.LogFile, headless bool) (*slog.Logger, error) { func buildLogger(cfg config.LogFile) (*slog.Logger, error) {
build := func(w io.Writer) *slog.Logger { var handlerOpts slog.HandlerOptions
var handlerOpts slog.HandlerOptions if os.Getenv("OCTO_DEBUG") != "" {
if os.Getenv("OCTO_DEBUG") != "" { handlerOpts.Level = slog.LevelDebug
handlerOpts.Level = slog.LevelDebug
}
return slog.New(slog.NewTextHandler(w, &handlerOpts))
} }
return slog.New(slog.NewTextHandler(os.Stderr, &handlerOpts)), nil
// In headless mode, always log to stderr.
if headless {
return build(os.Stderr), nil
}
if !cfg.Enabled {
return slog.New(slog.DiscardHandler), nil
}
fptr, err := os.OpenFile(cfg.GetPath(), os.O_RDWR|os.O_CREATE|os.O_APPEND, 0666)
if err != nil {
return nil, fmt.Errorf("error opening log file: %w", err)
}
return build(fptr), nil
} }

View File

@ -32,7 +32,6 @@ type App struct {
dispatchC chan event.Command dispatchC chan event.Command
dockerClient container.DockerClient dockerClient container.DockerClient
screen *terminal.Screen // Screen may be nil. screen *terminal.Screen // Screen may be nil.
headless bool
clipboardAvailable bool clipboardAvailable bool
configFilePath string configFilePath string
buildInfo domain.BuildInfo buildInfo domain.BuildInfo
@ -45,7 +44,6 @@ type Params struct {
DockerClient container.DockerClient DockerClient container.DockerClient
ChanSize int ChanSize int
Screen *terminal.Screen // Screen may be nil. Screen *terminal.Screen // Screen may be nil.
Headless bool
ClipboardAvailable bool ClipboardAvailable bool
ConfigFilePath string ConfigFilePath string
BuildInfo domain.BuildInfo BuildInfo domain.BuildInfo
@ -64,7 +62,6 @@ func New(params Params) *App {
dispatchC: make(chan event.Command, cmp.Or(params.ChanSize, defaultChanSize)), dispatchC: make(chan event.Command, cmp.Or(params.ChanSize, defaultChanSize)),
dockerClient: params.DockerClient, dockerClient: params.DockerClient,
screen: params.Screen, screen: params.Screen,
headless: params.Headless,
clipboardAvailable: params.ClipboardAvailable, clipboardAvailable: params.ClipboardAvailable,
configFilePath: params.ConfigFilePath, configFilePath: params.ConfigFilePath,
buildInfo: params.BuildInfo, buildInfo: params.BuildInfo,
@ -83,46 +80,10 @@ func (a *App) Run(ctx context.Context) error {
return errors.New("config: either sources.mediaServer.rtmp.enabled or sources.mediaServer.rtmps.enabled must be set") return errors.New("config: either sources.mediaServer.rtmp.enabled or sources.mediaServer.rtmps.enabled must be set")
} }
if !a.headless {
ui, err := terminal.StartUI(ctx, terminal.StartParams{
EventBus: a.eventBus,
Dispatcher: func(cmd event.Command) { a.dispatchC <- cmd },
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)
}
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() {
a.eventBus.Send(event.AppStateChangedEvent{State: domain.AppState{}})
}
// doFatalError publishes a fatal error to the event bus, waiting for the // doFatalError publishes a fatal error to the event bus, waiting for the
// user to acknowledge it if not in headless mode. // user to acknowledge it if not in headless mode.
doFatalError := func(msg string) { doFatalError := func(msg string) {
a.eventBus.Send(event.FatalErrorOccurredEvent{Message: msg}) a.eventBus.Send(event.FatalErrorOccurredEvent{Message: msg})
if a.headless {
return
}
emptyUI()
<-a.dispatchC
} }
const grpcAddr = ":50051" const grpcAddr = ":50051"
@ -195,15 +156,11 @@ func (a *App) Run(ctx context.Context) error {
defer uiUpdateT.Stop() defer uiUpdateT.Stop()
startMediaServerC := make(chan struct{}, 1) startMediaServerC := make(chan struct{}, 1)
if a.headless { // disable startup check in headless mode for now if ok, startupErr := doStartupCheck(ctx, containerClient, a.eventBus); startupErr != nil {
doFatalError(startupErr.Error())
return startupErr
} else if ok {
startMediaServerC <- struct{}{} startMediaServerC <- struct{}{}
} else {
if ok, startupErr := doStartupCheck(ctx, containerClient, a.eventBus); startupErr != nil {
doFatalError(startupErr.Error())
return startupErr
} else if ok {
startMediaServerC <- struct{}{}
}
} }
for { for {