132 lines
5.0 KiB
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
|
|
}
|