A controlled reader renders accepted application frames in a new virtual-only cthulhu-braille mode at the controller's width and applies its pan and route gestures; reader UI, flashes and secure frames are never exported, and hidden frames are unroutable. A controller reader reports its display width, shows peer cells as an untranslated raw frame and forwards local pan and routing keys. The physical helper now reports its width and edge pans. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
170 lines
4.4 KiB
Go
170 lines
4.4 KiB
Go
package braille
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"runtime"
|
|
"time"
|
|
)
|
|
|
|
// openRetryDelay and openRetryGiveUp bound the in-helper wait for a
|
|
// display to become ready. Variables so tests can shrink them.
|
|
var (
|
|
openRetryDelay = time.Second
|
|
openRetryGiveUp = 4 * time.Minute
|
|
)
|
|
|
|
// openWithRetry waits for a usable display inside the helper. While a
|
|
// display is switched off or still negotiating, BRLTTY stalls, refuses, or
|
|
// half-answers connection attempts (query failures, zero geometry); each
|
|
// is transient, so retrying here claims the display as soon as BRLTTY is
|
|
// ready instead of bouncing through supervisor respawn backoff. The last
|
|
// error is returned at the give-up bound so permanent misconfiguration
|
|
// (e.g. authorization) still surfaces to the parent's log.
|
|
func openWithRetry(ctx context.Context, backend Backend) (Geometry, error) {
|
|
deadline := time.NewTimer(openRetryGiveUp)
|
|
defer deadline.Stop()
|
|
for {
|
|
geometry, err := backend.Open()
|
|
if err == nil {
|
|
if _, cellErr := geometry.Cells(); cellErr == nil {
|
|
return geometry, nil
|
|
} else {
|
|
err = cellErr
|
|
}
|
|
_ = backend.Close()
|
|
}
|
|
select {
|
|
case <-ctx.Done():
|
|
return Geometry{}, ctx.Err()
|
|
case <-deadline.C:
|
|
return Geometry{}, err
|
|
case <-time.After(openRetryDelay):
|
|
}
|
|
}
|
|
}
|
|
|
|
// RunClient owns BrlAPI inside the disposable helper process. The parent can
|
|
// terminate this process if a native call blocks or BRLTTY disconnects.
|
|
func RunClient(ctx context.Context, backend Backend, input io.ReadCloser, ready io.Writer) (result error) {
|
|
// Keep the session on one OS thread. Migration between Open and ReadKey
|
|
// produced an unexpected EOF with the live BRLTTY connection.
|
|
runtime.LockOSThread()
|
|
defer runtime.UnlockOSThread()
|
|
geometry, err := openWithRetry(ctx, backend)
|
|
if err != nil {
|
|
if ctx.Err() != nil {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
defer func() { result = errors.Join(result, backend.Close()) }()
|
|
cells, err := geometry.Cells()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if _, err := fmt.Fprintf(ready, "ready %d\n", cells); err != nil {
|
|
return err
|
|
}
|
|
frames := make(chan Frame, 1)
|
|
readErrors := make(chan error, 1)
|
|
readDone := make(chan struct{})
|
|
// The session owns its input reader: close unblocks Scan, then join it.
|
|
defer func() { _ = input.Close(); <-readDone }()
|
|
go func() {
|
|
defer close(readDone)
|
|
scanner := bufio.NewScanner(input)
|
|
scanner.Buffer(make([]byte, 4096), 65536)
|
|
for scanner.Scan() {
|
|
var frame Frame
|
|
if err := json.Unmarshal(scanner.Bytes(), &frame); err != nil {
|
|
readErrors <- fmt.Errorf("decode braille frame: %w", err)
|
|
return
|
|
}
|
|
offerLatestFrame(frames, frame)
|
|
}
|
|
readErrors <- scanner.Err()
|
|
}()
|
|
v := viewport{width: cells}
|
|
encoder := json.NewEncoder(ready)
|
|
ticker := time.NewTicker(20 * time.Millisecond)
|
|
defer ticker.Stop()
|
|
inputClosed := false
|
|
for {
|
|
if inputClosed && len(frames) == 0 {
|
|
return nil
|
|
}
|
|
select {
|
|
case <-ctx.Done():
|
|
return nil
|
|
case err := <-readErrors:
|
|
if err != nil {
|
|
return err
|
|
}
|
|
inputClosed = true
|
|
readErrors = nil
|
|
case frame := <-frames:
|
|
if err := v.setFrame(frame); err != nil {
|
|
return err
|
|
}
|
|
if err := writeViewport(backend, &v); err != nil {
|
|
return err
|
|
}
|
|
case <-ticker.C:
|
|
key, err := backend.ReadKey()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if v.pan(key) {
|
|
if err := writeViewport(backend, &v); err != nil {
|
|
return err
|
|
}
|
|
} else if key == PanLeft || key == PanRight {
|
|
// The parent may forward an edge pan to a Remote peer.
|
|
pan := "left"
|
|
if key == PanRight {
|
|
pan = "right"
|
|
}
|
|
if err := encoder.Encode(RoutingEvent{ID: v.id, Offset: -1, Pan: pan}); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if event, ok := v.route(key); ok {
|
|
if err := encoder.Encode(event); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
type dotWriter interface {
|
|
WriteWithDots(string, int, []byte) error
|
|
}
|
|
|
|
func writeViewport(backend Backend, viewport *viewport) error {
|
|
if writer, ok := backend.(dotWriter); ok {
|
|
return writer.WriteWithDots(viewport.frame(), viewport.displayCursor(), viewport.displayDots())
|
|
}
|
|
return backend.Write(viewport.frame(), viewport.displayCursor())
|
|
}
|
|
|
|
// The consumer may remove the old frame after the first nonblocking send
|
|
// fails. Draining must therefore also be nonblocking.
|
|
func offerLatestFrame(frames chan Frame, frame Frame) {
|
|
select {
|
|
case frames <- frame:
|
|
return
|
|
default:
|
|
}
|
|
select {
|
|
case <-frames:
|
|
default:
|
|
}
|
|
frames <- frame
|
|
}
|