After commit c018f5fd29 Fenrir is being switched over to go. If you do not want to be part of the testing process, please switch to master.
This commit is contained in:
1 parent
c018f5fd29
commit
0e50de4c76
1047 files changed
+58364
-63747
No files matched your search
@@ -0,0 +1,331 @@
|
||||
// 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 Settings struct {
|
||||
Version int `toml:"version"`
|
||||
Presentation Presentation `toml:"presentation" label:"Presentation"`
|
||||
Keyboard Keyboard `toml:"keyboard" label:"Keyboard"`
|
||||
Reading Reading `toml:"reading" label:"Reading"`
|
||||
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"`
|
||||
Layout string `toml:"layout" label:"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{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: 80, 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")
|
||||
}
|
||||
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 follows Python Fenrir's selection: root uses the system file; a normal
|
||||
// user first checks their personal file, then the system file. Sudo also uses
|
||||
// the system settings, just as Python does.
|
||||
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
|
||||
}
|
||||
|
||||
// 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 {
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0700); 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
|
||||
}
|
||||
if err := file.Sync(); err != nil {
|
||||
file.Close()
|
||||
return err
|
||||
}
|
||||
if err := file.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(file.Name(), path)
|
||||
}
|
||||
Reference in new issue
Block a user