// 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 . 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 }