Cthulhu is no longer a fork. Now it is a screen reader written in Golang.
This commit is contained in:
1 parent
a998fe67de
commit
069fc674dc
708 files changed
+112482
-532
No files matched your search
@@ -0,0 +1,322 @@
|
||||
// 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
|
||||
}
|
||||
Reference in new issue
Block a user