// 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 }