Files

372 lines
16 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 config loads Fenrir's typed TOML settings without reading Python INI files.
package config
import (
"bytes"
"errors"
"fmt"
"git.stormux.org/storm/fenrir/internal/input"
"git.stormux.org/storm/fenrir/internal/presentation"
"git.stormux.org/storm/fenrir/internal/prompt"
"git.stormux.org/storm/fenrir/internal/punctuation"
"git.stormux.org/storm/fenrir/internal/sound"
"git.stormux.org/storm/fenrir/internal/speech"
"io"
"os"
"path/filepath"
"strings"
"time"
"github.com/pelletier/go-toml/v2"
)
const SystemPath = "/etc/fenrirscreenreader/settings/settings.toml"
// Review options are file-only and take effect at launch.
type Review struct {
LineBreak bool `toml:"line_break"`
EndOfScreen bool `toml:"end_of_screen"`
LeaveReviewOnCursorChange bool `toml:"leave_review_on_cursor_change"`
LeaveReviewOnScreenChange bool `toml:"leave_review_on_screen_change"`
}
type InternalUI struct {
AnnounceGridCoordinates bool `toml:"announce_grid_coordinates" label:"Announce grid row and column"`
AnnounceItemPosition bool `toml:"announce_item_position" label:"Announce item position"`
}
type Settings struct {
Version int `toml:"version"`
Presentation Presentation `toml:"presentation" label:"Presentation"`
Keyboard Keyboard `toml:"keyboard" label:"Keyboard"`
Reading Reading `toml:"reading" label:"Reading"`
InternalUI InternalUI `toml:"internal_ui" label:"Internal UI"`
Review Review `toml:"review"`
Screen Screen `toml:"screen"`
Clipboard Clipboard `toml:"clipboard" label:"Clipboard"`
General General `toml:"general"`
Scripts Scripts `toml:"scripts"`
Remote Remote `toml:"remote"`
Prompt Prompt `toml:"prompt"`
}
// Screen policy is file-only; VT numbers apply only to the console backend.
type Screen struct {
IgnoredVTs []int `toml:"ignored_vts"`
}
// Prompt customization is file-only, using Go regular expressions.
type Prompt struct {
ExactMatches []string `toml:"exact_matches"`
CustomPatterns []string `toml:"custom_patterns"`
}
// These sections are file-only until a dedicated scripts UI is introduced.
type General struct {
ScriptPath string `toml:"script_path"`
ScriptKeys []string `toml:"script_keys"`
}
type Scripts struct {
Workers int `toml:"workers"`
QueueLimit int `toml:"queue_limit"`
OutputBytes int `toml:"output_bytes"`
TimeoutSeconds int `toml:"timeout_seconds"`
}
type Remote struct {
Enabled bool `toml:"enabled"`
SocketFile string `toml:"socket_file"`
EnableCommandRemote bool `toml:"enable_command_remote"`
EnableSettingsRemote bool `toml:"enable_settings_remote"`
}
type Presentation struct {
Speech Speech `toml:"speech" label:"Speech"`
Sound Sound `toml:"sound" label:"Sound"`
Progress Progress `toml:"progress"`
DateTime DateTime `toml:"time_and_date" label:"Time and date"`
}
type DateTime struct {
TimeFormat string `toml:"time_format" label:"Time format" choices:"time-format"`
DateFormat string `toml:"date_format" label:"Date format" choices:"date-format"`
}
type Clipboard struct {
Sync bool `toml:"sync" label:"Clipboard sync"`
Persistent bool `toml:"persistent" label:"Enable Persistent Clipboard"`
SyncDisplay string `toml:"sync_display"`
NumberOfClipboards int `toml:"number_of_clipboards" label:"History items" min:"1" max:"10000" step:"1"`
ExportPath string `toml:"export_path"`
}
type Progress struct {
Enabled bool `toml:"enabled"`
}
type Sound struct {
Enabled bool `toml:"enabled" label:"Sound enabled"`
Volume float64 `toml:"volume" label:"Volume" min:"0" max:"1" step:"0.1"`
Driver string `toml:"driver" label:"Driver" choices:"sound-driver"`
Theme string `toml:"theme" label:"Select sound theme" choices:"sound-theme"`
Device string `toml:"device"`
}
type Speech struct {
Enabled bool `toml:"enabled" label:"Speech enabled"`
Rate int `toml:"rate" label:"Rate" min:"-100" max:"100" step:"1" quick:"speech rate"`
Driver string `toml:"driver" label:"Driver" choices:"speech-driver"`
HardwareDevice string `toml:"hardware_device" label:"Serial port" speech:"serial"`
HardwareBaudRate int `toml:"hardware_baud_rate" label:"Baud rate" choices:"speech-baud" speech:"serial"`
Module string `toml:"module" label:"Speech Dispatcher module" choices:"speech-module" quick:"Speech Dispatcher module" speech:"modules"`
Language string `toml:"language" label:"Speech language" choices:"speech-language" quick:"speech language" speech:"voices"`
Voice string `toml:"voice" label:"Speech voice" choices:"speech-voice" quick:"speech voice" speech:"voices"`
Pitch int `toml:"pitch" label:"Pitch" min:"-100" max:"100" step:"1" quick:"speech pitch"`
Volume int `toml:"volume" label:"Volume" min:"-100" max:"100" step:"1" quick:"speech volume"`
}
type Keyboard struct {
MultiPressTimeoutMS int `toml:"multi_press_timeout_ms"`
Devices []string `toml:"devices" label:"Devices"`
FenrirKeys []string `toml:"fenrir_keys,omitempty"`
Layout string `toml:"layout" label:"Layout" choices:"keyboard-layout"`
CharacterEcho bool `toml:"character_echo" label:"Character echo" quick:"Character echo"`
WordEcho bool `toml:"word_echo" label:"Word echo" quick:"Word echo"`
EchoOnCapsLock bool `toml:"echo_on_capslock" label:"Echo on CapsLock" quick:"Echo on CapsLock"`
}
type Reading struct {
Automatic bool `toml:"automatic" label:"Automatic reading"`
CursorEcho bool `toml:"cursor_echo" label:"Cursor echo"`
Mode string `toml:"mode" label:"Tracking mode" choices:"tracking-mode"`
PunctuationProfile string `toml:"punctuation_profile" label:"Punctuation profile"`
PunctuationLevel string `toml:"punctuation_level" label:"Punctuation level" choices:"punctuation-level" quick:"Punctuation level"`
RaisePitchForCapitals bool `toml:"raise_pitch_for_capitals" label:"Raise pitch for capital letters"`
BeepForCapitals bool `toml:"beep_for_capitals" label:"Beep for capital letters"`
SayCapForCapitals bool `toml:"say_cap_for_capitals" label:"Say “cap” before capital letters"`
Indentation string `toml:"indentation" label:"Indentation" choices:"indentation" quick:"Indentation"`
Attributes string `toml:"attributes" label:"Attributes" choices:"attributes" quick:"Attributes"`
AttributeFormat string `toml:"attribute_format" label:"Attribute format"`
Spellcheck bool `toml:"spellcheck" label:"Automatic spell checking"`
SpellcheckReadWord bool `toml:"spellcheck_read_word" label:"Read misspelled word"`
SpellcheckSayMisspelled bool `toml:"spellcheck_say_misspelled" label:"Say “misspelled”"`
SpellcheckSound bool `toml:"spellcheck_sound" label:"Misspelling sound"`
Emoticons bool `toml:"emoticons" label:"Emoticons"`
RepeatedSymbols bool `toml:"repeated_symbols" label:"Repeated symbols"`
RepeatedSymbolsMin int `toml:"repeated_symbols_min" label:"Repeated symbols minimum length" min:"2" max:"10000" step:"1"`
}
func Defaults() Settings {
return Settings{InternalUI: InternalUI{AnnounceGridCoordinates: true, AnnounceItemPosition: true}, Screen: Screen{IgnoredVTs: []int{}}, Review: Review{LineBreak: true, EndOfScreen: true, LeaveReviewOnCursorChange: true, LeaveReviewOnScreenChange: true}, Prompt: Prompt{ExactMatches: []string{}, CustomPatterns: []string{}}, Version: 3, Presentation: Presentation{DateTime: DateTime{TimeFormat: "locale", DateFormat: "locale"}, Progress: Progress{Enabled: true}, Sound: Sound{Enabled: true, Volume: 0.7, Driver: "pulse", Theme: "default", Device: "default"}, Speech: Speech{Enabled: true, Rate: 0, Volume: 100, Driver: "speech-dispatcher"}}, Keyboard: Keyboard{MultiPressTimeoutMS: int(input.DefaultMultiPressTimeout / time.Millisecond), Layout: "desktop", CharacterEcho: true}, Reading: Reading{Emoticons: true, RepeatedSymbolsMin: 4, Spellcheck: true, SpellcheckSound: true, Attributes: "none", AttributeFormat: presentation.Default_attribute_format, Indentation: "none", RaisePitchForCapitals: true, Automatic: true, CursorEcho: true, Mode: "cursor", PunctuationProfile: "default", PunctuationLevel: "some"}, Clipboard: Clipboard{NumberOfClipboards: 200, ExportPath: "/tmp/fenrirClipboard"}, General: General{ScriptPath: "/usr/share/fenrirscreenreader/scripts", ScriptKeys: []string{"KEY_COMPOSE"}}, Scripts: Scripts{Workers: 2, QueueLimit: 16, OutputBytes: 1 << 20}, Remote: Remote{Enabled: true, EnableCommandRemote: true, EnableSettingsRemote: true}}
}
func (s Settings) Validate() error {
if err := sound.Validate_selection(s.Presentation.Sound.Driver, s.Presentation.Sound.Device); err != nil {
return err
}
if err := sound.Validate_theme(s.Presentation.Sound.Theme); err != nil {
return err
}
seenVTs := make(map[int]bool)
for _, number := range s.Screen.IgnoredVTs {
if number < 1 || number > 63 || seenVTs[number] {
return fmt.Errorf("screen.ignored_vts must contain distinct VT numbers between 1 and 63")
}
seenVTs[number] = true
}
if s.Keyboard.MultiPressTimeoutMS < 1 || s.Keyboard.MultiPressTimeoutMS > 5000 {
return fmt.Errorf("keyboard.multi_press_timeout_ms must be between 1 and 5000")
}
if _, err := prompt.New(s.Prompt.ExactMatches, s.Prompt.CustomPatterns); err != nil {
return err
}
if err := punctuation.Validate_name(s.Reading.PunctuationProfile); err != nil {
return err
}
if s.Scripts.Workers < 1 || s.Scripts.Workers > 32 || s.Scripts.QueueLimit < 1 || s.Scripts.QueueLimit > 1024 || s.Scripts.OutputBytes < 1 || s.Scripts.OutputBytes > 4<<20 || s.Scripts.TimeoutSeconds < 0 || s.Scripts.TimeoutSeconds > 86400 {
return fmt.Errorf("invalid script execution limits")
}
if s.Keyboard.FenrirKeys != nil {
if err := input.Validate_modifier_keys(s.Keyboard.FenrirKeys, s.General.ScriptKeys); err != nil {
return err
}
}
for _, key := range s.General.ScriptKeys {
if _, ok := input.Key_code(key); !ok {
return fmt.Errorf("invalid script key %q", key)
}
}
if strings.ContainsAny(s.General.ScriptPath, "\x00\r\n") || strings.ContainsAny(s.Remote.SocketFile, "\x00\r\n") {
return fmt.Errorf("invalid script or remote path")
}
if err := validate_definitions(s); err != nil {
return err
}
if s.Clipboard.NumberOfClipboards < 1 || s.Clipboard.NumberOfClipboards > 10000 {
return fmt.Errorf("number_of_clipboards must be between 1 and 10000")
}
if strings.ContainsAny(s.Clipboard.SyncDisplay, "\x00\r\n") || strings.TrimSpace(s.Clipboard.ExportPath) == "" || strings.ContainsAny(s.Clipboard.ExportPath, "\x00\r\n") {
return fmt.Errorf("invalid clipboard display or export path")
}
if s.Version != 3 {
return fmt.Errorf("unsupported settings version %d (expected 3)", s.Version)
}
selected := s.Presentation.Speech
if err := speech.Validate_driver_options(selected.Driver, speech.Options{Module: selected.Module, Language: selected.Language, Voice: selected.Voice, Hardware: speech.HardwareOptions{Device: selected.HardwareDevice, BaudRate: selected.HardwareBaudRate}}); err != nil {
return err
}
for _, path := range s.Keyboard.Devices {
if strings.TrimSpace(path) == "" || strings.ContainsRune(path, 0) {
return fmt.Errorf("keyboard device path cannot be empty or contain NUL")
}
}
if strings.TrimSpace(s.Keyboard.Layout) == "" || strings.ContainsAny(s.Keyboard.Layout, "\x00\r\n") {
return fmt.Errorf("keyboard layout cannot be empty or contain control characters")
}
extension := filepath.Ext(s.Keyboard.Layout)
if extension != "" && extension != ".toml" || strings.ContainsRune(s.Keyboard.Layout, filepath.Separator) && extension != ".toml" {
return fmt.Errorf("keyboard layout must be a name or a .toml path")
}
if strings.TrimSuffix(s.Keyboard.Layout, ".toml") == "" {
return fmt.Errorf("keyboard layout name cannot be empty")
}
return nil
}
// Paths lists TOML settings in load order. The first path is also the default
// save destination: personal settings for a normal user, system settings for root.
func Paths(home string, uid int) []string {
if uid == 0 {
return []string{SystemPath}
}
return []string{filepath.Join(home, ".local/share/stormux/fenrirscreenreader/settings/settings.toml"), SystemPath}
}
func Decode(data []byte) (Settings, error) {
// Version 1 is migrated in memory only. Disk is changed solely by Save.
var header struct {
Version *int `toml:"version"`
}
if err := toml.Unmarshal(data, &header); err != nil {
return Settings{}, fmt.Errorf("parse settings: %w", err)
}
if header.Version == nil || *header.Version == 1 {
return decode_v1(data)
}
if *header.Version == 2 {
return decode_v2(data)
}
settings := Defaults()
if err := toml.NewDecoder(bytes.NewReader(data)).DisallowUnknownFields().Decode(&settings); err != nil {
return Settings{}, fmt.Errorf("parse settings: %w", err)
}
return settings, settings.Validate()
}
// Load selects one file rather than merging files. An explicitly requested
// file must exist; only absent default files select built-in defaults.
func Load(requested string, paths []string) (Settings, string, error) {
if requested != "" {
paths = []string{requested}
}
for _, path := range paths {
file, err := os.Open(path)
if errors.Is(err, os.ErrNotExist) && requested == "" {
continue
}
if err != nil {
return Settings{}, path, err
}
data, readErr := io.ReadAll(io.LimitReader(file, (1<<20)+1))
closeErr := file.Close()
if err := errors.Join(readErr, closeErr); err != nil {
return Settings{}, path, err
}
if len(data) > 1<<20 {
return Settings{}, path, fmt.Errorf("settings file exceeds 1 MiB")
}
settings, err := Decode(data)
return settings, path, err
}
return Defaults(), "", nil
}
// Load_startup loads settings and returns their save destination. System
// fallback settings seed a user's first save without making /etc the destination.
// An explicit --config path remains the destination; errors name the source.
func Load_startup(requested string, paths []string) (Settings, string, error) {
settings, path, err := Load(requested, paths)
if err == nil && requested == "" && len(paths) != 0 {
path = paths[0]
}
return settings, path, err
}
// Save validates before touching disk and replaces the file atomically. The
// caller retains its last good in-memory settings on a load/save error.
func Save(path string, settings Settings) error {
if err := settings.Validate(); err != nil {
return err
}
data, err := toml.Marshal(settings)
if err != nil {
return err
}
return save_data(path, data)
}
func save_data(path string, data []byte) error {
absolutePath, err := filepath.Abs(path)
if err != nil {
return err
}
if absolutePath == SystemPath {
// System settings also seed unprivileged users' first launch.
return save_data_with_permissions(path, data, 0644, 0755)
}
return save_data_with_permissions(path, data, 0600, 0700)
}
func save_data_with_permissions(path string, data []byte, fileMode, dirMode os.FileMode) error {
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, dirMode); err != nil {
return err
}
file, err := os.CreateTemp(dir, ".settings-*.toml")
if err != nil {
return err
}
defer os.Remove(file.Name())
if _, err := file.Write(data); err != nil {
file.Close()
return err
}
// Keep the temporary file private until its contents are complete. Apply
// permissions before syncing and atomically replacing the destination.
if err := file.Chmod(fileMode); err != nil {
file.Close()
return err
}
if err := file.Sync(); err != nil {
file.Close()
return err
}
if err := file.Close(); err != nil {
return err
}
return os.Rename(file.Name(), path)
}