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