323 lines
9.7 KiB
Go
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
|
|
}
|