Files

274 lines
8.4 KiB
Go

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