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