260 lines
10 KiB
Go
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
|
|
}
|