// Fenrir console screen reader. // Copyright (c) 2026 Storm Dragon // SPDX-License-Identifier: GPL-3.0-or-later // // 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" "fmt" "log/slog" "os" "path/filepath" "time" "git.stormux.org/storm/fenrir/internal/audio" "git.stormux.org/storm/fenrir/internal/clipboard" "git.stormux.org/storm/fenrir/internal/command" "git.stormux.org/storm/fenrir/internal/config" "git.stormux.org/storm/fenrir/internal/console" "git.stormux.org/storm/fenrir/internal/control" "git.stormux.org/storm/fenrir/internal/input" "git.stormux.org/storm/fenrir/internal/preferences" "git.stormux.org/storm/fenrir/internal/punctuation" "git.stormux.org/storm/fenrir/internal/scripts" "git.stormux.org/storm/fenrir/internal/sound" "git.stormux.org/storm/fenrir/internal/speech" "git.stormux.org/storm/fenrir/internal/spellcheck" "git.stormux.org/storm/fenrir/internal/terminal" ) // disabledVoice allows sound-only operation without opening a speech driver. type disabledVoice struct{} func (disabledVoice) Say(string) {} func (disabledVoice) Say_after(string, func(context.Context)) {} func (disabledVoice) Say_with_pitch(string, int) {} func (disabledVoice) Configure(speech.Parameters) {} func (disabledVoice) Interrupt() {} func (disabledVoice) Toggle_mute() bool { return true } func (disabledVoice) Errors() <-chan error { return nil } func (disabledVoice) Close() {} func Start(ctx context.Context, settings config.Settings, configPath string, bindings input.Layout) error { return Start_with_ready(ctx, settings, configPath, bindings, nil) } // Start_with_ready reports console startup after the first screen and input poll. // A failed notification tears down the reader and releases its keyboards. func Start_with_ready(ctx context.Context, settings config.Settings, configPath string, bindings input.Layout, ready func() error) error { return start(ctx, settings, false, configPath, bindings, ready) } // Start_terminal runs the same reader and command dispatcher in an X11 PTY. func Start_terminal(ctx context.Context, settings config.Settings, configPath string, bindings input.Layout) error { return start(ctx, settings, true, configPath, bindings, nil) } func start(ctx context.Context, settings config.Settings, terminalMode bool, configPath string, bindings input.Layout, ready func() error) (result error) { if err := settings.Validate(); err != nil { return err } punctuationProcessor, punctuationPath, err := punctuation.Open(settings.Reading.PunctuationProfile) if err != nil { return fmt.Errorf("punctuation profile %s: %w", punctuationPath, err) } // Connect before creating virtual keyboards or acquiring any input grabs. router, err := input.New_router(bindings) if err != nil { return err } if err := router.Configure_modifiers(settings.Keyboard.FenrirKeys, settings.General.ScriptKeys); err != nil { return err } settings.Keyboard.FenrirKeys = router.Fenrir_keys() if err := settings.Validate(); err != nil { return err } if err := router.Set_multi_press_timeout(time.Duration(settings.Keyboard.MultiPressTimeoutMS) * time.Millisecond); err != nil { return err } home, err := os.UserHomeDir() if err != nil { return err } executable, err := os.Executable() if err != nil { return err } spellChecker := spellcheck.New_service(ctx, spellcheck.Options{Language: spellcheck.Language(), ExceptionsPath: spellcheck.Exceptions_path(home)}) defer spellChecker.Close() installedScripts, problems := scripts.Discover(scripts.Paths(home, settings.General.ScriptPath, filepath.Join(filepath.Dir(executable), "config", "scripts")), nil) for _, problem := range problems { slog.Warn("script discovery", "error", problem, "at", time.Now()) } scriptCommands := make(map[command.ID]scripts.Script) var scriptBindings []input.ScriptBinding for _, script := range installedScripts { scriptCommands[script.ID] = script scriptBindings = append(scriptBindings, input.ScriptBinding{ID: script.ID, Keys: script.Keys}) } if err := router.Configure_scripts(settings.General.ScriptKeys, scriptBindings); err != nil { return err } var remote *control.Server if settings.Remote.Enabled { remote, err = control.Open(ctx, control.Options{SocketFile: settings.Remote.SocketFile, TerminalMode: terminalMode, WindowID: os.Getenv("WINDOWID")}) if err != nil { return fmt.Errorf("remote interface: %w (use remote.socket_file for a separate trial instance)", err) } defer remote.Close() } scriptRunner := scripts.New_runner(ctx, scripts.Limits{Workers: settings.Scripts.Workers, Queue: settings.Scripts.QueueLimit, OutputBytes: settings.Scripts.OutputBytes, Timeout: time.Duration(settings.Scripts.TimeoutSeconds) * time.Second}, func(terminal int) string { return script_username(terminalMode, terminal) }) defer scriptRunner.Close() var voices Speaker = disabledVoice{} var effectiveSpeech *config.Speech if settings.Presentation.Speech.Enabled { var client speech.Transport var selected config.Speech waitingForSpeech := false retryContext, cancel := context.WithTimeout(ctx, audio.RecoveryTimeout) err := audio.Retry(retryContext, func(ctx context.Context) error { var err error client, selected, err = open_speech(ctx, settings.Presentation.Speech) if err != nil && !waitingForSpeech && audio.Temporary(err) { waitingForSpeech = true slog.Warn("speech unavailable; waiting before acquiring keyboards", "error", err, "at", time.Now()) } return err }, audio.Temporary) cancel() effectiveSpeech = &selected if err != nil { return err } if waitingForSpeech { slog.Info("speech connection ready", "at", time.Now()) } voices = speech.New(client) voices.Configure(speech_parameters(settings)) } defer voices.Close() var cues SoundPlayer var player *sound.Player if settings.Presentation.Sound.Enabled { home, err := os.UserHomeDir() if err != nil { return err } player = sound.Open_recovering(ctx, sound.Theme_paths(home, settings.Presentation.Sound.Theme), sound.Options{Driver: settings.Presentation.Sound.Driver, Device: settings.Presentation.Sound.Device, Volume: settings.Presentation.Sound.Volume}) if player != nil { defer player.Close() cues = player } } historyStore := clipboard.New_persistence(clipboard.History_path(home, terminalMode), settings.Clipboard.Persistent) defer func() { result = errors.Join(result, historyStore.Close()) }() var source Source var keyboards Keyboards if terminalMode { session, err := terminal.Open(os.Stdin, os.Stdout, router) if err != nil { return err } defer func() { result = errors.Join(result, session.Close()) }() source, keyboards = session, session.Keyboard() } else { consoleSource, err := console.OpenSource() if err != nil { return err } defer consoleSource.Close() source = consoleSource consoleKeys, err := console.Open_keyboards(settings.Keyboard.Devices, router) if err != nil { return err } keyboards = consoleKeys } var paste func(context.Context, string, int) error if consoleSource, ok := source.(*console.Source); ok { paste = consoleSource.Paste } transfers := clipboard.New_worker(ctx, paste) defer transfers.Close() result = Run(ctx, source, keyboards, voices, RunOptions{Ready: ready, Spellcheck: spellChecker, Bindings: bindings.Bindings, Settings: settings, EffectiveSpeech: effectiveSpeech, Punctuation: punctuationProcessor, Sounds: cues, ConfigPath: configPath, Clipboard: transfers, ClipboardHistory: historyStore, SettingsChoices: preferences.Discover_choices, Scripts: scriptCommands, ScriptRunner: scriptRunner, Remote: remote, TerminalMode: terminalMode, SharedNotifications: true}) if result == nil && player != nil { if err := player.Finish(context.Background()); err != nil { slog.Warn("shutdown sound incomplete", "error", err, "at", time.Now()) } } return result }