Files

372 lines
13 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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)
}