// 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 . // Package remote publishes Cthulhu's bounded self-voice D-Bus API. package remote import ( "encoding/xml" "log/slog" "sync" "git.stormux.org/storm/cthulhu/internal/version" "github.com/godbus/dbus/v5" ) type endpoint struct { logger *slog.Logger handler Handler authorize func(dbus.Sender) bool mutex sync.Mutex accepting bool active int idle chan struct{} } func newEndpoint(logger *slog.Logger, handler Handler, authorize func(dbus.Sender) bool) *endpoint { if logger == nil { logger = slog.New(slog.DiscardHandler) } return &endpoint{logger: logger, handler: handler, authorize: authorize} } func (endpoint *endpoint) startAccepting() { endpoint.mutex.Lock() endpoint.accepting = true endpoint.mutex.Unlock() } func (endpoint *endpoint) begin(sender dbus.Sender, method string) *dbus.Error { endpoint.mutex.Lock() if !endpoint.accepting { endpoint.mutex.Unlock() endpoint.logger.Debug("self-voice D-Bus call unavailable", "method", method, "sender", sender) return &dbus.Error{ Name: "org.stormux.Cthulhu1.Error.Unavailable", Body: []any{"self-voice service is not accepting requests"}, } } endpoint.active++ endpoint.mutex.Unlock() if endpoint.authorize == nil || !endpoint.authorize(sender) { endpoint.end() endpoint.logger.Warn("self-voice D-Bus call denied", "method", method, "sender", sender) return &dbus.Error{ Name: "org.freedesktop.DBus.Error.AccessDenied", Body: []any{"self-voice requests are restricted to the runtime user"}, } } endpoint.logger.Debug("self-voice D-Bus call accepted", "method", method, "sender", sender) return nil } func (endpoint *endpoint) end() { endpoint.mutex.Lock() endpoint.active-- if endpoint.active == 0 && endpoint.idle != nil { close(endpoint.idle) endpoint.idle = nil } endpoint.mutex.Unlock() } func (endpoint *endpoint) stopAccepting() <-chan struct{} { endpoint.mutex.Lock() defer endpoint.mutex.Unlock() endpoint.accepting = false if endpoint.active == 0 { idle := make(chan struct{}) close(idle) return idle } if endpoint.idle == nil { endpoint.idle = make(chan struct{}) } return endpoint.idle } func (endpoint *endpoint) logRequest(method string) { endpoint.logger.Debug("self-voice D-Bus request", "method", method) } func (endpoint *endpoint) logResponse(method string, result any) { endpoint.logger.Debug("self-voice D-Bus response", "method", method, "result", result) } func (endpoint *endpoint) PresentMessage(sender dbus.Sender, message string) (bool, *dbus.Error) { endpoint.logRequest("PresentMessage") if err := endpoint.begin(sender, "PresentMessage"); err != nil { return false, err } defer endpoint.end() if len(message) > maxTextBytes { endpoint.logResponse("PresentMessage", false) return false, nil } accepted := endpoint.handler.PresentMessage(message) endpoint.logResponse("PresentMessage", accepted) return accepted, nil } func (endpoint *endpoint) ShowPreferences(sender dbus.Sender) (bool, *dbus.Error) { if err := endpoint.begin(sender, "ShowPreferences"); err != nil { return false, err } defer endpoint.end() if handler, ok := endpoint.handler.(ControlHandler); ok { return handler.ShowPreferences(), nil } return false, nil } func (endpoint *endpoint) GetVersion(sender dbus.Sender) (string, *dbus.Error) { if err := endpoint.begin(sender, "GetVersion"); err != nil { return "", err } defer endpoint.end() return version.String(), nil } func (endpoint *endpoint) GetRuntimeStateSnapshot(sender dbus.Sender) (string, *dbus.Error) { if err := endpoint.begin(sender, "GetRuntimeStateSnapshot"); err != nil { return "", err } defer endpoint.end() if handler, ok := endpoint.handler.(ControlHandler); ok { return handler.RuntimeStateSnapshot(), nil } return "", nil } func (endpoint *endpoint) Quit(sender dbus.Sender) (bool, *dbus.Error) { if err := endpoint.begin(sender, "Quit"); err != nil { return false, err } defer endpoint.end() if handler, ok := endpoint.handler.(ControlHandler); ok { return handler.Quit(), nil } return false, nil } func (endpoint *endpoint) SpeakText(sender dbus.Sender, text string, interrupt bool) (bool, *dbus.Error) { endpoint.logRequest("SpeakText") if err := endpoint.begin(sender, "SpeakText"); err != nil { return false, err } defer endpoint.end() if text == "" || len(text) > maxTextBytes { endpoint.logResponse("SpeakText", false) return false, nil } accepted := endpoint.handler.SpeakText(text, interrupt) endpoint.logResponse("SpeakText", accepted) return accepted, nil } func (endpoint *endpoint) BrailleMessage(sender dbus.Sender, text string) (bool, *dbus.Error) { endpoint.logRequest("BrailleMessage") if err := endpoint.begin(sender, "BrailleMessage"); err != nil { return false, err } defer endpoint.end() if text == "" || len(text) > maxTextBytes { endpoint.logResponse("BrailleMessage", false) return false, nil } accepted := endpoint.handler.BrailleMessage(text) endpoint.logResponse("BrailleMessage", accepted) return accepted, nil } func (endpoint *endpoint) CancelSpeech(sender dbus.Sender) (bool, *dbus.Error) { endpoint.logRequest("CancelSpeech") if err := endpoint.begin(sender, "CancelSpeech"); err != nil { return false, err } defer endpoint.end() accepted := endpoint.handler.CancelSpeech() endpoint.logResponse("CancelSpeech", accepted) return accepted, nil } func (endpoint *endpoint) SpeakSsml(sender dbus.Sender, requestID, ssml string) (int32, *dbus.Error) { endpoint.logRequest("SpeakSsml") if err := endpoint.begin(sender, "SpeakSsml"); err != nil { return windowsErrorInvalidParameter, err } defer endpoint.end() if len(requestID) > maxRequestIDBytes || len(ssml) > maxTextBytes || !validSSML(ssml) { endpoint.logResponse("SpeakSsml", windowsErrorInvalidParameter) return windowsErrorInvalidParameter, nil } endpoint.logResponse("SpeakSsml", windowsErrorNotSupported) return windowsErrorNotSupported, nil } func (endpoint *endpoint) PresentAccessibleEvent(sender dbus.Sender, sourceID, eventType, name, role, value, states, description string, position, count int32, windowTitle string) (bool, *dbus.Error) { endpoint.logRequest("PresentAccessibleEvent") if err := endpoint.begin(sender, "PresentAccessibleEvent"); err != nil { return false, err } defer endpoint.end() for _, field := range []string{sourceID, eventType, name, role, value, states, description, windowTitle} { if len(field) > 4096 { endpoint.logResponse("PresentAccessibleEvent", false) return false, nil } } if sourceID == "" { endpoint.logResponse("PresentAccessibleEvent", false) return false, nil } accepted := endpoint.handler.PresentAccessibleEvent(AccessibleEvent{ SourceID: sourceID, EventType: eventType, Name: name, Role: role, Value: value, States: states, Description: description, Position: position, Count: count, WindowTitle: windowTitle, }) endpoint.logResponse("PresentAccessibleEvent", accepted) return accepted, nil } func validSSML(value string) bool { var root struct { XMLName xml.Name } if err := xml.Unmarshal([]byte(value), &root); err != nil { return false } return root.XMLName.Local == "speak" } // speechManagerEndpoint shares authorization and shutdown accounting with the // main service. Python's notify_user argument does not affect speech cancellation. type speechManagerEndpoint struct { endpoint *endpoint } func (manager *speechManagerEndpoint) InterruptSpeech(sender dbus.Sender, _ bool) (bool, *dbus.Error) { endpoint := manager.endpoint endpoint.logRequest("InterruptSpeech") if err := endpoint.begin(sender, "InterruptSpeech"); err != nil { return false, err } defer endpoint.end() accepted := endpoint.handler.CancelSpeech() endpoint.logResponse("InterruptSpeech", accepted) return accepted, nil }