372 lines
13 KiB
Go
372 lines
13 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"
|
||
"errors"
|
||
"log/slog"
|
||
"math/rand/v2"
|
||
"time"
|
||
|
||
"git.stormux.org/storm/cthulhu/internal/atspi"
|
||
"git.stormux.org/storm/cthulhu/internal/config"
|
||
"git.stormux.org/storm/cthulhu/internal/input"
|
||
"git.stormux.org/storm/cthulhu/internal/lifecycle"
|
||
"git.stormux.org/storm/cthulhu/internal/logging"
|
||
"git.stormux.org/storm/cthulhu/internal/platform/xorg"
|
||
"git.stormux.org/storm/cthulhu/internal/sound"
|
||
"git.stormux.org/storm/cthulhu/internal/tentacle"
|
||
"git.stormux.org/storm/cthulhu/internal/wine"
|
||
)
|
||
|
||
type Speaker interface {
|
||
Connect(context.Context) error
|
||
Speak(context.Context, string) error
|
||
Cancel(context.Context) error
|
||
Close(context.Context) error
|
||
}
|
||
|
||
type Adapter interface {
|
||
Events() <-chan atspi.Event
|
||
Overflow() uint64
|
||
CaptureDesktop(context.Context) (*atspi.AccessibleTree, error)
|
||
CaptureReviewWindow(context.Context, atspi.ObjectID) (*atspi.AccessibleTree, error)
|
||
CaptureDocument(context.Context, atspi.ObjectID) (*atspi.AccessibleTree, error)
|
||
CaptureDocumentText(context.Context, atspi.ObjectID) (*atspi.AccessibleTree, error)
|
||
Start(context.Context) error
|
||
Stop(context.Context) error
|
||
}
|
||
|
||
type speechEnqueuer interface {
|
||
Enqueue(speechRequest)
|
||
Interrupt()
|
||
}
|
||
|
||
type soundService interface {
|
||
Start(context.Context) error
|
||
Stop(context.Context) error
|
||
Enqueue(sound.Cue) bool
|
||
EnqueueBatch([]sound.Cue) bool
|
||
Available(string) bool
|
||
}
|
||
|
||
type soundSettingsUpdater interface {
|
||
Configure(sound.Configuration)
|
||
}
|
||
|
||
type soundGeometryCaptureConfigurer interface {
|
||
SetCaptureSoundGeometry(bool)
|
||
}
|
||
|
||
type textAttributeCaptureConfigurer interface {
|
||
SetCaptureTextAttributes(bool)
|
||
}
|
||
|
||
func random_string(messages []string) string {
|
||
if len(messages) == 0 {
|
||
return ""
|
||
}
|
||
return messages[rand.IntN(len(messages))]
|
||
}
|
||
|
||
type commandListener interface {
|
||
Commands() <-chan input.CommandEvent
|
||
LockStates() <-chan input.LockState
|
||
Failures() <-chan error
|
||
Start(context.Context) error
|
||
Stop(context.Context) error
|
||
}
|
||
|
||
type foregroundMonitor interface {
|
||
Foreground() atspi.ForegroundState
|
||
ForegroundChanges() <-chan struct{}
|
||
}
|
||
|
||
type xtermFocusMonitor interface {
|
||
FocusChanges() <-chan xorg.FocusState
|
||
}
|
||
|
||
type Options struct {
|
||
TentacleFile string
|
||
TentacleRegistry string
|
||
TentacleCgroupParent string
|
||
EnableRemote bool
|
||
OpenPreferences bool
|
||
newCommands func() commandListener
|
||
onPreferences func()
|
||
newSound func() soundService
|
||
Logger *slog.Logger
|
||
SpeechSocket string
|
||
Settings *config.Settings
|
||
newSpeaker func() Speaker
|
||
newAdapter func() Adapter
|
||
newInput func() InputListener
|
||
onNotificationList func()
|
||
onSpeechHistory func()
|
||
onSpeechMonitor func()
|
||
}
|
||
|
||
type InputListener interface {
|
||
Events() <-chan input.Event
|
||
ModifierKeycodes() input.ModifierKeycodes
|
||
Overflow() uint64
|
||
Start(context.Context) error
|
||
Stop(context.Context) error
|
||
}
|
||
|
||
const (
|
||
caretDebounce = 150 * time.Millisecond
|
||
terminalMutationDebounce = 50 * time.Millisecond
|
||
objectReviewCaptureTimeout = 2 * time.Second
|
||
flatReviewCaptureTimeout = 10 * time.Second
|
||
)
|
||
|
||
var startupMessages = []string{
|
||
"Cthulhu welcomes you!",
|
||
"Face the thing that should not be!",
|
||
"The stars came right again.",
|
||
}
|
||
|
||
var shutdownMessages = []string{
|
||
"Cthulhu lurks beneath the waves!",
|
||
"He watches, lurking beneath the sea.",
|
||
"In his house at R’lyeh dead Cthulhu waits dreaming.",
|
||
}
|
||
|
||
const (
|
||
farewellTimeout = 2 * time.Second
|
||
shutdownTimeout = 3 * time.Second
|
||
soundStopTimeout = 3 * time.Second
|
||
)
|
||
|
||
func Run(ctx context.Context, options Options) error {
|
||
ctx, stopRun := context.WithCancel(ctx)
|
||
defer stopRun()
|
||
logger := options.Logger
|
||
if logger == nil {
|
||
logger = logging.New(nil, logging.Options{})
|
||
}
|
||
settings := config.DefaultSettings()
|
||
if options.Settings != nil {
|
||
settings = *options.Settings
|
||
}
|
||
if options.newAdapter == nil {
|
||
options.newAdapter = func() Adapter { return atspi.New() }
|
||
}
|
||
|
||
if options.newCommands == nil {
|
||
options.newCommands = func() commandListener {
|
||
return xorg.NewCommands(settings.CthulhuModifierKeys, input.KeyboardLayout(settings.KeyboardLayout))
|
||
}
|
||
}
|
||
commands := options.newCommands()
|
||
if updater, ok := commands.(interface {
|
||
SetKeyBindings(context.Context, []input.BindingOverride) error
|
||
}); ok {
|
||
if err := updater.SetKeyBindings(ctx, settings.KeyBindings); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
focusGate := newFocusGate(commands)
|
||
var focusChanges <-chan xorg.FocusState
|
||
if monitor, ok := commands.(xtermFocusMonitor); ok {
|
||
focusChanges = monitor.FocusChanges()
|
||
}
|
||
adapter := options.newAdapter()
|
||
if configurer, ok := adapter.(soundGeometryCaptureConfigurer); ok {
|
||
configurer.SetCaptureSoundGeometry(captureSoundGeometry(settings))
|
||
}
|
||
if configurer, ok := adapter.(textAttributeCaptureConfigurer); ok {
|
||
configurer.SetCaptureTextAttributes(captureTextAttributes(settings))
|
||
}
|
||
backends, err := newRuntimeBackends(&options, settings, logger, adapter)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
speechOutput := backends.speechOutput
|
||
brailleOutput := backends.brailleOutput
|
||
focusGate.braille = brailleOutput
|
||
speechMessages := backends.speechMessages
|
||
history := backends.history
|
||
settingsUpdater := backends.settingsUpdater
|
||
inputListener := backends.inputListener
|
||
sounds := backends.sounds
|
||
services := backends.services
|
||
services = append(services, desktopInputService("commands", commands, logger))
|
||
services = append(services, lifecycle.Service{Name: "atspi", Start: adapter.Start, Stop: adapter.Stop})
|
||
var wineReview *wine.ReviewRegistry
|
||
if options.EnableRemote {
|
||
wineReview = wine.NewReviewRegistry(logger)
|
||
}
|
||
var wineCapture wineReviewCapturer
|
||
if wineReview != nil {
|
||
wineCapture = wineReview
|
||
}
|
||
objectReview, browser, reviewServices, err := newRuntimeReviewServices(logger, commands, adapter, inputListener, focusGate, speechOutput, speechMessages, brailleOutput, wineCapture)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
services = append(services, reviewServices...)
|
||
live := &liveSettings{clipboardChanges: make(chan struct{}, 1), wineReview: wineReview}
|
||
live.Store(settings)
|
||
if pointAdapter, ok := adapter.(pointReviewAdapter); ok {
|
||
services = append(services, mouseReviewService(logger, pointAdapter,
|
||
gatedSpeech{speech: speechOutput, gate: focusGate}, brailleOutput, live, focusGate))
|
||
}
|
||
if speechMessages != nil {
|
||
speechMessages.reading = func() config.ReadingSettings { return live.Load().Reading }
|
||
speechMessages.verbosity = func() int { return live.Load().SpeechVerbosityLevel }
|
||
speechMessages.indentationCue = func(cue sound.Cue) {
|
||
if sounds != nil {
|
||
sounds.EnqueueBatch([]sound.Cue{cue})
|
||
}
|
||
}
|
||
speechMessages.textTransform = func(text string) string {
|
||
settings := live.Load()
|
||
return pronunciationSpeechText(readingSpeechText(text, settings.Reading), settings.Pronunciations)
|
||
}
|
||
}
|
||
ocrSession := new_ocr_session(logger, commands, adapter, objectReview, speechOutput, speechMessages, brailleOutput, focusGate, live)
|
||
brailleOutput.ocr = ocrSession
|
||
brailleOutput.document = objectReview
|
||
focusGate.ocr = ocrSession
|
||
services = append(services, lifecycle.Service{Name: "OCR", Start: ocrSession.Start, Stop: ocrSession.Stop})
|
||
var tentacles *tentacleRuntime
|
||
if options.TentacleFile != "" || options.TentacleRegistry != "" {
|
||
tentacles = newTentacleRuntime(logger, commands, options.TentacleFile, options.TentacleCgroupParent)
|
||
if options.TentacleFile == "" {
|
||
tentacles.registry = &tentacle.Registry{Path: options.TentacleRegistry}
|
||
tentacles.requests = make(chan tentacleManagementCall, 16)
|
||
tentacles.snapshot.Message = "Loading tentacle configuration."
|
||
}
|
||
tentacles.output = &tentacleOutput{speech: speechMessages, braille: brailleOutput, gate: focusGate, live: live}
|
||
focusGate.tentacleOutput = tentacles.output
|
||
services = append(services, lifecycle.Service{Name: "tentacles", Start: tentacles.Start, Stop: tentacles.Stop})
|
||
}
|
||
preferences := newPreferencesWorker(logger, settings, settingsUpdater)
|
||
preferences.tentacles = tentacles
|
||
preferences.live = live
|
||
live.onApplication = preferences.SetApplication
|
||
preferences.bindings, _ = commands.(interface {
|
||
SetKeyBindings(context.Context, []input.BindingOverride) error
|
||
})
|
||
preferences.history = history
|
||
preferences.speechWorker = speechMessages
|
||
preferences.braille = brailleOutput
|
||
preferences.documentReview = objectReview
|
||
objectReview.SetDocumentBrailleEnabled(settings.EnableBraille)
|
||
if updater, ok := sounds.(soundSettingsUpdater); ok {
|
||
preferences.sound = updater
|
||
}
|
||
if configurer, ok := adapter.(soundGeometryCaptureConfigurer); ok {
|
||
preferences.geometry = configurer
|
||
}
|
||
if configurer, ok := adapter.(textAttributeCaptureConfigurer); ok {
|
||
preferences.textAttributes = configurer
|
||
}
|
||
preferences.socket = options.SpeechSocket
|
||
if options.onPreferences == nil {
|
||
options.onPreferences = preferences.Open
|
||
}
|
||
openPreferences := options.onPreferences
|
||
options.onPreferences = func() {
|
||
ocrSession.Retire()
|
||
openPreferences()
|
||
}
|
||
preferences.announce = func(message string) {
|
||
speechOutput.Enqueue(speechRequest{Text: message, Role: "system", Cause: "speech-setting"})
|
||
brailleOutput.EnqueueMessage(message)
|
||
}
|
||
preferences.announceApply = func(message string) {
|
||
speechOutput.Enqueue(speechRequest{Text: message, Role: "system", Cause: "preferences-applied", Confirmation: true})
|
||
brailleOutput.FlashConfirmation(message, live.Load().Braille)
|
||
}
|
||
services = append(services, lifecycle.Service{Name: "preferences", Start: preferences.Start, Stop: preferences.Stop})
|
||
|
||
notifications, onNotificationList, notificationServices := newNotificationRuntime(logger, speechOutput, brailleOutput, live, options.onNotificationList)
|
||
options.onNotificationList = onNotificationList
|
||
services = append(services, notificationServices...)
|
||
if history != nil && (options.onSpeechHistory == nil || options.onSpeechMonitor == nil) {
|
||
windows := newSpeechHistoryWindows(logger, history, outputQueue{
|
||
speech: gatedSpeech{speech: speechOutput, gate: focusGate},
|
||
braille: brailleOutput, live: live,
|
||
}, focusGate)
|
||
if options.onSpeechHistory == nil {
|
||
options.onSpeechHistory = windows.Open
|
||
}
|
||
preferences.refreshHistory = windows.Refresh
|
||
if options.onSpeechMonitor == nil {
|
||
options.onSpeechMonitor = windows.ToggleMonitor
|
||
}
|
||
services = append(services, lifecycle.Service{Name: "speech history", Start: windows.Start, Stop: windows.Stop})
|
||
}
|
||
if options.EnableRemote {
|
||
services = append(services, newRemoteRuntimeServices(remoteServiceDependencies{
|
||
logger: logger, stopRun: stopRun, adapter: adapter, inputListener: inputListener,
|
||
speech: speechMessages, braille: brailleOutput, live: live,
|
||
preferences: preferences, onPreferences: options.onPreferences,
|
||
})...)
|
||
}
|
||
runner, err := lifecycle.NewWithLogger(logger, services...)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if err := runner.Start(ctx); err != nil {
|
||
return err
|
||
}
|
||
if settings.EnableBraille {
|
||
if err := brailleOutput.Start(); err != nil {
|
||
logger.Warn("start braille", "error", err)
|
||
}
|
||
}
|
||
if options.OpenPreferences && options.onPreferences != nil {
|
||
options.onPreferences()
|
||
}
|
||
|
||
presentationOptions := presentationSettingsFromConfig(settings, true)
|
||
presentationOptions.SoundCueAvailable = sounds.Available
|
||
if soundConfiguration(settings).Enabled {
|
||
sounds.Enqueue(sound.Cue{Name: "start"})
|
||
}
|
||
if speechMessages != nil && !focusGate.Locked() {
|
||
speechMessages.EnqueueStartup(speechRequest{Text: random_string(startupMessages), Role: "system", Cause: "startup"})
|
||
}
|
||
commandWorker := commandRuntime{
|
||
logger: logger, commands: commands, focusChanges: focusChanges, focusGate: focusGate,
|
||
speechMessages: speechMessages, speechOutput: speechOutput,
|
||
browser: browser, preferences: preferences, live: live,
|
||
onPreferences: options.onPreferences, onSpeechHistory: options.onSpeechHistory,
|
||
onSpeechMonitor: options.onSpeechMonitor, onNotificationList: options.onNotificationList,
|
||
tentacles: tentacles, ocr: ocrSession, objectReview: objectReview, notifications: notifications, stopRun: stopRun,
|
||
}
|
||
commandWorker.actionSource, _ = adapter.(accessibleActionSource)
|
||
workers := startRuntimeWorkers(ctx, commandWorker, inputListener, settings, backends.copyChanges, brailleOutput)
|
||
var eventInput any = inputListener
|
||
if browser != nil {
|
||
eventInput = browser
|
||
}
|
||
runError := processEventsWithAttributes(ctx, logger, outputQueue{speech: gatedSpeech{speech: speechOutput, gate: focusGate}, sounds: sounds, braille: brailleOutput, live: live}, adapter, workers.reviewRequests, workers.attributeRequests, objectReview, caretDebounce, live, eventInput, focusGate, notifications, ocrSession, presentationOptions)
|
||
stopError := stopRuntime(runtimeShutdown{
|
||
logger: logger, cancel: stopRun, workers: workers,
|
||
tentacles: tentacles, ocr: ocrSession, preferences: preferences, braille: brailleOutput, speech: speechMessages,
|
||
focusGate: focusGate, services: runner,
|
||
})
|
||
return errors.Join(runError, workers.commandError, stopError)
|
||
}
|