Files

260 lines
10 KiB
Go

// Cthulhu screen reader.
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
//
// 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
import (
"errors"
"fmt"
"math"
"path/filepath"
"strings"
"unicode"
"git.stormux.org/storm/cthulhu/internal/datetime"
"git.stormux.org/storm/cthulhu/internal/input"
)
// Validate checks the complete configuration without touching the filesystem.
func Validate(settings Settings) error { return validate(settings) }
func validate(settings Settings) error {
if settings.OCR.Layout != 3 && settings.OCR.Layout != 6 && settings.OCR.Layout != 11 ||
settings.OCR.Scale < 1 || settings.OCR.Scale > 4 || settings.OCR.Threshold < 0 || settings.OCR.Threshold > 255 ||
settings.OCR.RefreshInterval < 500 || settings.OCR.RefreshInterval > 60000 ||
len(settings.OCR.Language) > 1024 || strings.ContainsAny(settings.OCR.Language, "\x00\r\n\t ") {
return errors.New("validate configuration: invalid OCR settings")
}
if settings.Braille.Verbosity < 0 || settings.Braille.Verbosity > 1 ||
settings.Braille.FlashTime < 0 || settings.Braille.FlashTime > 60000 {
return errors.New("validate configuration: invalid braille settings")
}
if len(settings.Pronunciations) > 256 {
return errors.New("too many pronunciation entries")
}
words := make(map[string]bool, len(settings.Pronunciations))
for _, entry := range settings.Pronunciations {
word := strings.ToLower(entry.Word)
if word == "" || strings.TrimSpace(entry.Word) != entry.Word ||
len(entry.Word) > 128 || len(entry.Replacement) > 512 ||
strings.ContainsAny(entry.Word, "\x00\r\n\t ") ||
strings.ContainsAny(entry.Replacement, "\x00\r\n") || words[word] {
return errors.New("pronunciation entries need unique, single words and valid replacements")
}
words[word] = true
for _, character := range entry.Word {
if !unicode.IsLetter(character) && !unicode.IsNumber(character) && character != '_' {
return errors.New("pronunciation words may contain only letters, numbers, or underscores")
}
}
}
if settings.TextAttributes.Indicator != 0 && settings.TextAttributes.Indicator != 0x40 &&
settings.TextAttributes.Indicator != 0x80 && settings.TextAttributes.Indicator != 0xc0 {
return errors.New("invalid text attribute braille indicator")
}
if len(settings.TextAttributes.Choices) != len(TextAttributeKeys) {
return errors.New("text attributes list must include every supported attribute")
}
allowed, seen := make(map[string]bool), make(map[string]bool)
for _, key := range TextAttributeKeys {
allowed[key] = true
}
for _, choice := range settings.TextAttributes.Choices {
if !allowed[choice.Key] || seen[choice.Key] {
return errors.New("invalid or duplicate text attribute")
}
seen[choice.Key] = true
}
for _, indicator := range []int{settings.Braille.SelectionIndicator, settings.Braille.LinkIndicator} {
if indicator != 0 && indicator != 0x40 && indicator != 0x80 && indicator != 0xc0 {
return errors.New("validate configuration: invalid braille indicator")
}
}
if len(settings.BrailleContractionTable) > 256 || strings.ContainsAny(settings.BrailleContractionTable, "\x00\r\n,") {
return errors.New("validate configuration: invalid braille contraction table")
}
if !datetime.ValidTime(settings.TimeFormat) {
return errors.New("choose a valid time format")
}
if !datetime.ValidDate(settings.DateFormat) {
return errors.New("choose a valid date format")
}
if settings.SoundVolume < 0 || settings.SoundVolume > 1 || math.IsNaN(settings.SoundVolume) || math.IsInf(settings.SoundVolume, 0) {
return errors.New("validate configuration: soundVolume must be between 0 and 1")
}
switch settings.SoundSink {
case "auto", "pipewire", "pulse", "alsa":
default:
return fmt.Errorf("validate configuration: unknown soundSink %q", settings.SoundSink)
}
if settings.SoundTheme == "" || filepath.Base(settings.SoundTheme) != settings.SoundTheme || settings.SoundTheme == "." || settings.SoundTheme == ".." {
return errors.New("validate configuration: invalid soundTheme")
}
switch settings.ProgressBar.OutputMode {
case "off", "beep", "speak", "both":
default:
return errors.New("choose a valid progress bar output mode")
}
if settings.ProgressBar.BeepPercentageInterval < 1 || settings.ProgressBar.BeepPercentageInterval > 100 ||
settings.ProgressBar.SpeechPercentageInterval < 1 || settings.ProgressBar.SpeechPercentageInterval > 100 {
return errors.New("progress bar percentage intervals must be between 1 and 100")
}
switch settings.RoleSoundPresentation {
case "sound_and_speech", "speech_only", "sound_only":
default:
return errors.New("validate configuration: invalid roleSoundPresentation")
}
if settings.SpeechServerFactory != "speechdispatcherfactory" &&
settings.SpeechServerFactory != "cthulhu.speechdispatcherfactory" &&
settings.SpeechServerFactory != "hardwarefactory" {
return fmt.Errorf(
"validate configuration: speechServerFactory %q is not supported by the preview",
settings.SpeechServerFactory,
)
}
if len(settings.HardwareSpeechDevice) > 1024 || strings.ContainsAny(settings.HardwareSpeechDevice, "\x00\r\n") {
return errors.New("invalid hardware speech device")
}
switch settings.HardwareSpeechBaudRate {
case 1200, 2400, 4800, 9600, 19200, 38400, 57600, 115200:
default:
return errors.New("choose a supported hardware speech baud rate")
}
if settings.SpeechServerFactory == "hardwarefactory" {
if settings.UseCustomEchoVoice && settings.UseCustomEchoSpeechServer {
return errors.New("a separate echo synthesizer requires Speech Dispatcher; disable that option for hardware speech")
}
switch settings.SpeechOutputModule {
case "litetalk", "doubletalk", "tripletalk", "dectalk":
default:
return errors.New("choose a hardware speech synthesizer")
}
if !filepath.IsAbs(settings.HardwareSpeechDevice) {
return errors.New("choose an explicit serial device path for hardware speech")
}
}
switch settings.SpeechVerbosityLevel {
case 0, 1:
default:
return fmt.Errorf("validate configuration: speechVerbosityLevel %d is not 0 or 1", settings.SpeechVerbosityLevel)
}
if settings.SayAllStyle != "line" && settings.SayAllStyle != "sentence" {
return fmt.Errorf("validate configuration: say-all-style %q is invalid", settings.SayAllStyle)
}
if settings.SpeechHistoryLimit < MinSpeechHistoryLimit || settings.SpeechHistoryLimit > MaxSpeechHistoryLimit {
return fmt.Errorf("validate configuration: speech history limit %d must be between %d and %d",
settings.SpeechHistoryLimit, MinSpeechHistoryLimit, MaxSpeechHistoryLimit)
}
if settings.VerbalizePunctuationStyle < 0 || settings.VerbalizePunctuationStyle > 3 {
return fmt.Errorf(
"validate configuration: verbalizePunctuationStyle %d is outside 0 through 3",
settings.VerbalizePunctuationStyle,
)
}
switch settings.CapitalizationStyle {
case "none", "spell", "icon":
default:
return fmt.Errorf("validate configuration: capitalizationStyle %q is invalid", settings.CapitalizationStyle)
}
if err := input.ValidateBindingOverrides(settings.KeyBindings); err != nil {
return fmt.Errorf("key bindings: %w", err)
}
if settings.KeyboardLayout != 1 && settings.KeyboardLayout != 2 {
return fmt.Errorf("validate configuration: keyboardLayout %d is not 1 or 2", settings.KeyboardLayout)
}
for _, key := range settings.CthulhuModifierKeys {
if err := validateCommandValue("cthulhuModifierKeys", key); err != nil {
return err
}
}
if strings.ContainsAny(settings.SpeechOutputModule, "\r\n\x00") {
return errors.New("invalid speech output module")
}
if strings.ContainsAny(settings.EchoSpeechServer, "\r\n\x00") {
return fmt.Errorf("invalid echo speech synthesizer")
}
if err := validateVoice(settings.EchoVoice, true); err != nil {
return fmt.Errorf("echo voice: %w", err)
}
if err := validateVoice(settings.DefaultVoice); err != nil {
return err
}
for _, voice := range []Voice{settings.UppercaseVoice, settings.HyperlinkVoice, settings.SystemVoice} {
if err := validateVoice(voice); err != nil {
return err
}
}
switch settings.Reading.IndentationPresentationMode {
case "", "speech", "beeps", "both":
default:
return errors.New("invalid indentation presentation mode")
}
return nil
}
func validateVoice(voice Voice, echo ...bool) error {
values := []struct {
name string
value *float64
maximum float64
}{
{name: "rate", value: voice.Rate, maximum: 99},
{name: "average-pitch", value: voice.AveragePitch, maximum: 9},
{name: "gain", value: voice.Gain, maximum: 9},
}
if len(echo) != 0 && echo[0] {
values[0].maximum = 100
values[1].maximum = 10
values[2].maximum = 10
}
for _, field := range values {
name, value, maximum := field.name, field.value, field.maximum
if value == nil {
continue
}
if *value < 0 || *value > maximum || math.IsNaN(*value) || math.IsInf(*value, 0) {
return fmt.Errorf("validate configuration: default voice %s %.2f is outside 0 through %.0f", name, *value, maximum)
}
}
if voice.Family == nil {
return nil
}
familyValues := []struct {
name string
value string
}{
{name: "name", value: voice.Family.Name},
{name: "lang", value: voice.Family.Lang},
{name: "dialect", value: voice.Family.Dialect},
{name: "variant", value: voice.Family.Variant},
}
for _, field := range familyValues {
if err := validateCommandValue("default voice family "+field.name, field.value); err != nil {
return err
}
}
return nil
}
func validateCommandValue(name, value string) error {
if strings.ContainsAny(value, "\r\n\x00") {
return fmt.Errorf("validate configuration: %s contains a control character", name)
}
return nil
}