Compare commits

..

18 Commits

Author SHA1 Message Date
9c42d54a08 fixup! feat: headless mode
Some checks failed
build / lint (push) Failing after 10s
build / build (push) Has been skipped
build / release (push) Has been skipped
2025-05-01 17:42:14 +02:00
06a1501fbe feat: headless mode
Some checks are pending
build / lint (push) Waiting to run
build / build (push) Blocked by required conditions
build / release (push) Blocked by required conditions
2025-04-30 22:47:05 +02:00
750e9432be refactor(app): add Dispatch method 2025-04-30 22:17:19 +02:00
d313c1e020 doc: update README 2025-04-29 22:39:46 +02:00
812d3901d3 refactor(app): internalize dispatch channel 2025-04-29 22:37:05 +02:00
caa543703e refactor: extract commands from domain 2025-04-28 06:32:00 +02:00
8403d751b6 doc: add godoc
Some checks failed
ci-build / lint (push) Has been cancelled
ci-scan / Analyze (go) (push) Has been cancelled
ci-scan / Analyze (actions) (push) Has been cancelled
ci-build / build (push) Has been cancelled
ci-build / release (push) Has been cancelled
2025-04-25 19:19:54 +02:00
2f1cadcf40 refactor(app): add DestinationWentOffAirEvent 2025-04-25 18:10:22 +02:00
1f4a931903 fix(app): event ordering
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
Use a single channel per consumer, instead of one channel per
consumer/event tuple. This ensures that overall ordering of events
remains consistent, and avoids introducing subtle race conditions.
2025-04-25 17:42:49 +02:00
94623248c0 refactor(app): async startup check 2025-04-25 17:42:49 +02:00
4a16780915 fixup! refactor: add event bus 2025-04-25 04:46:32 +02:00
3019387f38 refactor(app): add StartDestinationFailedEvent 2025-04-25 04:44:36 +02:00
f4021a2886 refactor(app): add destination error events 2025-04-24 21:45:53 +02:00
b8550f050b refactor(app): add AppStateChangedEvent 2025-04-24 21:45:53 +02:00
c02a66202f doc: update README 2025-04-24 21:45:53 +02:00
4029c66a4a refactor(app): extract more events 2025-04-24 21:45:53 +02:00
cdf41e47c3 refactor(app): extract handleCommand 2025-04-22 16:28:38 +02:00
d7f8fb49eb refactor(app): add App type 2025-04-22 16:06:37 +02:00
5 changed files with 47 additions and 58 deletions

View File

@ -40,25 +40,12 @@ brews:
system "#{bin}/octoplex -h"
release:
draft: true
github:
owner: rfwatson
name: octoplex
changelog:
use: github
groups:
- title: New Features
regexp: '^.*?feat(\([[:word:]]+\))??!?:.+$'
order: 0
- title: "Bug fixes"
regexp: '^.*?fix(\([[:word:]]+\))??!?:.+$'
order: 1
- title: "Refactorings"
regexp: '^.*?refactor(\([[:word:]]+\))??!?:.+$'
order: 2
- title: Others
order: 999
filters:
exclude:
- "^doc:"

View File

@ -21,12 +21,14 @@ import (
// App is an instance of the app.
type App struct {
cfg config.Config
configService *config.Service
eventBus *event.Bus
dispatchC chan event.Command
dockerClient container.DockerClient
screen *terminal.Screen // Screen may be nil.
cfg config.Config
configService *config.Service
eventBus *event.Bus
dispatchC chan event.Command
dockerClient container.DockerClient
screen *terminal.Screen // Screen may be nil.
// TODO: startup check
// TODO: handle SIGINT
headless bool
clipboardAvailable bool
configFilePath string
@ -107,19 +109,6 @@ func (a *App) Run(ctx context.Context) error {
a.eventBus.Send(event.AppStateChangedEvent{State: domain.AppState{}})
}
// doFatalError publishes a fatal error to the event bus, waiting for the
// user to acknowledge it if not in headless mode.
doFatalError := func(msg string) {
a.eventBus.Send(event.FatalErrorOccurredEvent{Message: msg})
if a.headless {
return
}
emptyUI()
<-a.dispatchC
}
containerClient, err := container.NewClient(ctx, a.dockerClient, a.logger.With("component", "container_client"))
if err != nil {
err = fmt.Errorf("create container client: %w", err)
@ -130,7 +119,10 @@ func (a *App) Run(ctx context.Context) error {
} else {
msg = err.Error()
}
doFatalError(msg)
a.eventBus.Send(event.FatalErrorOccurredEvent{Message: msg})
emptyUI()
<-a.dispatchC
return err
}
defer containerClient.Close()
@ -160,7 +152,9 @@ func (a *App) Run(ctx context.Context) error {
})
if err != nil {
err = fmt.Errorf("create mediaserver: %w", err)
doFatalError(err.Error())
a.eventBus.Send(event.FatalErrorOccurredEvent{Message: err.Error()})
emptyUI()
<-a.dispatchC
return err
}
defer srv.Close()
@ -177,15 +171,13 @@ func (a *App) Run(ctx context.Context) error {
defer uiUpdateT.Stop()
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 {
startupErr = fmt.Errorf("startup check: %w", startupErr)
a.eventBus.Send(event.FatalErrorOccurredEvent{Message: startupErr.Error()})
<-a.dispatchC
return startupErr
} else if ok {
startMediaServerC <- struct{}{}
} else {
if ok, startupErr := doStartupCheck(ctx, containerClient, a.eventBus); startupErr != nil {
doFatalError(startupErr.Error())
return startupErr
} else if ok {
startMediaServerC <- struct{}{}
}
}
for {
@ -210,12 +202,6 @@ func (a *App) Run(ctx context.Context) error {
updateUI()
case serverState := <-srv.C():
a.logger.Debug("Server state received", "state", serverState)
if serverState.ExitReason != "" {
doFatalError(serverState.ExitReason)
return errors.New("media server exited")
}
applyServerState(serverState, state)
updateUI()
case replState := <-repl.C():

View File

@ -892,17 +892,13 @@ func TestIntegrationMediaServerError(t *testing.T) {
done <- struct{}{}
}()
require.EqualError(
t,
app.New(buildAppParams(t, configService, dockerClient, screen, screenCaptureC, logger)).Run(ctx),
"media server exited",
)
require.NoError(t, app.New(buildAppParams(t, configService, dockerClient, screen, screenCaptureC, logger)).Run(ctx))
}()
require.EventuallyWithT(
t,
func(c *assert.CollectT) {
assert.True(c, contentsIncludes(getContents(), "Server process exited unexpectedly."), "expected to see title")
assert.True(c, contentsIncludes(getContents(), "Mediaserver error: Server process exited unexpectedly."), "expected to see title")
assert.True(c, contentsIncludes(getContents(), "address already in use"), "expected to see message")
},
waitTime,

View File

@ -486,6 +486,10 @@ func (ui *UI) captureScreen(screen tcell.Screen) {
func (ui *UI) handleAppStateChanged(evt event.AppStateChangedEvent) {
state := evt.State
if state.Source.ExitReason != "" {
ui.handleMediaServerClosed(state.Source.ExitReason)
}
ui.updatePullProgress(state)
ui.mu.Lock()
@ -680,6 +684,24 @@ func (ui *UI) hideModal(pageName string) {
ui.app.SetFocus(ui.destView)
}
func (ui *UI) handleMediaServerClosed(exitReason string) {
if ui.pages.HasPage(pageNameModalSourceError) {
return
}
modal := tview.NewModal()
modal.SetText("Mediaserver error: " + exitReason).
AddButtons([]string{"Quit"}).
SetBackgroundColor(tcell.ColorBlack).
SetTextColor(tcell.ColorWhite).
SetDoneFunc(func(int, string) {
ui.dispatch(event.CommandQuit{})
})
modal.SetBorderStyle(tcell.StyleDefault.Background(tcell.ColorBlack).Foreground(tcell.ColorWhite))
ui.pages.AddPage(pageNameModalSourceError, modal, true, true)
}
const dash = "—"
const (

View File

@ -95,11 +95,9 @@ func run() error {
// When running in headless mode tview doesn't handle SIGINT for us.
ch := make(chan os.Signal, 1)
signal.Notify(ch, syscall.SIGINT, syscall.SIGTERM)
go func() {
<-ch
logger.Info("Received interrupt signal, exiting")
signal.Stop(ch)
cancel(errShutdown)
}()
}
@ -188,8 +186,8 @@ func printUsage() {
os.Stderr.WriteString(" help Print this help message\n")
os.Stderr.WriteString("\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_HEADLESS Enables headless mode if set (experimental)\n\n")
os.Stderr.WriteString(" OCTO_DEBUG Enables debug logging if set\n\n")
os.Stderr.WriteString(" OCTO_HEADLESS Enables headless mode if set (experimental)\n")
}
// buildLogger builds the logger, which may be a no-op logger.