// 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 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) }