// Copyright (c) 2026 Storm Dragon // Authors: Storm Dragon, Angus Kola // SPDX-License-Identifier: GPL-3.0-or-later package app import ( "context" "math" "reflect" "strings" "time" "git.stormux.org/storm/cthulhu/internal/config" "git.stormux.org/storm/cthulhu/internal/input" "github.com/godbus/dbus/v5" ) // The names are the Python service's public names. Each mapped field is read // from the live snapshot and applied by the preferences worker. var remoteFields = map[string]string{ "SpeechManager.CapitalizationStyle": "CapitalizationStyle", "SpeechManager.SpeakDescription": "SpeakDescription", "SpeechManager.SpeakRowInGuiTable": "Reading.ReadFullRowInGUITable", "TypingEchoManager.KeyEchoEnabled": "EnableKeyEcho", "TypingEchoManager.CharacterEchoEnabled": "EnableEchoByCharacter", "TypingEchoManager.WordEchoEnabled": "EnableEchoByWord", "TypingEchoManager.SentenceEchoEnabled": "EnableEchoBySentence", "TypingEchoManager.AlphabeticKeysEnabled": "EnableAlphabeticKeys", "TypingEchoManager.NumericKeysEnabled": "EnableNumericKeys", "TypingEchoManager.PunctuationKeysEnabled": "EnablePunctuationKeys", "TypingEchoManager.SpaceEnabled": "EnableSpace", "TypingEchoManager.ModifierKeysEnabled": "EnableModifierKeys", "TypingEchoManager.FunctionKeysEnabled": "EnableFunctionKeys", "TypingEchoManager.ActionKeysEnabled": "EnableActionKeys", "TypingEchoManager.NavigationKeysEnabled": "EnableNavigationKeys", "TypingEchoManager.DiacriticalKeysEnabled": "EnableDiacriticalKeys", } func remoteField(settings *config.Settings, path string) reflect.Value { value := reflect.ValueOf(settings).Elem() for _, part := range strings.Split(path, ".") { if value.Kind() != reflect.Struct { return reflect.Value{} } value = value.FieldByName(part) if !value.IsValid() { return value } } return value } func remoteValue(settings config.Settings, module, property string) (dbus.Variant, bool) { if module == "SpeechManager" { switch property { case "CurrentServer": if !settings.EnableSpeech { return dbus.MakeVariant(""), true } return dbus.MakeVariant("speechdispatcher"), true case "AvailableServers": return dbus.MakeVariant([]string{"speechdispatcher"}), true case "CurrentSynthesizer": return dbus.MakeVariant(settings.SpeechOutputModule), true case "CurrentVoice": if settings.DefaultVoice.Family != nil { return dbus.MakeVariant(settings.DefaultVoice.Family.Name), true } return dbus.MakeVariant(""), true case "Rate": if settings.DefaultVoice.Rate != nil { return dbus.MakeVariant(int32(*settings.DefaultVoice.Rate)), true } return dbus.MakeVariant(int32(50)), true case "Pitch": if settings.DefaultVoice.AveragePitch != nil { return dbus.MakeVariant(*settings.DefaultVoice.AveragePitch), true } return dbus.MakeVariant(float64(5)), true case "Volume": if settings.DefaultVoice.Gain != nil { return dbus.MakeVariant(*settings.DefaultVoice.Gain), true } return dbus.MakeVariant(float64(10)), true case "SpeechIsEnabled": return dbus.MakeVariant(settings.EnableSpeech), true case "PunctuationLevel": return dbus.MakeVariant([]string{"all", "most", "some", "none"}[settings.VerbalizePunctuationStyle]), true } } path, ok := remoteFields[module+"."+property] if !ok { return dbus.Variant{}, false } field := remoteField(&settings, path) if !field.IsValid() { return dbus.Variant{}, false } return dbus.MakeVariant(field.Interface()), true } type remoteSettingsChange struct { module, property string command string value dbus.Variant result chan bool expires time.Time } func (worker *preferencesWorker) RunRemoteCommand(command string) bool { change := remoteSettingsChange{module: "SpeechManager", command: command, result: make(chan bool, 1), expires: time.Now().Add(2 * time.Second)} select { case worker.remoteChanges <- change: default: return false } select { case accepted := <-change.result: return accepted case <-worker.done: return false case <-time.After(2 * time.Second): return false } } func (worker *preferencesWorker) SetRemote(module, property string, value dbus.Variant) bool { change := remoteSettingsChange{module: module, property: property, value: value, result: make(chan bool, 1), expires: time.Now().Add(2 * time.Second)} select { case worker.remoteChanges <- change: default: return false } select { case accepted := <-change.result: return accepted case <-worker.done: return false case <-time.After(2 * time.Second): return false } } func (worker *preferencesWorker) applyRemoteChange(ctx context.Context, change remoteSettingsChange) bool { if time.Now().After(change.expires) { return false } next := worker.settings if change.command != "" { if !adjustRemoteSpeech(&next, change.command) { return false } } else { if !setRemoteValue(&next, change.module, change.property, change.value) { return false } } applyCtx, cancel := context.WithDeadline(ctx, change.expires) defer cancel() return worker.applyNext(applyCtx, next, false, false).Accepted } func adjustRemoteSpeech(settings *config.Settings, command string) bool { var field **float64 var defaultValue, step, upper float64 delta := float64(1) switch command { case "DecreaseRate", "IncreaseRate": field = &settings.DefaultVoice.Rate defaultValue, step, upper = 50, 1, 99 case "DecreasePitch", "IncreasePitch": field = &settings.DefaultVoice.AveragePitch defaultValue, step, upper = 5, 0.1, 9 case "DecreaseVolume", "IncreaseVolume": field = &settings.DefaultVoice.Gain defaultValue, step, upper = 9, 0.1, 9 default: return false } if strings.HasPrefix(command, "Decrease") { delta = -1 } value := defaultValue if *field != nil { value = **field } value = math.Round((value+delta*step)*10) / 10 value = math.Max(0, math.Min(upper, value)) *field = &value return true } func setRemoteValue(settings *config.Settings, module, property string, value dbus.Variant) bool { key := module + "." + property if module == "SpeechManager" { switch property { case "Rate", "Pitch", "Volume": var number float64 if property == "Rate" { v, ok := value.Value().(int32) if !ok || v < 0 || v > 99 { return false } number = float64(v) } else { v, ok := value.Value().(float64) if !ok { return false } number = v } switch property { case "Rate": settings.DefaultVoice.Rate = &number case "Pitch": settings.DefaultVoice.AveragePitch = &number case "Volume": settings.DefaultVoice.Gain = &number } return true case "CurrentSynthesizer": v, ok := value.Value().(string) if !ok || len(v) > 256 { return false } settings.SpeechOutputModule = v settings.DefaultVoice.Family = nil return true case "CurrentVoice": v, ok := value.Value().(string) if !ok || len(v) > 256 { return false } if settings.DefaultVoice.Family == nil { settings.DefaultVoice.Family = &config.VoiceFamily{} } else { family := *settings.DefaultVoice.Family settings.DefaultVoice.Family = &family } settings.DefaultVoice.Family.Name = v return true case "PunctuationLevel": v, ok := value.Value().(string) if !ok { return false } for i, name := range []string{"all", "most", "some", "none"} { if strings.EqualFold(v, name) { settings.VerbalizePunctuationStyle = i return true } } return false } } path, ok := remoteFields[key] if !ok { return false } field := remoteField(settings, path) if !field.IsValid() || !field.CanSet() { return false } newValue := reflect.ValueOf(value.Value()) if !newValue.IsValid() || newValue.Type() != field.Type() { return false } field.Set(newValue) return true } func (presenter remoteSpeech) RemoteGet(module, property string) (dbus.Variant, bool) { if presenter.live == nil { return dbus.Variant{}, false } if module == "SpeechManager" && property == "SpeechIsEnabled" { return dbus.MakeVariant(presenter.speech != nil && presenter.live.Load().EnableSpeech), true } if module == "SpeechManager" && presenter.speech == nil && (property == "CurrentServer" || property == "CurrentSynthesizer" || property == "CurrentVoice") { return dbus.MakeVariant(""), true } return remoteValue(presenter.live.Load(), module, property) } func (presenter remoteSpeech) RemoteModulesAvailable() bool { return presenter.live != nil && presenter.preferences != nil } func (presenter remoteSpeech) RemoteSet(module, property string, value dbus.Variant) bool { return presenter.preferences != nil && presenter.preferences.SetRemote(module, property, value) } var remoteSpeechCommands = map[string]input.Command{ "SelectPreviousSpeechSetting": input.SelectPreviousSpeechSetting, "SelectNextSpeechSetting": input.SelectNextSpeechSetting, "DecreaseCurrentSpeechSetting": input.DecreaseSpeechSetting, "IncreaseCurrentSpeechSetting": input.IncreaseSpeechSetting, "SaveSpeechSettings": input.SaveSpeechSettings, } func (presenter remoteSpeech) RemoteCommand(module, command string, notify bool) bool { if module == "SpeechManager" { if command == "InterruptSpeech" { return presenter.CancelSpeech() } if strings.HasPrefix(command, "Decrease") || strings.HasPrefix(command, "Increase") { if presenter.preferences == nil { return false } accepted := presenter.preferences.RunRemoteCommand(command) if accepted && notify { if message, ok := map[string]string{"DecreaseRate": "Speech slower", "IncreaseRate": "Speech faster", "DecreasePitch": "Speech lower", "IncreasePitch": "Speech higher", "DecreaseVolume": "Speech softer", "IncreaseVolume": "Speech louder"}[command]; ok { presenter.PresentMessage(message) } } return accepted } if mapped, ok := remoteSpeechCommands[command]; ok && presenter.preferences != nil { return presenter.preferences.TrySpeechCommand(mapped) } } if module == "TypingEchoManager" && command == "CycleKeyEcho" && presenter.live != nil { current := presenter.live.Load().EnableKeyEcho accepted := presenter.RemoteSet(module, "KeyEchoEnabled", dbus.MakeVariant(!current)) if accepted && notify { if current { presenter.PresentMessage("Key echo disabled") } else { presenter.PresentMessage("Key echo enabled") } } return accepted } return false }