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,210 @@
|
||||
// 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 (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
)
|
||||
|
||||
type messagePackBinary string
|
||||
|
||||
func decodeMessagePack(reader *bytes.Reader) (any, error) {
|
||||
return decodeMessagePackDepth(reader, 0)
|
||||
}
|
||||
|
||||
func decodeMessagePackDepth(reader *bytes.Reader, depth int) (any, error) {
|
||||
if depth > 64 {
|
||||
return nil, errors.New("MessagePack nesting exceeds limit")
|
||||
}
|
||||
code, err := reader.ReadByte()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch {
|
||||
case code <= 0x7f:
|
||||
return int64(code), nil
|
||||
case code >= 0xe0:
|
||||
return int64(int8(code)), nil
|
||||
case code >= 0x80 && code <= 0x8f:
|
||||
return decodeMessagePackMap(reader, int(code&0x0f), depth)
|
||||
case code >= 0x90 && code <= 0x9f:
|
||||
return decodeMessagePackArray(reader, int(code&0x0f), depth)
|
||||
case code >= 0xa0 && code <= 0xbf:
|
||||
return decodeMessagePackString(reader, int(code&0x1f))
|
||||
case code == 0xc0:
|
||||
return nil, nil
|
||||
case code == 0xc2:
|
||||
return false, nil
|
||||
case code == 0xc3:
|
||||
return true, nil
|
||||
case code == 0xc4 || code == 0xc5 || code == 0xc6:
|
||||
length, err := decodeLength(reader, code, 0xc4)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
value := make([]byte, length)
|
||||
if _, err := io.ReadFull(reader, value); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return messagePackBinary(value), nil
|
||||
case code >= 0xca && code <= 0xcb:
|
||||
return decodeMessagePackFloat(reader, code)
|
||||
case code >= 0xcc && code <= 0xd3:
|
||||
return decodeMessagePackInteger(reader, code)
|
||||
case code == 0xd9 || code == 0xda || code == 0xdb:
|
||||
length, err := decodeLength(reader, code, 0xd9)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return decodeMessagePackString(reader, length)
|
||||
case code == 0xdc || code == 0xdd:
|
||||
length, err := decodeLength(reader, code, 0xdc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return decodeMessagePackArray(reader, length, depth)
|
||||
case code == 0xde || code == 0xdf:
|
||||
length, err := decodeLength(reader, code, 0xde)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return decodeMessagePackMap(reader, length, depth)
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported MessagePack type 0x%02x", code)
|
||||
}
|
||||
}
|
||||
|
||||
func decodeLength(reader *bytes.Reader, code, base byte) (int, error) {
|
||||
var size int
|
||||
switch code - base {
|
||||
case 0:
|
||||
size = 1
|
||||
case 1:
|
||||
size = 2
|
||||
case 2:
|
||||
size = 4
|
||||
default:
|
||||
return 0, fmt.Errorf("invalid length type 0x%02x", code)
|
||||
}
|
||||
buffer := make([]byte, size)
|
||||
if _, err := io.ReadFull(reader, buffer); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
value := uint64(0)
|
||||
for _, octet := range buffer {
|
||||
value = value<<8 | uint64(octet)
|
||||
}
|
||||
if value > uint64(maxPayload) {
|
||||
return 0, errors.New("MessagePack value exceeds 64 KiB")
|
||||
}
|
||||
return int(value), nil
|
||||
}
|
||||
|
||||
func decodeMessagePackString(reader *bytes.Reader, length int) (string, error) {
|
||||
value := make([]byte, length)
|
||||
if _, err := io.ReadFull(reader, value); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(value), nil
|
||||
}
|
||||
|
||||
func decodeMessagePackFloat(reader *bytes.Reader, code byte) (any, error) {
|
||||
var buffer []byte
|
||||
if code == 0xca {
|
||||
buffer = make([]byte, 4)
|
||||
} else {
|
||||
buffer = make([]byte, 8)
|
||||
}
|
||||
if _, err := io.ReadFull(reader, buffer); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if code == 0xca {
|
||||
bits := binary.BigEndian.Uint32(buffer)
|
||||
return float64(math.Float32frombits(bits)), nil
|
||||
}
|
||||
return math.Float64frombits(binary.BigEndian.Uint64(buffer)), nil
|
||||
}
|
||||
|
||||
func decodeMessagePackInteger(reader *bytes.Reader, code byte) (any, error) {
|
||||
switch code {
|
||||
case 0xcc, 0xd0:
|
||||
buffer := make([]byte, 1)
|
||||
if _, err := io.ReadFull(reader, buffer); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if code == 0xcc {
|
||||
return uint64(buffer[0]), nil
|
||||
}
|
||||
return int64(int8(buffer[0])), nil
|
||||
case 0xcd, 0xd1:
|
||||
buffer := make([]byte, 2)
|
||||
if _, err := io.ReadFull(reader, buffer); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
value := binary.BigEndian.Uint16(buffer)
|
||||
if code == 0xcd {
|
||||
return uint64(value), nil
|
||||
}
|
||||
return int64(int16(value)), nil
|
||||
case 0xce, 0xd2:
|
||||
buffer := make([]byte, 4)
|
||||
if _, err := io.ReadFull(reader, buffer); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
value := binary.BigEndian.Uint32(buffer)
|
||||
if code == 0xce {
|
||||
return uint64(value), nil
|
||||
}
|
||||
return int64(int32(value)), nil
|
||||
case 0xcf, 0xd3:
|
||||
buffer := make([]byte, 8)
|
||||
if _, err := io.ReadFull(reader, buffer); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
value := binary.BigEndian.Uint64(buffer)
|
||||
if code == 0xcf {
|
||||
return value, nil
|
||||
}
|
||||
return int64(value), nil
|
||||
default:
|
||||
return nil, fmt.Errorf("invalid integer type 0x%02x", code)
|
||||
}
|
||||
}
|
||||
|
||||
func decodeMessagePackArray(reader *bytes.Reader, length, depth int) ([]any, error) {
|
||||
values := make([]any, length)
|
||||
for index := range values {
|
||||
value, err := decodeMessagePackDepth(reader, depth+1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
values[index] = value
|
||||
}
|
||||
return values, nil
|
||||
}
|
||||
|
||||
func decodeMessagePackMap(reader *bytes.Reader, length, depth int) (map[any]any, error) {
|
||||
values := make(map[any]any, length)
|
||||
for range length {
|
||||
key, err := decodeMessagePackDepth(reader, depth+1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch key.(type) {
|
||||
case string, messagePackBinary:
|
||||
default:
|
||||
return nil, errors.New("MessagePack map key must be text")
|
||||
}
|
||||
value, err := decodeMessagePackDepth(reader, depth+1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
values[key] = value
|
||||
}
|
||||
return values, nil
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package nvda2cthulhu
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
func TestDecodeMessagePackRequests(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
payload []byte
|
||||
want bridgeRequest
|
||||
}{
|
||||
{name: "fix string", payload: []byte{0xa4, 'G', 'a', 'm', 'e'}, want: bridgeRequest{kind: "SpeakText", text: "Game"}},
|
||||
{name: "map with string", payload: []byte{0x81, 0xa9, 'S', 'p', 'e', 'a', 'k', 'T', 'e', 'x', 't', 0xa4, 'G', 'a', 'm', 'e'}, want: bridgeRequest{kind: "SpeakText", text: "Game"}},
|
||||
{name: "array cancel", payload: []byte{0x91, 0xac, 'C', 'a', 'n', 'c', 'e', 'l', 'S', 'p', 'e', 'e', 'c', 'h'}, want: bridgeRequest{kind: "CancelSpeech"}},
|
||||
{name: "bin value", payload: []byte{0x81, 0xab, 'B', 'r', 'a', 'i', 'l', 'l', 'e', 'T', 'e', 'x', 't', 0xc4, 0x02, 'O', 'K'}, want: bridgeRequest{kind: "BrailleText", text: "OK"}},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
got, err := decodeRequest(websocket.BinaryMessage, test.payload)
|
||||
if err != nil {
|
||||
t.Fatalf("decodeRequest() error = %v", err)
|
||||
}
|
||||
if got.kind != test.want.kind || got.text != test.want.text {
|
||||
t.Fatalf("decodeRequest() = %+v, want %+v", got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeMessagePackRejectsMalformedContainers(t *testing.T) {
|
||||
for _, payload := range [][]byte{
|
||||
{0x81, 0x90, 0xc0}, // An array key cannot be inserted into a Go map.
|
||||
append(append([]byte{}, bytes.Repeat([]byte{0x91}, 66)...), 0xc0),
|
||||
{0xa2, 'O', 'K', 0xc0}, // One WebSocket frame contains exactly one request.
|
||||
} {
|
||||
if _, err := decodeRequest(websocket.BinaryMessage, payload); err == nil {
|
||||
t.Fatalf("accepted malformed MessagePack payload %x", payload)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
// 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 (
|
||||
"bytes"
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
func (server *Server) deliver(request bridgeRequest) {
|
||||
switch request.kind {
|
||||
case "SpeakText":
|
||||
if server.handler.SpeakText(request.text, true) {
|
||||
server.logger.Debug("NVDA to Cthulhu speech accepted", "bytes", len(request.text))
|
||||
} else {
|
||||
server.logger.Warn("NVDA to Cthulhu speech rejected", "bytes", len(request.text))
|
||||
}
|
||||
case "BrailleText":
|
||||
if server.handler.BrailleMessage(request.text) {
|
||||
server.logger.Debug("NVDA to Cthulhu braille accepted", "bytes", len(request.text))
|
||||
} else {
|
||||
server.logger.Debug("NVDA to Cthulhu braille unavailable")
|
||||
}
|
||||
case "CancelSpeech":
|
||||
if server.handler.CancelSpeech() {
|
||||
server.logger.Debug("NVDA to Cthulhu cancellation accepted")
|
||||
} else {
|
||||
server.logger.Warn("NVDA to Cthulhu cancellation rejected")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type bridgeRequest struct {
|
||||
kind, text string
|
||||
}
|
||||
|
||||
func decodeRequest(messageType int, payload []byte) (bridgeRequest, error) {
|
||||
if len(payload) > maxPayload {
|
||||
return bridgeRequest{}, errors.New("message exceeds 64 KiB")
|
||||
}
|
||||
switch messageType {
|
||||
case websocket.TextMessage:
|
||||
return parseRequest(string(payload))
|
||||
case websocket.BinaryMessage:
|
||||
reader := bytes.NewReader(payload)
|
||||
value, err := decodeMessagePack(reader)
|
||||
if err != nil {
|
||||
return bridgeRequest{}, err
|
||||
}
|
||||
if reader.Len() != 0 {
|
||||
return bridgeRequest{}, errors.New("trailing MessagePack data")
|
||||
}
|
||||
return parseRequest(value)
|
||||
default:
|
||||
return bridgeRequest{}, errors.New("unsupported WebSocket message type")
|
||||
}
|
||||
}
|
||||
|
||||
func parseRequest(value any) (bridgeRequest, error) {
|
||||
switch payload := value.(type) {
|
||||
case string:
|
||||
if strings.TrimSpace(payload) == "" {
|
||||
return bridgeRequest{}, errors.New("blank text request")
|
||||
}
|
||||
if payload == "CancelSpeech" {
|
||||
return bridgeRequest{kind: "CancelSpeech"}, nil
|
||||
}
|
||||
return bridgeRequest{kind: "SpeakText", text: payload}, nil
|
||||
case map[any]any:
|
||||
if len(payload) != 1 {
|
||||
return bridgeRequest{}, errors.New("map request must contain exactly one entry")
|
||||
}
|
||||
for key, value := range payload {
|
||||
return keyedRequest(coerceText(key), value)
|
||||
}
|
||||
case map[string]any:
|
||||
if len(payload) != 1 {
|
||||
return bridgeRequest{}, errors.New("map request must contain exactly one entry")
|
||||
}
|
||||
for key, value := range payload {
|
||||
return keyedRequest(key, value)
|
||||
}
|
||||
case []any:
|
||||
if len(payload) == 0 {
|
||||
return bridgeRequest{}, errors.New("array request is empty")
|
||||
}
|
||||
kind := coerceText(payload[0])
|
||||
if kind == "CancelSpeech" {
|
||||
return bridgeRequest{kind: "CancelSpeech"}, nil
|
||||
}
|
||||
if len(payload) < 2 {
|
||||
return bridgeRequest{}, errors.New("array request has no value")
|
||||
}
|
||||
return keyedRequest(kind, payload[1])
|
||||
}
|
||||
return bridgeRequest{}, errors.New("unsupported MessagePack payload")
|
||||
}
|
||||
|
||||
func keyedRequest(kind string, value any) (bridgeRequest, error) {
|
||||
switch kind {
|
||||
case "CancelSpeech":
|
||||
return bridgeRequest{kind: "CancelSpeech"}, nil
|
||||
case "SpeakText", "BrailleText":
|
||||
if strings.TrimSpace(coerceText(value)) == "" {
|
||||
return bridgeRequest{}, errors.New("blank text request")
|
||||
}
|
||||
return bridgeRequest{kind: kind, text: coerceText(value)}, nil
|
||||
default:
|
||||
return bridgeRequest{}, errors.New("unsupported request kind")
|
||||
}
|
||||
}
|
||||
|
||||
func coerceText(value any) string {
|
||||
switch text := value.(type) {
|
||||
case string:
|
||||
return text
|
||||
case messagePackBinary:
|
||||
return string(text)
|
||||
case nil:
|
||||
return ""
|
||||
case bool:
|
||||
return strconv.FormatBool(text)
|
||||
case int:
|
||||
return strconv.Itoa(text)
|
||||
case int8:
|
||||
return strconv.FormatInt(int64(text), 10)
|
||||
case int16:
|
||||
return strconv.FormatInt(int64(text), 10)
|
||||
case int32:
|
||||
return strconv.FormatInt(int64(text), 10)
|
||||
case int64:
|
||||
return strconv.FormatInt(text, 10)
|
||||
case uint:
|
||||
return strconv.FormatUint(uint64(text), 10)
|
||||
case uint8:
|
||||
return strconv.FormatUint(uint64(text), 10)
|
||||
case uint16:
|
||||
return strconv.FormatUint(uint64(text), 10)
|
||||
case uint32:
|
||||
return strconv.FormatUint(uint64(text), 10)
|
||||
case uint64:
|
||||
return strconv.FormatUint(text, 10)
|
||||
case float32:
|
||||
return strconv.FormatFloat(float64(text), 'g', -1, 32)
|
||||
case float64:
|
||||
return strconv.FormatFloat(text, 'g', -1, 64)
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package nvda2cthulhu
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseRequestMatchesPythonCompatibilityProtocol(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
value any
|
||||
want bridgeRequest
|
||||
}{
|
||||
{name: "text", value: "Game text", want: bridgeRequest{kind: "SpeakText", text: "Game text"}},
|
||||
{name: "cancel string", value: "CancelSpeech", want: bridgeRequest{kind: "CancelSpeech"}},
|
||||
{name: "speech map", value: map[any]any{"SpeakText": "Map text"}, want: bridgeRequest{kind: "SpeakText", text: "Map text"}},
|
||||
{name: "braille map", value: map[string]any{"BrailleText": "Braille"}, want: bridgeRequest{kind: "BrailleText", text: "Braille"}},
|
||||
{name: "cancel map", value: map[any]any{"CancelSpeech": nil}, want: bridgeRequest{kind: "CancelSpeech"}},
|
||||
{name: "speech array", value: []any{"SpeakText", "Array text"}, want: bridgeRequest{kind: "SpeakText", text: "Array text"}},
|
||||
{name: "cancel array", value: []any{"CancelSpeech"}, want: bridgeRequest{kind: "CancelSpeech"}},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
got, err := parseRequest(test.value)
|
||||
if err != nil {
|
||||
t.Fatalf("parseRequest() error = %v", err)
|
||||
}
|
||||
if got.kind != test.want.kind || got.text != test.want.text {
|
||||
t.Fatalf("parseRequest() = %+v, want %+v", got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRequestRejectsUnknownAndMalformedPayloads(t *testing.T) {
|
||||
for _, value := range []any{"", map[any]any{}, map[any]any{"Two": "values", "SpeakText": "text"}, []any{}, []any{"Unknown", "value"}} {
|
||||
if request, err := parseRequest(value); err == nil {
|
||||
t.Fatalf("parseRequest(%#v) = %+v, want error", value, request)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
// 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()
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
package nvda2cthulhu
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
type recordingHandler struct {
|
||||
mutex sync.Mutex
|
||||
spoken []string
|
||||
interrupt []bool
|
||||
braille []string
|
||||
canceled int
|
||||
}
|
||||
|
||||
func (handler *recordingHandler) SpeakText(text string, interrupt bool) bool {
|
||||
handler.mutex.Lock()
|
||||
defer handler.mutex.Unlock()
|
||||
handler.spoken = append(handler.spoken, text)
|
||||
handler.interrupt = append(handler.interrupt, interrupt)
|
||||
return true
|
||||
}
|
||||
|
||||
func (handler *recordingHandler) BrailleMessage(text string) bool {
|
||||
handler.mutex.Lock()
|
||||
defer handler.mutex.Unlock()
|
||||
handler.braille = append(handler.braille, text)
|
||||
return true
|
||||
}
|
||||
|
||||
func (handler *recordingHandler) CancelSpeech() bool {
|
||||
handler.mutex.Lock()
|
||||
defer handler.mutex.Unlock()
|
||||
handler.canceled++
|
||||
return true
|
||||
}
|
||||
|
||||
func TestAllowedOriginAcceptsNativeAndLoopbackClients(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
origin string
|
||||
want bool
|
||||
}{
|
||||
{"", true},
|
||||
{"http://127.0.0.1:3457", true},
|
||||
{"http://localhost:3457", true},
|
||||
{"https://[::1]:3457", true},
|
||||
{"https://example.com", false},
|
||||
{"null", false},
|
||||
} {
|
||||
request := httptest.NewRequest(http.MethodGet, "http://127.0.0.1:3457/", nil)
|
||||
if test.origin != "" {
|
||||
request.Header.Set("Origin", test.origin)
|
||||
}
|
||||
if got := allowedOrigin(request); got != test.want {
|
||||
t.Errorf("allowedOrigin(%q) = %v, want %v", test.origin, got, test.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func liveSocketTest(t *testing.T) {
|
||||
t.Helper()
|
||||
handler := &recordingHandler{}
|
||||
server := New(nil, handler)
|
||||
httpTest := httptest.NewServer(http.HandlerFunc(server.handleHTTP))
|
||||
defer httpTest.Close()
|
||||
url := "ws" + strings.TrimPrefix(httpTest.URL, "http")
|
||||
connection, _, err := websocket.DefaultDialer.Dial(url, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("dial WebSocket: %v", err)
|
||||
}
|
||||
defer connection.Close()
|
||||
|
||||
if err := connection.WriteMessage(websocket.TextMessage, []byte("Accessibility mod loaded.")); err != nil {
|
||||
t.Fatalf("write speech text: %v", err)
|
||||
}
|
||||
if err := connection.WriteMessage(websocket.BinaryMessage, []byte{0x81, 0xab, 'B', 'r', 'a', 'i', 'l', 'l', 'e', 'T', 'e', 'x', 't', 0xa2, 'O', 'K'}); err != nil {
|
||||
t.Fatalf("write braille text: %v", err)
|
||||
}
|
||||
if err := connection.WriteMessage(websocket.TextMessage, []byte("CancelSpeech")); err != nil {
|
||||
t.Fatalf("write cancellation: %v", err)
|
||||
}
|
||||
|
||||
waitForHandlerCalls(t, handler)
|
||||
handler.mutex.Lock()
|
||||
defer handler.mutex.Unlock()
|
||||
if len(handler.spoken) != 1 || handler.spoken[0] != "Accessibility mod loaded." || len(handler.interrupt) != 1 || !handler.interrupt[0] {
|
||||
t.Fatalf("speech calls = %v, interrupts = %v", handler.spoken, handler.interrupt)
|
||||
}
|
||||
if len(handler.braille) != 1 || handler.braille[0] != "OK" {
|
||||
t.Fatalf("braille calls = %v", handler.braille)
|
||||
}
|
||||
if handler.canceled != 1 {
|
||||
t.Fatalf("cancellations = %d, want 1", handler.canceled)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerHandlesWebSocketRequests(t *testing.T) {
|
||||
if os.Getenv("CTHULHU_LIVE_SOCKET_TEST") != "1" {
|
||||
t.Skip("set CTHULHU_LIVE_SOCKET_TEST=1 to allow a local TCP listener")
|
||||
}
|
||||
liveSocketTest(t)
|
||||
}
|
||||
|
||||
func TestStartStopClosesActiveConnection(t *testing.T) {
|
||||
if os.Getenv("CTHULHU_LIVE_SOCKET_TEST") != "1" {
|
||||
t.Skip("set CTHULHU_LIVE_SOCKET_TEST=1 to allow a local TCP listener")
|
||||
}
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("find port: %v", err)
|
||||
}
|
||||
port := listener.Addr().(*net.TCPAddr).Port
|
||||
if err := listener.Close(); err != nil {
|
||||
t.Fatalf("release port: %v", err)
|
||||
}
|
||||
t.Setenv(hostEnv, fmt.Sprintf("http://127.0.0.1:%d", port))
|
||||
|
||||
server := New(nil, &recordingHandler{})
|
||||
if err := server.Start(context.Background()); err != nil {
|
||||
t.Fatalf("start server: %v", err)
|
||||
}
|
||||
connection, _, err := websocket.DefaultDialer.Dial(fmt.Sprintf("ws://127.0.0.1:%d", port), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("dial server: %v", err)
|
||||
}
|
||||
defer connection.Close()
|
||||
if err := server.Stop(context.Background()); err != nil {
|
||||
t.Fatalf("stop server: %v", err)
|
||||
}
|
||||
if _, _, err := connection.ReadMessage(); err == nil {
|
||||
t.Fatal("active connection remained open after Stop")
|
||||
}
|
||||
}
|
||||
|
||||
func waitForHandlerCalls(t *testing.T, handler *recordingHandler) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
handler.mutex.Lock()
|
||||
done := len(handler.spoken) == 1 && len(handler.braille) == 1 && handler.canceled == 1
|
||||
handler.mutex.Unlock()
|
||||
if done {
|
||||
return
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBindAddressUsesPythonDefault(t *testing.T) {
|
||||
t.Setenv(hostEnv, "http://127.0.0.1")
|
||||
if address := bindAddress(); address != "127.0.0.1:3457" {
|
||||
t.Fatalf("default address = %q", address)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStopWithoutStartSucceeds(t *testing.T) {
|
||||
server := New(nil, nil)
|
||||
if err := server.Stop(context.Background()); err != nil {
|
||||
t.Fatalf("stop unstarted server: %v", err)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user