Files
fenrir/internal/app/remote_persistence.go

227 lines
7.9 KiB
Go

// Copyright (c) 2026 Storm Dragon
// SPDX-License-Identifier: GPL-3.0-or-later
package app
import (
"fmt"
"os"
"strings"
"git.stormux.org/storm/fenrir/internal/config"
"git.stormux.org/storm/fenrir/internal/control"
"git.stormux.org/storm/fenrir/internal/i18n"
)
// These operations run only after the shared credential and settings gates.
// A save keeps its reply pending until the worker reports the actual disk result.
func (a *application) remote_settings_request(text string, request control.Request) (string, bool) {
verb, parameter, _ := strings.Cut(text, " ")
verb, parameter = strings.ToUpper(verb), strings.TrimSpace(parameter)
if a.saves_unavailable() && (verb == "SAVE" || verb == "SAVEAS") {
return "Save unavailable.", false
}
if a.overrideTransition {
return "Settings are busy; try again after the current operation", false
}
if a.speechPending {
return "Settings are busy; try again after the current operation", false
}
if verb == "SET" {
if err := a.check_application_remote(text); err != nil {
return err.Error(), false
}
updated, err := remote_settings(a.effective_settings(), text)
if err != nil {
return err.Error(), false
}
if !same_speech_selection(updated.Presentation.Speech, a.settings.live.Presentation.Speech) {
if a.saver.busy || a.ui.active {
return "Settings are busy; try again after the current operation", false
}
a.change_speech(updated.Presentation.Speech, func(err error) {
response := ""
if err != nil {
response = err.Error()
} else {
response = a.apply_remote_settings(resolved_remote_selection(text, updated))
}
remote_reply(request.Reply, response)
})
return "", true
}
}
switch verb {
case "SAVE", "SAVEAS", "RESET":
if a.saver.busy || a.ui.active {
return "Settings are busy; try again after the current operation", false
}
default:
return a.apply_remote_settings(text), false
}
if verb != "SAVEAS" && parameter != "" {
return verb + " does not accept parameters", false
}
if verb == "RESET" {
updated, _, err := a.remote_reset_state()
if err != nil {
return err.Error(), false
}
if !same_speech_selection(updated.Presentation.Speech, a.settings.live.Presentation.Speech) {
a.change_speech(updated.Presentation.Speech, func(err error) {
response := ""
if err != nil {
response = err.Error()
} else {
response = a.reset_remote_settings()
}
remote_reply(request.Reply, response)
})
return "", true
}
return a.reset_remote_settings(), false
}
path := a.settings_path()
if verb == "SAVEAS" {
if a.overrideCurrent != nil {
return "SAVEAS is unavailable in application settings", false
}
// A root TTY reader also admits its active console user for ordinary
// commands. That must not grant arbitrary root-owned file writes.
if request.UID != 0 && request.UID != os.Geteuid() {
return "SAVEAS requires the reader owner or root", false
}
path = parameter
}
if path == "" || strings.ContainsAny(path, "\x00\r\n") {
return "A valid settings destination is required", false
}
if !a.start_settings_save(path, a.settings_to_save()) {
return "Settings are busy; try again after the current operation", false
}
a.saver.revision = a.settings.remoteRevision
a.saver.reply = request.Reply
return "", true
}
func (a *application) settings_save_result(err error) {
if !a.saver.busy {
return
}
if settings_committed(err) {
a.settings.resetBase = a.saver.snapshot
for path, revision := range a.settings.remoteFields {
if revision <= a.saver.revision {
delete(a.settings.remoteFields, path)
}
}
}
if done := a.saver.menuDone; done != nil {
a.saver.busy, a.saver.menuDone = false, nil
done(err)
} else if reply := a.saver.reply; reply != nil {
a.saver.busy, a.saver.reply = false, nil
response := ""
if err != nil {
response = "Could not save settings: " + err.Error()
}
remote_reply(reply, response)
} else if a.overrideCurrent != nil && err == nil {
a.saver.busy = false
a.voice.Say(i18n.Format("{0} application settings saved.", i18n.T(a.overrideCurrent.adapter.Name())))
} else {
a.saver.result(err, a.voice)
}
}
// RESET clears temporary quick/remote overrides, as in Python's
// reset_setting_arg_dict. It never reloads a file or installs factory defaults.
func (a *application) remote_reset_state() (config.Settings, config.Settings, error) {
updated, persisted := a.effective_settings(), a.settings.saveDraft
for _, target := range []*config.Settings{&updated, &persisted} {
copy_quick_settings(target, a.settings.resetBase)
if a.quick.selectionChanged {
copy_speech_selection(&target.Presentation.Speech, a.settings.resetBase.Presentation.Speech)
}
copy_remote_settings(target, a.settings.resetBase, a.settings.remoteFields)
if err := target.Validate(); err != nil {
return updated, persisted, err
}
}
if updated.Presentation.Speech.Enabled && !a.settings.speechAvailable {
return updated, persisted, fmt.Errorf("Speech was disabled at startup; enable it in settings and relaunch")
}
return updated, persisted, nil
}
func (a *application) reset_remote_settings() string {
updated, persisted, err := a.remote_reset_state()
if err != nil {
return err.Error()
}
a.settings.saveDraft = persisted
_, speechOverridden := a.settings.remoteFields["speech#enabled"]
a.apply_remote_state(updated, speechOverridden)
clear(a.settings.remoteFields)
a.quick.changed = false
a.quick.selectionChanged = false
return ""
}
// Copy only remotely overridden fields; unrelated session mute/AutoRead,
// next-launch menu choices, reading regions and keyboard routing survive.
func copy_remote_settings(target *config.Settings, source config.Settings, fields map[string]uint64) {
for path := range fields {
switch path {
case "reading#mode", "focus#cursor", "focus#highlight":
target.Reading.Mode = source.Reading.Mode
case "speech#enabled":
target.Presentation.Speech.Enabled = source.Presentation.Speech.Enabled
case "speech#module", "speech#language", "speech#voice":
copy_speech_selection(&target.Presentation.Speech, source.Presentation.Speech)
case "speech#rate":
target.Presentation.Speech.Rate = source.Presentation.Speech.Rate
case "speech#pitch":
target.Presentation.Speech.Pitch = source.Presentation.Speech.Pitch
case "speech#volume":
target.Presentation.Speech.Volume = source.Presentation.Speech.Volume
case "sound#enabled":
target.Presentation.Sound.Enabled = source.Presentation.Sound.Enabled
case "sound#volume":
target.Presentation.Sound.Volume = source.Presentation.Sound.Volume
case "sound#progress_monitoring":
target.Presentation.Progress.Enabled = source.Presentation.Progress.Enabled
case "general#punctuation_level":
target.Reading.PunctuationLevel = source.Reading.PunctuationLevel
case "speech#auto_read_incoming", "reading#automatic":
target.Reading.Automatic = source.Reading.Automatic
case "reading#cursor_echo":
target.Reading.CursorEcho = source.Reading.CursorEcho
case "keyboard#char_echo_mode":
target.Keyboard.CharacterEcho = source.Keyboard.CharacterEcho
target.Keyboard.EchoOnCapsLock = source.Keyboard.EchoOnCapsLock
case "keyboard#word_echo":
target.Keyboard.WordEcho = source.Keyboard.WordEcho
case "keyboard#echo_on_capslock":
target.Keyboard.EchoOnCapsLock = source.Keyboard.EchoOnCapsLock
case "screen#ignore_screen":
target.Screen.IgnoredVTs = append([]int{}, source.Screen.IgnoredVTs...)
}
}
}
// Screen routing is global and has no application-profile counterpart.
func (a *application) check_application_remote(text string) error {
if a.overrideCurrent == nil {
return nil
}
_, parameters, _ := strings.Cut(text, " ")
for _, assignment := range remote_assignments(parameters) {
path, _, _ := strings.Cut(assignment, "=")
if strings.EqualFold(strings.TrimSpace(path), "screen#ignore_screen") {
return fmt.Errorf("screen#ignore_screen is unavailable in application settings")
}
}
return nil
}