Files

197 lines
5.2 KiB
Go

// Package nvda2cthulhu accepts the local WebSocket protocol used by NVDA
// compatibility bridges and routes it to Cthulhu's speech and braille queues.
package nvda2cthulhu
import (
"context"
"errors"
"fmt"
"log/slog"
"net"
"net/http"
"net/url"
"os"
"strconv"
"sync"
"time"
"github.com/gorilla/websocket"
)
const (
defaultPort = 3457
hostEnv = "NVDA2SPEECHD_HOST"
maxPayload = 64 * 1024
)
// Handler accepts already-decoded bridge requests. Implementations must
// enqueue backend work instead of waiting for speech or braille output.
type Handler interface {
SpeakText(string, bool) bool
BrailleMessage(string) bool
CancelSpeech() bool
}
// Server owns the local bridge listener and active WebSocket connections.
type Server struct {
logger *slog.Logger
handler Handler
connections map[*websocket.Conn]struct{}
connectionWait sync.WaitGroup
stopOnce sync.Once
stopped chan struct{}
mutex sync.Mutex
server *http.Server
}
// New creates a bridge server. A nil logger discards diagnostics.
func New(logger *slog.Logger, handler Handler) *Server {
if logger == nil {
logger = slog.New(slog.DiscardHandler)
}
return &Server{
logger: logger,
handler: handler,
connections: make(map[*websocket.Conn]struct{}),
stopped: make(chan struct{}),
}
}
// Start binds the same local address used by Python Cthulhu. The listener is
// established before Start returns, while connection handling stays async.
func (server *Server) Start(ctx context.Context) error {
listener, err := net.Listen("tcp", bindAddress())
if err != nil {
return fmt.Errorf("bind NVDA to Cthulhu bridge: %w", err)
}
httpServer := &http.Server{
Handler: http.HandlerFunc(server.handleHTTP),
ReadHeaderTimeout: 5 * time.Second,
}
server.mutex.Lock()
server.server = httpServer
server.mutex.Unlock()
go func() {
if err := httpServer.Serve(listener); err != nil && !errors.Is(err, http.ErrServerClosed) {
server.logger.Warn("NVDA to Cthulhu server stopped", "error", err)
}
}()
server.logger.Info("NVDA to Cthulhu listening", "address", bindAddress())
return nil
}
// Stop closes the listener and active connections.
func (server *Server) Stop(ctx context.Context) error {
server.stopOnce.Do(func() {
close(server.stopped)
})
server.mutex.Lock()
httpServer := server.server
connections := make([]*websocket.Conn, 0, len(server.connections))
for connection := range server.connections {
connections = append(connections, connection)
}
server.mutex.Unlock()
for _, connection := range connections {
_ = connection.Close()
}
if httpServer == nil {
return server.waitConnections(ctx)
}
if err := httpServer.Shutdown(ctx); err != nil && !errors.Is(err, context.Canceled) {
return fmt.Errorf("stop NVDA to Cthulhu bridge: %w", err)
}
return server.waitConnections(ctx)
}
func (server *Server) handleHTTP(response http.ResponseWriter, request *http.Request) {
if request.URL.Path != "/" {
http.NotFound(response, request)
return
}
upgrader := websocket.Upgrader{
CheckOrigin: allowedOrigin,
}
connection, err := upgrader.Upgrade(response, request, nil)
if err != nil {
server.logger.Warn("NVDA to Cthulhu connection rejected", "error", err)
return
}
defer connection.Close()
server.mutex.Lock()
select {
case <-server.stopped:
server.mutex.Unlock()
return
default:
server.connectionWait.Add(1)
server.connections[connection] = struct{}{}
server.mutex.Unlock()
}
defer func() {
server.mutex.Lock()
delete(server.connections, connection)
server.mutex.Unlock()
server.connectionWait.Done()
}()
connection.SetReadLimit(maxPayload)
server.logger.Debug("NVDA to Cthulhu client connected", "remote", connection.RemoteAddr().String())
for {
messageType, payload, err := connection.ReadMessage()
if err != nil {
if !websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) {
server.logger.Debug("NVDA to Cthulhu client disconnected", "error", err)
}
return
}
if len(payload) > maxPayload {
server.logger.Warn("NVDA to Cthulhu message too large", "size", len(payload))
return
}
request, err := decodeRequest(messageType, payload)
if err != nil {
server.logger.Warn("NVDA to Cthulhu rejected message", "error", err)
continue
}
server.deliver(request)
}
}
func (server *Server) waitConnections(ctx context.Context) error {
done := make(chan struct{})
go func() {
server.connectionWait.Wait()
close(done)
}()
select {
case <-done:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
func bindAddress() string {
port := defaultPort
if value := os.Getenv(hostEnv); value != "" {
if parsed, err := url.Parse(value); err == nil && parsed.Port() != "" {
port, _ = strconv.Atoi(parsed.Port())
}
}
return net.JoinHostPort("127.0.0.1", strconv.Itoa(port))
}
func allowedOrigin(request *http.Request) bool {
origin := request.Header.Get("Origin")
if origin == "" {
return true // Native bridge clients do not send a browser origin.
}
parsed, err := url.Parse(origin)
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.User != nil {
return false
}
host := parsed.Hostname()
return host == "localhost" || net.ParseIP(host).IsLoopback()
}