207 lines
8.5 KiB
Go
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
|
|
}
|