// 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" "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/session" "git.stormux.org/storm/cthulhu/internal/platform/wayland" "git.stormux.org/storm/cthulhu/internal/platform/xorg" "git.stormux.org/storm/cthulhu/internal/review" "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 LaptopLayout 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 terminalRepaintDebounce = 500 * time.Millisecond objectReviewCaptureTimeout = 2 * time.Second flatReviewCaptureTimeout = 10 * time.Second documentCaptureTimeout = 15 * 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 } savedSettings := settings if options.LaptopLayout { settings = laptopSettings(settings) logger.Info("using temporary laptop keyboard layout", "keyboard_layout", settings.KeyboardLayout, "cthulhu_modifier_keys", settings.CthulhuModifierKeys) } if options.newAdapter == nil { options.newAdapter = func() Adapter { adapter := atspi.New() adapter.SetCaptureLogger(logger) return adapter } } if options.newCommands == nil { if session.Wayland() { keyboard := wayland.New(settings.CthulhuModifierKeys, input.KeyboardLayout(settings.KeyboardLayout), logger) options.newCommands = func() commandListener { return keyboard } if options.newInput == nil { options.newInput = func() InputListener { return keyboard.Monitor() } } } else { 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} if source, ok := adapter.(interface{ EditFieldFocused() bool }); ok { live.editFieldFocused = source.EditFieldFocused } live.Store(settings) if browser != nil { browser.settings = func() review.ModeSettings { settings := review.DefaultModeSettings() settings.StructuralTriggersFocus = live.Load().StructuralNavigationTriggersFocusMode return 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.capitalization = func() (bool, bool) { settings := live.Load() return settings.BeepForCapitals, settings.SayCapForCapitals } speechMessages.inputActivity = func() input.Activity { return recentInput(inputListener) } 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 focusGate.remotePolicy = tentacle.NewRemotePolicy() focusGate.sync_remote_policy() tentacles.remotePolicy = focusGate.remotePolicy services = append(services, lifecycle.Service{Name: "tentacles", Start: tentacles.Start, Stop: tentacles.Stop}) } preferences := newPreferencesWorker(logger, savedSettings, settingsUpdater) preferences.laptopLayout = options.LaptopLayout preferences.settings = settings preferences.tentacles = tentacles if tentacles != nil { tentacles.readerSettings = preferences } preferences.live = live // Publish the startup active/global profile's Wine values; later global // applies republish them through the preferences worker. live.StoreGlobalWine(savedSettings.Wine) 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 preferences.preferencesProcess, _ = adapter.(interface{ SetPreferencesProcess(int) }) 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) } preferences.announceEchoPreview = func(label, value string) { speechOutput.Enqueue(echo_preview_speech(label, value)) } 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) }