197 lines
5.2 KiB
Go
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()
|
|
}
|