Files
Chase CichorzandClaude Opus 5.5 551ef86d5f Export and display Remote Braille through the reader
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>
2026-11-06 16:06:38 -05:00

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
}