Files
cthulhu/internal/app/runtime_backends.go

132 lines
5.0 KiB
Go

// Cthulhu screen reader.
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
//
// This program is free software: you can redistribute it and/or modify it
// under the terms of the GNU General Public License as published by the Free
// Software Foundation, either version 3 of the License, or (at your option)
// any later version.
//
// This program is distributed in the hope that it will be useful, but WITHOUT
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
// more details.
//
// You should have received a copy of the GNU General Public License along with
// this program. If not, see <https://www.gnu.org/licenses/gpl-3.0.html>.
package app
import (
"context"
"log/slog"
"git.stormux.org/storm/cthulhu/internal/config"
"git.stormux.org/storm/cthulhu/internal/lifecycle"
"git.stormux.org/storm/cthulhu/internal/platform/xorg"
"git.stormux.org/storm/cthulhu/internal/sound"
)
// runtimeBackends carries constructed backends only between assembly and Run.
// The lifecycle runner owns their start/stop order after construction.
type runtimeBackends struct {
speechOutput speechEnqueuer
brailleOutput *brailleWorker
speechMessages *speechWorker
history *speechHistory
settingsUpdater any
inputListener InputListener
copyChanges <-chan struct{}
sounds soundService
services []lifecycle.Service
}
// newRuntimeBackends registers sound, speech, speech worker, input, and the
// optional clipboard monitor in that order. The speech worker has a lifetime
// independent of the event-loop context so farewell can be delivered.
func newRuntimeBackends(options *Options, settings config.Settings, logger *slog.Logger, adapter Adapter) (*runtimeBackends, error) {
var speechOutput speechEnqueuer = discardSpeech{}
brailleOutput, err := newBrailleWorker(logger)
if err != nil {
return nil, err
}
brailleOutput.router, _ = adapter.(brailleRouter)
brailleOutput.SetContraction(settings.EnableContractedBraille, settings.BrailleContractionTable)
brailleOutput.SetWordWrap(settings.Braille.WordWrap)
backends := &runtimeBackends{
speechOutput: speechOutput,
brailleOutput: brailleOutput,
services: make([]lifecycle.Service, 0, 5),
}
if options.newSound != nil {
backends.sounds = options.newSound()
} else {
backends.sounds = sound.New(logger, sound.NativePlayer{Sink: settings.SoundSink}, settings.SoundTheme, settings.SoundVolume)
}
if updater, ok := backends.sounds.(soundSettingsUpdater); ok {
updater.Configure(soundConfiguration(settings))
}
backends.services = append(backends.services, lifecycle.Service{Name: "sound", Start: backends.sounds.Start, Stop: func(context.Context) error {
soundContext, cancelSound := context.WithTimeout(context.Background(), soundStopTimeout)
defer cancelSound()
logger.Debug("playing shutdown sound")
if err := backends.sounds.Stop(soundContext); err != nil {
logger.Warn("stop sound service", "error", err)
return err
}
logger.Debug("shutdown sound completed")
return nil
}})
if options.newSpeaker == nil {
options.newSpeaker = func() Speaker {
return newConfiguredSpeaker(options.SpeechSocket, settings)
}
}
speaker := options.newSpeaker()
backends.settingsUpdater = speaker
backends.speechMessages = newSpeechWorker(logger, speaker)
backends.speechMessages.SetEnabled(settings.EnableSpeech)
if beeper, ok := backends.sounds.(interface{ SpeechBeep(context.Context) error }); ok {
backends.speechMessages.beep = beeper.SpeechBeep
}
backends.history = newSpeechHistory(settings.SpeechHistoryLimit)
backends.speechMessages.history = backends.history
backends.speechOutput = backends.speechMessages
if options.newInput == nil {
options.newInput = func() InputListener { return xorg.New() }
}
backends.inputListener = options.newInput()
backends.services = append(backends.services,
lifecycle.Service{Name: "speech", Start: func(ctx context.Context) error {
if !settings.EnableSpeech {
return nil
}
return speaker.Connect(ctx)
}, Stop: speaker.Close},
lifecycle.Service{Name: "speech worker", Start: func(context.Context) error {
// The runner owns this worker's lifetime. Keeping it independent of
// the event-loop context lets graceful shutdown queue its farewell.
return backends.speechMessages.Start(context.Background())
}, Stop: backends.speechMessages.Stop},
)
backends.services = append(backends.services,
desktopInputService("X11 listener", backends.inputListener, logger),
)
if _, native := backends.inputListener.(*xorg.Listener); native {
clipboardMonitor := xorg.NewClipboardMonitor()
backends.services = append(backends.services, lifecycle.Service{
Name: "clipboard changes",
Start: func(ctx context.Context) error {
if err := clipboardMonitor.Start(ctx); err != nil {
logger.Warn("copy feedback unavailable", "error", err)
return nil
}
backends.copyChanges = clipboardMonitor.Changes()
return nil
},
Stop: clipboardMonitor.Stop,
})
}
return backends, nil
}