372 lines
16 KiB
Go
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)
|
|
}
|