Files

323 lines
9.7 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 (
"context"
"errors"
"fmt"
"log/slog"
"os"
"sync"
"time"
"git.stormux.org/storm/cthulhu/internal/sessionbus"
"github.com/godbus/dbus/v5"
)
const (
BusName = "org.stormux.Cthulhu1.Service"
InterfaceName = "org.stormux.Cthulhu1.Service"
ObjectPath = dbus.ObjectPath("/org/stormux/Cthulhu1/Service")
SpeechManagerInterface = "org.stormux.Cthulhu1.SpeechManager"
SpeechManagerPath = dbus.ObjectPath("/org/stormux/Cthulhu1/Service/SpeechManager")
OrcaBusName = "org.gnome.Orca.Service"
OrcaInterfaceName = "org.gnome.Orca.Service"
OrcaObjectPath = dbus.ObjectPath("/org/gnome/Orca/Service")
maxTextBytes = 64 * 1024
maxRequestIDBytes = 256
authorizeTimeout = 500 * time.Millisecond
windowsErrorNotSupported int32 = 50
windowsErrorInvalidParameter int32 = 87
)
// Handler accepts already-authorized presentation requests. Implementations
// must return quickly and enqueue backend work rather than delivering it inline.
type Handler interface {
PresentMessage(string) bool
SpeakText(string, bool) bool
BrailleMessage(string) bool
CancelSpeech() bool
PresentAccessibleEvent(AccessibleEvent) bool
}
// ControlHandler is optional so existing self-voice users keep their contract.
// Implementations enqueue commands with their existing owners and return promptly.
type ControlHandler interface {
ShowPreferences() bool
Quit() bool
RuntimeStateSnapshot() string
}
// AccessibleEvent is the normalized, bounded Wine helper payload. It contains
// no native object references and is safe to hand to the presentation owner.
type AccessibleEvent struct {
SourceID, EventType, Name, Role, Value, States, Description, WindowTitle string
Position, Count int32
}
type connection interface {
Export(any, dbus.ObjectPath, string) error
RequestName(string, dbus.RequestNameFlags) (dbus.RequestNameReply, error)
ReleaseName(string) (dbus.ReleaseNameReply, error)
UnixUser(context.Context, string) (uint32, error)
Close() error
}
type dbusConnection struct {
*dbus.Conn
}
func (connection dbusConnection) UnixUser(ctx context.Context, sender string) (uint32, error) {
var uid uint32
call := connection.BusObject().CallWithContext(
ctx,
"org.freedesktop.DBus.GetConnectionUnixUser",
0,
sender,
)
if err := call.Store(&uid); err != nil {
return 0, err
}
return uid, nil
}
type connector func() (connection, error)
// Service owns the session-bus connection, exported object, and well-known
// name. The lifecycle owner must call Stop before its Handler is stopped.
type Service struct {
logger *slog.Logger
handler Handler
connect connector
orca bool
mutex sync.Mutex
connection connection
endpoint *endpoint
ownedName bool
bindings []serviceBinding
}
type serviceBinding struct {
path dbus.ObjectPath
iface string
value any
}
// NewOrca creates an independent compatibility service. A running Orca owns
// its name exclusively; a name conflict does not affect the native service.
func NewOrca(logger *slog.Logger, handler Handler) *Service {
service := New(logger, handler)
service.orca = true
return service
}
func New(logger *slog.Logger, handler Handler) *Service {
return newService(logger, handler, func() (connection, error) {
connection, err := sessionbus.Connect(logger, "self-voice")
if err != nil {
return nil, err
}
return dbusConnection{Conn: connection}, nil
})
}
func newService(logger *slog.Logger, handler Handler, connect connector) *Service {
if logger == nil {
logger = slog.New(slog.DiscardHandler)
}
return &Service{logger: logger, handler: handler, connect: connect}
}
func (service *Service) Start(ctx context.Context) error {
if ctx == nil {
return errors.New("remote service start requires a non-nil context")
}
if err := ctx.Err(); err != nil {
return fmt.Errorf("start remote service: %w", err)
}
if service.handler == nil {
return errors.New("start remote service without a handler")
}
service.mutex.Lock()
defer service.mutex.Unlock()
if service.connection != nil {
return errors.New("remote service already started")
}
connection, err := service.connect()
if err != nil {
return fmt.Errorf("connect to session bus: %w", err)
}
endpoint := newEndpoint(service.logger, service.handler, func(sender dbus.Sender) bool {
authorizeContext, cancel := context.WithTimeout(context.Background(), authorizeTimeout)
defer cancel()
uid, err := connection.UnixUser(authorizeContext, string(sender))
return err == nil && uid == uint32(os.Getuid())
})
name := BusName
if service.orca {
name = OrcaBusName
}
registrations := service_bindings(endpoint, service.orca, service.handler)
started := false
exported := 0
defer func() {
if !started {
for index := exported - 1; index >= 0; index-- {
binding := registrations[index]
connection.Export(nil, binding.path, binding.iface)
}
connection.Close()
}
}()
for _, binding := range registrations {
if err := connection.Export(binding.value, binding.path, binding.iface); err != nil {
return fmt.Errorf("export %s: %w", binding.iface, err)
}
exported++
}
reply, err := connection.RequestName(name, dbus.NameFlagDoNotQueue)
if err != nil {
return fmt.Errorf("request D-Bus name %s: %w", name, err)
}
if reply != dbus.RequestNameReplyPrimaryOwner && reply != dbus.RequestNameReplyAlreadyOwner {
return fmt.Errorf("D-Bus name %s is already owned", name)
}
endpoint.startAccepting()
service.connection = connection
service.endpoint = endpoint
service.ownedName = true
service.bindings = registrations
if !service.orca {
if handler, ok := service.handler.(WineReviewConnectionHandler); ok {
if native, ok := connection.(dbusConnection); ok {
handler.BindWineReviewConnection(native.Conn)
}
}
}
started = true
service.logger.Debug("remote D-Bus service ready", "name", name)
return nil
}
func (service *Service) Stop(ctx context.Context) error {
if ctx == nil {
return errors.New("remote service stop requires a non-nil context")
}
service.mutex.Lock()
connection := service.connection
endpoint := service.endpoint
ownedName := service.ownedName
bindings := service.bindings
service.connection = nil
service.endpoint = nil
service.ownedName = false
service.bindings = nil
service.mutex.Unlock()
if connection == nil {
return nil
}
idle := endpoint.stopAccepting()
if !service.orca {
if handler, ok := service.handler.(WineReviewConnectionHandler); ok {
handler.BindWineReviewConnection(nil)
}
}
var stopErrors []error
name := BusName
if service.orca {
name = OrcaBusName
}
for i := len(bindings) - 1; i >= 0; i-- {
binding := bindings[i]
if err := connection.Export(nil, binding.path, binding.iface); err != nil {
stopErrors = append(stopErrors, fmt.Errorf("unexport %s: %w", binding.iface, err))
}
}
if ownedName {
if reply, err := connection.ReleaseName(name); err != nil {
stopErrors = append(stopErrors, fmt.Errorf("release D-Bus name %s: %w", name, err))
} else if reply != dbus.ReleaseNameReplyReleased && reply != dbus.ReleaseNameReplyNonExistent {
stopErrors = append(stopErrors, fmt.Errorf("release D-Bus name %s: reply %d", name, reply))
}
}
select {
case <-idle:
case <-ctx.Done():
stopErrors = append(stopErrors, fmt.Errorf("wait for self-voice calls: %w", ctx.Err()))
}
if err := connection.Close(); err != nil {
stopErrors = append(stopErrors, fmt.Errorf("close session bus: %w", err))
}
return errors.Join(stopErrors...)
}
// Keep the Orca export limited to the methods it actually advertises.
type orcaServiceEndpoint struct {
endpoint *endpoint
modules []string
}
func (service *orcaServiceEndpoint) ShowPreferences(sender dbus.Sender) (bool, *dbus.Error) {
return service.endpoint.ShowPreferences(sender)
}
func (service *orcaServiceEndpoint) PresentMessage(sender dbus.Sender, message string) (bool, *dbus.Error) {
return service.endpoint.PresentMessage(sender, message)
}
func (service *orcaServiceEndpoint) GetVersion(sender dbus.Sender) (string, *dbus.Error) {
return service.endpoint.GetVersion(sender)
}
func (service *orcaServiceEndpoint) Quit(sender dbus.Sender) (bool, *dbus.Error) {
return service.endpoint.Quit(sender)
}
func (service *orcaServiceEndpoint) ListModules(sender dbus.Sender) ([]string, *dbus.Error) {
if err := service.endpoint.begin(sender, "ListModules"); err != nil {
return nil, err
}
defer service.endpoint.end()
return append([]string(nil), service.modules...), nil
}
func (service *orcaServiceEndpoint) ListCommands(sender dbus.Sender) ([]namedDescription, *dbus.Error) {
if err := service.endpoint.begin(sender, "ListCommands"); err != nil {
return nil, err
}
defer service.endpoint.end()
return []namedDescription{
{"GetVersion", "Returns Cthulhu's version."},
{"ListCommands", "Lists available service commands."},
{"ListModules", "Lists available modules."},
{"PresentMessage", "Presents a message."},
{"Quit", "Quits Cthulhu."},
{"ShowPreferences", "Shows preferences."},
}, nil
}