Cthulhu is no longer a fork. Now it is a screen reader written in Golang.
This commit is contained in:
1 parent
a998fe67de
commit
069fc674dc
708 files changed
+112482
-532
No files matched your search
@@ -0,0 +1,285 @@
|
||||
// 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 (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// Store owns the versioned Go configuration. An explicitly selected
|
||||
// Python-era file fails strict decoding and cannot be replaced by Save.
|
||||
type Store struct {
|
||||
ioMu sync.Mutex
|
||||
mu sync.RWMutex
|
||||
path string
|
||||
lastGood Settings
|
||||
rename func(string, string) error
|
||||
}
|
||||
|
||||
func DefaultStorePath() (string, error) { return DefaultPath() }
|
||||
|
||||
func NewStore(path string) (*Store, error) {
|
||||
if path == "" {
|
||||
var err error
|
||||
path, err = DefaultStorePath()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
defaults := DefaultSettings()
|
||||
defaults.Path = path
|
||||
return &Store{path: path, lastGood: cloneSettings(defaults), rename: os.Rename}, nil
|
||||
}
|
||||
|
||||
func (store *Store) Path() string { return store.path }
|
||||
|
||||
// Current returns a copy of the last configuration which parsed and validated.
|
||||
func (store *Store) Current() Settings {
|
||||
store.mu.RLock()
|
||||
defer store.mu.RUnlock()
|
||||
return cloneSettings(store.lastGood)
|
||||
}
|
||||
|
||||
// Load returns the last known good configuration together with any read,
|
||||
// parse, schema, or validation error. A missing file selects built-in defaults.
|
||||
func (store *Store) Load() (Settings, error) {
|
||||
store.ioMu.Lock()
|
||||
defer store.ioMu.Unlock()
|
||||
contents, err := readStoreFile(store.path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
defaults := DefaultSettings()
|
||||
defaults.Path = store.path
|
||||
store.setCurrent(defaults)
|
||||
return cloneSettings(defaults), nil
|
||||
}
|
||||
if err != nil {
|
||||
return store.Current(), err
|
||||
}
|
||||
_, settings, err := decodeDocument(contents)
|
||||
if err != nil {
|
||||
return store.Current(), err
|
||||
}
|
||||
settings.Path = store.path
|
||||
settings.Loaded = true
|
||||
store.setCurrent(settings)
|
||||
return cloneSettings(settings), nil
|
||||
}
|
||||
|
||||
// Save updates only the active profile and preserves all other profiles and
|
||||
// application mappings. It validates before using an atomic replacement.
|
||||
func (store *Store) Save(settings Settings) error {
|
||||
return store.save(settings, "")
|
||||
}
|
||||
|
||||
// LoadForApplication returns the selected application profile, or the global
|
||||
// profile when no application settings have been saved.
|
||||
func (store *Store) LoadForApplication(application string) (Settings, bool, error) {
|
||||
store.ioMu.Lock()
|
||||
defer store.ioMu.Unlock()
|
||||
document, _, err := store.documentForSave()
|
||||
if err != nil {
|
||||
return Settings{}, false, err
|
||||
}
|
||||
global, err := effectiveSettings(document)
|
||||
if err != nil {
|
||||
return Settings{}, false, err
|
||||
}
|
||||
name, found := document.Applications[application]
|
||||
if found {
|
||||
applyProfile(&global, document.Profiles[name])
|
||||
global.ActiveProfile = name
|
||||
}
|
||||
global.Path, global.Loaded = store.path, true
|
||||
return global, found, nil
|
||||
}
|
||||
|
||||
// SaveForApplication creates or updates a dedicated profile without changing
|
||||
// the global profile or other application mappings.
|
||||
func (store *Store) SaveForApplication(application string, settings Settings) error {
|
||||
if application == "" || application != strings.TrimSpace(application) {
|
||||
return errors.New("invalid application identifier")
|
||||
}
|
||||
if err := validateCommandValue("application identifier", application); err != nil {
|
||||
return err
|
||||
}
|
||||
return store.save(settings, application)
|
||||
}
|
||||
|
||||
func (store *Store) save(settings Settings, application string) error {
|
||||
store.ioMu.Lock()
|
||||
defer store.ioMu.Unlock()
|
||||
if err := validate(settings); err != nil {
|
||||
return err
|
||||
}
|
||||
document, original, err := store.documentForSave()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
global, err := effectiveSettings(document)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
profileName := document.ActiveProfile
|
||||
if application == "" {
|
||||
if settings.ActiveProfile != "" {
|
||||
profileName = settings.ActiveProfile
|
||||
}
|
||||
if _, exists := document.Profiles[profileName]; !exists {
|
||||
return fmt.Errorf("active profile %q does not exist", profileName)
|
||||
}
|
||||
} else {
|
||||
profileName = document.Applications[application]
|
||||
if profileName == "" {
|
||||
hash := sha256.Sum256([]byte(application))
|
||||
profileName = "application-" + hex.EncodeToString(hash[:])
|
||||
}
|
||||
if document.Applications == nil {
|
||||
document.Applications = make(map[string]string)
|
||||
}
|
||||
document.Applications[application] = profileName
|
||||
}
|
||||
profile := profileFromSettings(settings)
|
||||
if application != "" {
|
||||
profile = applicationProfileDifference(global, settings)
|
||||
}
|
||||
document.Profiles[profileName] = profile
|
||||
contents, err := encodeDocument(document)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(store.path), 0o700); err != nil {
|
||||
return fmt.Errorf("create settings directory: %w", err)
|
||||
}
|
||||
temporary, err := os.CreateTemp(filepath.Dir(store.path), ".settings-*.toml")
|
||||
if err != nil {
|
||||
return fmt.Errorf("create temporary settings file: %w", err)
|
||||
}
|
||||
temporaryPath := temporary.Name()
|
||||
keepTemporary := false
|
||||
defer func() {
|
||||
if !keepTemporary {
|
||||
_ = os.Remove(temporaryPath)
|
||||
}
|
||||
}()
|
||||
if err := temporary.Chmod(0o600); err != nil {
|
||||
_ = temporary.Close()
|
||||
return fmt.Errorf("restrict temporary settings file: %w", err)
|
||||
}
|
||||
if _, err := temporary.Write(contents); err != nil {
|
||||
_ = temporary.Close()
|
||||
return fmt.Errorf("write temporary settings file: %w", err)
|
||||
}
|
||||
if err := temporary.Sync(); err != nil {
|
||||
_ = temporary.Close()
|
||||
return fmt.Errorf("flush temporary settings file: %w", err)
|
||||
}
|
||||
if err := temporary.Close(); err != nil {
|
||||
return fmt.Errorf("close temporary settings file: %w", err)
|
||||
}
|
||||
if err := ensureUnchanged(store.path, original); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := store.rename(temporaryPath, store.path); err != nil {
|
||||
return fmt.Errorf("replace settings file: %w", err)
|
||||
}
|
||||
keepTemporary = true
|
||||
saved, err := effectiveSettings(document)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
saved.Path = store.path
|
||||
saved.Loaded = true
|
||||
store.setCurrent(saved)
|
||||
if err := syncDirectory(filepath.Dir(store.path)); err != nil {
|
||||
return fmt.Errorf("flush settings directory: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *Store) documentForSave() (writableDocument, []byte, error) {
|
||||
contents, err := readStoreFile(store.path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return defaultDocument(), nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return writableDocument{}, nil, err
|
||||
}
|
||||
document, _, err := decodeDocument(contents)
|
||||
if err != nil {
|
||||
return writableDocument{}, nil, fmt.Errorf("refuse to replace existing settings without a successful versioned load: %w", err)
|
||||
}
|
||||
return document, contents, nil
|
||||
}
|
||||
|
||||
func ensureUnchanged(path string, original []byte) error {
|
||||
current, err := readStoreFile(path)
|
||||
if original == nil && errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("verify settings before replacement: %w", err)
|
||||
}
|
||||
if original == nil || !bytes.Equal(current, original) {
|
||||
return errors.New("settings changed while being saved; no file was replaced")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func readStoreFile(path string) ([]byte, error) {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer file.Close()
|
||||
contents, err := io.ReadAll(io.LimitReader(file, maxConfigSize+1))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read settings: %w", err)
|
||||
}
|
||||
if len(contents) > maxConfigSize {
|
||||
return nil, fmt.Errorf("settings exceed %d bytes", maxConfigSize)
|
||||
}
|
||||
return contents, nil
|
||||
}
|
||||
|
||||
func (store *Store) setCurrent(settings Settings) {
|
||||
store.mu.Lock()
|
||||
defer store.mu.Unlock()
|
||||
store.lastGood = cloneSettings(settings)
|
||||
}
|
||||
|
||||
func syncDirectory(path string) error {
|
||||
directory, err := os.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer directory.Close()
|
||||
if err := directory.Sync(); err != nil && !errors.Is(err, syscall.EINVAL) && !errors.Is(err, syscall.ENOTSUP) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in new issue
Block a user