274 lines
8.4 KiB
Go
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
|
|
}
|