Files

207 lines
8.5 KiB
Go

// 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 <https://www.gnu.org/licenses/gpl-3.0.html>.
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
}