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>
This commit is contained in:
Chase CichorzandClaude Opus 5.5 committed 2026-11-06 16:06:38 -05:00
1 parent 2651b5e9bd
commit 551ef86d5f
16 files changed
+925 -13

No files matched your search

+20 -4
View File
@@ -46,7 +46,7 @@ func (worker *brailleWorker) SetInternalUIFrame(frame *braille.Frame) {
worker.flashContext = nil
worker.protectedFlash = false
worker.internalUIFrame = nil
next := worker.baseFrame
next := worker.shown_base_locked()
if frame != nil {
next = *frame
next.WordWrap = worker.wordWrap
@@ -107,16 +107,25 @@ func (worker *brailleWorker) enqueueSecureFrameLocked(frame braille.Frame, base
if worker.internalUIFrame != nil {
if base {
worker.baseFrame = frame
worker.export_locked(frame, 0) // Not displayed: unroutable.
}
return false
}
if worker.cancel == nil || worker.stopping {
return false
}
if base && worker.remoteFrame != nil {
// A peer's display owns the base position; keep the newest local
// frame for restoration without showing it.
worker.baseFrame = frame
worker.export_locked(frame, 0)
return false
}
if base {
worker.flashContext = nil
if worker.protectedFlash {
worker.baseFrame = frame
worker.export_locked(frame, 0)
return false
}
worker.flashGeneration++
@@ -129,8 +138,15 @@ func (worker *brailleWorker) enqueueSecureFrameLocked(frame braille.Frame, base
frame.Contract = worker.contracted
frame.Table = worker.table
frame.ExpandAtCursor = frame.Cursor >= 0 && len(frame.Routes) > 0
if frame.Raw {
// Final peer cells: never translate or wrap them again.
frame.Contract, frame.Table, frame.ExpandAtCursor, frame.WordWrap = false, "", false, false
}
worker.sequence++
frame.ID = worker.sequence
if base {
worker.export_locked(frame, frame.ID)
}
frame.Routes = append([]braille.TextRoute(nil), frame.Routes...)
frame.Dots = append([]byte(nil), frame.Dots...)
if worker.routeCancel != nil {
@@ -202,7 +218,7 @@ func (worker *brailleWorker) flash_message_guarded(text string, options config.B
worker.flashTimer = nil
worker.flashContext = nil
worker.protectedFlash = false
queued := worker.enqueueFrameLocked(worker.baseFrame, false)
queued := worker.enqueueFrameLocked(worker.shown_base_locked(), false)
worker.mu.Unlock()
if queued {
worker.wakeDisplay()
@@ -228,7 +244,7 @@ func (worker *brailleWorker) DismissConfirmation() {
worker.flashTimer.Stop()
worker.flashTimer = nil
}
queued := worker.enqueueFrameLocked(worker.baseFrame, false)
queued := worker.enqueueFrameLocked(worker.shown_base_locked(), false)
worker.mu.Unlock()
if queued {
worker.wakeDisplay()
@@ -286,7 +302,7 @@ func (worker *brailleWorker) retirePresentationLocked(owner context.Context) boo
worker.flashTimer.Stop()
worker.flashTimer = nil
}
return worker.enqueueFrameLocked(worker.baseFrame, false)
return worker.enqueueFrameLocked(worker.shown_base_locked(), false)
}
func (worker *brailleWorker) retirePresentation(owner context.Context) {
+133
View File
@@ -0,0 +1,133 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package app
import (
"context"
"strings"
"unicode/utf8"
"git.stormux.org/storm/cthulhu/internal/braille"
)
// shown_base_locked is the frame a flash, reader UI or message restores to:
// a peer's cells while they own the display, otherwise the application frame.
func (worker *brailleWorker) shown_base_locked() braille.Frame {
if worker.remoteFrame != nil {
return *worker.remoteFrame
}
return worker.baseFrame
}
// export_locked offers an accepted application frame to a Remote exporter.
// Only base frames reach it: reader UI, flashed messages and secure-input
// frames are never exported. The callback must not block.
// id is the displayed frame's ID, or 0 when the frame is stored but not
// shown, so routing from the peer can only reach the frame it saw.
func (worker *brailleWorker) export_locked(frame braille.Frame, id uint64) {
if worker.remoteExport == nil || worker.secure || frame.Raw {
return
}
frame.Contract, frame.Table, frame.WordWrap = worker.contracted, worker.table, worker.wordWrap
frame.ID = id
frame.Routes, frame.Virtual, frame.Document = nil, nil, nil
frame.Dots = append([]byte(nil), frame.Dots...)
worker.remoteExport(frame)
}
// SetRemoteExport installs or removes (nil) the exporter and returns the
// current application frame so a new export starts from it.
func (worker *brailleWorker) SetRemoteExport(export func(braille.Frame)) (braille.Frame, bool) {
worker.mu.Lock()
defer worker.mu.Unlock()
worker.remoteExport = export
if export == nil || worker.secure {
return braille.Frame{}, false
}
frame := worker.baseFrame
frame.Contract, frame.Table, frame.WordWrap = worker.contracted, worker.table, worker.wordWrap
if worker.internalUIFrame == nil && worker.remoteFrame == nil {
frame.ID = worker.frame.ID
} else {
frame.ID = 0
}
frame.Routes, frame.Virtual, frame.Document = nil, nil, nil
frame.Dots = append([]byte(nil), frame.Dots...)
return frame, true
}
// RouteExported routes a cell chosen on a peer's display. The event must name
// the frame currently shown here; anything else is stale and ignored.
func (worker *brailleWorker) RouteExported(ctx context.Context, event braille.RoutingEvent) {
if event.ID == 0 || event.Pan != "" {
return
}
worker.route(ctx, event)
}
// RemoteDisplayWidth is the connected display's cell count, or 0.
func (worker *brailleWorker) RemoteDisplayWidth() int {
worker.mu.Lock()
defer worker.mu.Unlock()
if !worker.connected {
return 0
}
return worker.cells
}
// SetRemoteCells shows a peer's final cells in place of the application frame.
// Reader UI and flashed messages still take precedence. gesture receives pan
// and routing keys pressed while these cells are shown. nil cells restore the
// application frame.
func (worker *brailleWorker) SetRemoteCells(cells []int, gesture func(string, int)) {
worker.mu.Lock()
if cells == nil {
if worker.remoteFrame == nil {
worker.mu.Unlock()
return
}
worker.remoteFrame, worker.remoteGesture = nil, nil
queued := false
if worker.flashTimer == nil && !worker.protectedFlash {
queued = worker.enqueueFrameLocked(worker.baseFrame, false)
}
worker.mu.Unlock()
if queued {
worker.wakeDisplay()
}
return
}
var text strings.Builder
for _, cell := range cells {
text.WriteRune(rune(0x2800 + cell))
}
frame := braille.Frame{Text: text.String(), Cursor: -1, Raw: true}
worker.remoteFrame, worker.remoteGesture = &frame, gesture
queued := false
if worker.flashTimer == nil && !worker.protectedFlash {
queued = worker.enqueueFrameLocked(frame, false)
}
worker.mu.Unlock()
if queued {
worker.wakeDisplay()
}
}
// remote_gesture_locked reports a key pressed on a displayed peer frame.
func (worker *brailleWorker) remote_gesture_locked(event braille.RoutingEvent) (func(string, int), string, int, bool) {
frame := worker.frame
if !frame.Raw || worker.remoteGesture == nil || frame.ID == 0 || event.ID != frame.ID {
return nil, "", 0, false
}
switch {
case event.Pan == "left":
return worker.remoteGesture, "pan-left", 0, true
case event.Pan == "right":
return worker.remoteGesture, "pan-right", 0, true
case event.Pan == "" && event.Offset >= 0 && event.Offset < utf8.RuneCountInString(frame.Text):
return worker.remoteGesture, "route", event.Offset, true
}
return nil, "", 0, false
}
+9
View File
@@ -15,6 +15,15 @@ type brailleRouter interface {
func (worker *brailleWorker) route(ctx context.Context, event braille.RoutingEvent) {
worker.mu.Lock()
if callback, gesture, index, ok := worker.remote_gesture_locked(event); ok {
worker.mu.Unlock()
callback(gesture, index) // Must not block.
return
}
if event.Pan != "" {
worker.mu.Unlock()
return
}
frame := worker.frame
length := utf8.RuneCountInString(frame.Text)
if worker.secure || worker.stopping || frame.ID == 0 || event.ID != frame.ID || event.Offset < 0 ||
+12 -1
View File
@@ -125,10 +125,21 @@ func (worker *brailleWorker) session(ctx context.Context) (connectedFor time.Dur
defer ioWorkers.Done()
scanner := bufio.NewScanner(stdout)
scanner.Buffer(make([]byte, 1024), 1024)
if !scanner.Scan() || scanner.Text() != "ready" {
// "ready N" reports N display cells; a bare "ready" is an older helper.
cells, valid := 0, scanner.Scan()
if valid && scanner.Text() != "ready" {
var parsed int
_, err := fmt.Sscanf(scanner.Text(), "ready %d", &parsed)
valid = err == nil && parsed > 0 && parsed <= 4096 && scanner.Text() == fmt.Sprintf("ready %d", parsed)
cells = parsed
}
if !valid {
ready <- errors.New("braille helper sent an invalid ready message")
return
}
worker.mu.Lock()
worker.cells = cells
worker.mu.Unlock()
ready <- nil
for scanner.Scan() {
var event braille.RoutingEvent
+8
View File
@@ -45,6 +45,14 @@ type brailleWorker struct {
wake chan struct{}
cancel context.CancelFunc
done chan struct{}
// Remote Braille. cells is the connected display width (0 unknown).
// remoteExport receives accepted application frames; remoteFrame shows a
// peer's cells in place of them; remoteGesture receives display keys
// pressed while remoteFrame is shown. All are guarded by mu.
cells int
remoteExport func(braille.Frame)
remoteFrame *braille.Frame
remoteGesture func(gesture string, index int)
}
func newBrailleWorker(logger *slog.Logger) (*brailleWorker, error) {
+253
View File
@@ -0,0 +1,253 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package app
import (
"bufio"
"context"
"encoding/json"
"errors"
"io"
"os/exec"
"slices"
"time"
"git.stormux.org/storm/cthulhu/internal/braille"
"git.stormux.org/storm/cthulhu/internal/tentacle"
"git.stormux.org/storm/cthulhu/internal/tentaclewire"
)
// virtualBraille is one virtual-only Braille helper: frames, width, pan and
// route in; final cells and routing events out. Responses closes on exit.
type virtualBraille interface {
Send(braille.VirtualRequest) error
Responses() <-chan braille.VirtualResponse
Close()
}
type processVirtualBraille struct {
cmd *exec.Cmd
stdin io.WriteCloser
encoder *json.Encoder
responses chan braille.VirtualResponse
done chan struct{}
}
// start_virtual_braille runs `cthulhu-braille --virtual`. It opens no display;
// translation stays in the disposable helper, as for the physical display.
func start_virtual_braille(path string) (virtualBraille, error) {
cmd := exec.Command(path, "--virtual")
stdin, err := cmd.StdinPipe()
if err != nil {
return nil, err
}
stdout, err := cmd.StdoutPipe()
if err != nil {
return nil, err
}
if err := cmd.Start(); err != nil {
return nil, err
}
helper := &processVirtualBraille{cmd: cmd, stdin: stdin, encoder: json.NewEncoder(stdin),
responses: make(chan braille.VirtualResponse, 16), done: make(chan struct{})}
go func() {
defer close(helper.responses)
scanner := bufio.NewScanner(stdout)
scanner.Buffer(make([]byte, 4096), 65536)
for scanner.Scan() {
var response braille.VirtualResponse
if json.Unmarshal(scanner.Bytes(), &response) != nil {
return
}
select {
case helper.responses <- response:
case <-helper.done:
return
}
}
}()
return helper, nil
}
func (helper *processVirtualBraille) Send(request braille.VirtualRequest) error {
return helper.encoder.Encode(request)
}
func (helper *processVirtualBraille) Responses() <-chan braille.VirtualResponse {
return helper.responses
}
func (helper *processVirtualBraille) Close() {
close(helper.done)
_ = helper.stdin.Close()
exited := make(chan struct{})
go func() { _ = helper.cmd.Wait(); close(exited) }()
select {
case <-exited:
case <-time.After(time.Second):
_ = helper.cmd.Process.Kill()
<-exited
}
}
// start_remote_braille attaches the Remote Braille owner for a session
// installed with braille-stream. As the controlled computer it exports the
// application Braille frame, rendered by a virtual helper at the controller's
// width, and applies the controller's pan and route gestures. As the
// controller it shows the peer's cells on the local display and reports local
// pan and route keys. Reader UI, flashed messages and secure frames are never
// exported; lock, handoff and revocation end both directions.
func (runtime *tentacleRuntime) start_remote_braille(ctx context.Context, session *tentacle.Session) <-chan struct{} {
done := make(chan struct{})
owner := runtime.output
if owner == nil || owner.braille == nil || owner.gate == nil || runtime.remotePolicy == nil ||
!slices.Contains(session.RemoteGrants(), "braille-stream") {
close(done)
return done
}
worker := owner.braille
launch := runtime.virtualBraille
if launch == nil {
launch = func() (virtualBraille, error) { return start_virtual_braille(worker.path) }
}
requests := session.EnableRemoteBraille()
allowed := func() bool {
owner.gate.mutex.Lock()
defer owner.gate.mutex.Unlock()
return !owner.gate.Locked() && !owner.gate.Suspended()
}
current := func(delivery tentacle.RemoteBrailleDelivery) bool {
if delivery.Context == nil || delivery.Context.Err() != nil || !allowed() {
return false
}
_, err := session.RemoteContext(delivery.Lease, "braille-stream")
return err == nil
}
go func() {
defer close(done)
ctx, cancel := context.WithCancel(ctx)
defer cancel()
stop := context.AfterFunc(session.OutputContext(), cancel)
defer stop()
var helper virtualBraille
var responses <-chan braille.VirtualResponse
var exportLease tentaclewire.RemoteLease
var exportDone, displayDone <-chan struct{}
frames := make(chan braille.Frame, 1)
offer := func(frame braille.Frame) { // Called under the worker lock.
select {
case frames <- frame:
default:
select {
case <-frames:
default:
}
frames <- frame
}
}
stopExport := func() {
if helper == nil {
return
}
worker.SetRemoteExport(nil)
helper.Close()
helper, responses, exportDone = nil, nil, nil
exportLease = tentaclewire.RemoteLease{}
select {
case <-frames:
default:
}
}
stopDisplay := func() {
if displayDone == nil {
return
}
worker.SetRemoteCells(nil, nil)
displayDone = nil
}
defer stopExport()
defer stopDisplay()
for {
select {
case <-ctx.Done():
return
case <-exportDone:
stopExport()
case <-displayDone:
stopDisplay()
case delivery := <-requests:
if !current(delivery) {
continue
}
value := delivery.Braille
switch {
case delivery.Role == "controlled" && value.Op == "size":
if value.Width == 0 || helper != nil && exportLease != delivery.Lease {
stopExport()
}
if value.Width == 0 {
continue
}
if helper == nil {
started, err := launch()
if err != nil {
runtime.report("Remote Braille is unavailable.")
continue
}
helper, responses, exportLease, exportDone = started, started.Responses(), delivery.Lease, delivery.Context.Done()
if frame, ok := worker.SetRemoteExport(offer); ok {
offer(frame)
}
}
width := value.Width
if helper.Send(braille.VirtualRequest{Width: &width}) != nil {
stopExport()
}
case delivery.Role == "controlled" && value.Op == "gesture":
if helper == nil || exportLease != delivery.Lease {
continue
}
request := braille.VirtualRequest{Pan: "left"}
switch value.Gesture {
case "pan-right":
request.Pan = "right"
case "route":
index := value.Index
request = braille.VirtualRequest{Route: &index}
}
if helper.Send(request) != nil {
stopExport()
}
case delivery.Role == "controller" && value.Op == "geometry":
_ = session.PublishRemoteBraille(delivery.Lease, tentaclewire.RemoteBraille{Op: "geometry", Width: worker.RemoteDisplayWidth()})
case delivery.Role == "controller" && value.Op == "cells":
lease := delivery.Lease
displayDone = delivery.Context.Done()
worker.SetRemoteCells(value.Cells, func(gesture string, index int) {
_ = session.PublishRemoteBraille(lease, tentaclewire.RemoteBraille{Op: "gesture", Gesture: gesture, Index: index})
})
}
case frame := <-frames:
if helper != nil && helper.Send(braille.VirtualRequest{Frame: &frame}) != nil {
stopExport()
}
case response, open := <-responses:
if !open {
stopExport()
runtime.report("Remote Braille stopped. Connect again to retry.")
continue
}
if response.Route != nil {
worker.RouteExported(ctx, *response.Route)
} else if len(response.Cells) != 0 && allowed() {
if errors.Is(session.PublishRemoteBraille(exportLease, tentaclewire.RemoteBraille{Op: "cells", Cells: response.Cells}), tentacle.ErrLimit) {
stopExport()
}
}
}
}
}()
return done
}
@@ -0,0 +1,202 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package app
import (
"context"
"encoding/json"
"log/slog"
"reflect"
"sync"
"testing"
"time"
"git.stormux.org/storm/cthulhu/internal/atspi"
"git.stormux.org/storm/cthulhu/internal/braille"
"git.stormux.org/storm/cthulhu/internal/tentacle"
"git.stormux.org/storm/cthulhu/internal/tentaclewire"
)
type fakeVirtualBraille struct {
mu sync.Mutex
requests []braille.VirtualRequest
responses chan braille.VirtualResponse
closed chan struct{}
once sync.Once
}
func (fake *fakeVirtualBraille) Send(request braille.VirtualRequest) error {
fake.mu.Lock()
defer fake.mu.Unlock()
fake.requests = append(fake.requests, request)
return nil
}
func (fake *fakeVirtualBraille) Responses() <-chan braille.VirtualResponse { return fake.responses }
func (fake *fakeVirtualBraille) Close() { fake.once.Do(func() { close(fake.closed) }) }
func (fake *fakeVirtualBraille) seen() []braille.VirtualRequest {
fake.mu.Lock()
defer fake.mu.Unlock()
return append([]braille.VirtualRequest(nil), fake.requests...)
}
type peerRouteRecorder struct{ routes chan atspi.BrailleRoute }
func (router peerRouteRecorder) RouteBraille(_ context.Context, route atspi.BrailleRoute) error {
router.routes <- route
return nil
}
func braille_fixture(t *testing.T, grants []string) (*tentacle.Session, *tentacle.RemotePolicy, *brailleWorker, *fakeVirtualBraille, peerRouteRecorder) {
t.Helper()
policy := tentacle.NewRemotePolicy()
session := keyboard_owner_session(t, policy, grants)
router := peerRouteRecorder{routes: make(chan atspi.BrailleRoute, 4)}
worker := &brailleWorker{logger: slog.New(slog.DiscardHandler), wake: make(chan struct{}, 1), cancel: func() {},
connected: true, cells: 20, router: router, frame: braille.Frame{Cursor: -1}, baseFrame: braille.Frame{Cursor: -1}}
fake := &fakeVirtualBraille{responses: make(chan braille.VirtualResponse, 4), closed: make(chan struct{})}
runtime := &tentacleRuntime{logger: slog.New(slog.DiscardHandler), remotePolicy: policy, announcements: make(chan string, 4),
output: &tentacleOutput{braille: worker, gate: &focusGate{}}, virtualBraille: func() (virtualBraille, error) { return fake, nil }}
ctx, cancel := context.WithCancel(t.Context())
done := runtime.start_remote_braille(ctx, session)
t.Cleanup(func() {
cancel()
select {
case <-done:
case <-time.After(5 * time.Second):
t.Error("Braille owner did not join")
}
})
return session, policy, worker, fake, router
}
func braille_open(t *testing.T, session *tentacle.Session, role string, grants []string) tentaclewire.RemoteLease {
t.Helper()
approval, err := session.BeginRemoteApproval(role, grants)
if err != nil {
t.Fatal(err)
}
if _, err := session.AuthorizeRemoteWithApproval(approval, role, grants); err != nil {
t.Fatal(err)
}
var lease tentaclewire.RemoteLease
if json.Unmarshal(keyboard_owner_invoke(t, session, "open", nil), &lease) != nil || lease.Epoch == 0 {
t.Fatal("lease did not open")
}
return lease
}
func braille_call(t *testing.T, session *tentacle.Session, lease tentaclewire.RemoteLease, sequence uint64, value tentaclewire.RemoteBraille) {
t.Helper()
request := tentaclewire.RemoteBrailleRequest{RemoteOperation: tentaclewire.RemoteOperation{Lease: lease.Lease, Epoch: lease.Epoch, Sequence: sequence}, Braille: value}
keyboard_owner_invoke(t, session, "rpc", map[string]any{"Method": "remote.braille", "Params": request})
}
func wait_until(t *testing.T, what string, predicate func() bool) {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
for !predicate() {
if time.Now().After(deadline) {
t.Fatal("timed out waiting for", what)
}
time.Sleep(2 * time.Millisecond)
}
}
func TestRemoteBrailleExportsApplicationFramesOnly(t *testing.T) {
session, policy, worker, fake, router := braille_fixture(t, []string{"braille-stream"})
route := braille.TextRoute{Start: 0, End: 5, SourceStart: 0, TextLength: 5, BusName: ":1.2", Path: "/text", SourceText: "hello"}
worker.EnqueueFrame(braille.Frame{Text: "hello", Cursor: -1, Routes: []braille.TextRoute{route}})
lease := braille_open(t, session, "controlled", []string{"braille-stream"})
braille_call(t, session, lease, 1, tentaclewire.RemoteBraille{Op: "size", Width: 20})
wait_until(t, "width and initial frame", func() bool { return len(fake.seen()) >= 2 })
seen := fake.seen()
if seen[0].Width == nil || *seen[0].Width != 20 || seen[1].Frame == nil || seen[1].Frame.Text != "hello" || seen[1].Frame.ID == 0 || seen[1].Frame.Routes != nil {
t.Fatalf("initial export = %+v", seen)
}
exportedID := seen[1].Frame.ID
fake.responses <- braille.VirtualResponse{Cells: []int{1, 3, 9}}
var event tentaclewire.SessionEvent
if json.Unmarshal(keyboard_owner_invoke(t, session, "event", nil), &event) != nil || event.Kind != "remote.braille" || !reflect.DeepEqual(event.Braille.Cells, []int{1, 3, 9}) {
t.Fatal("cells not published", event)
}
braille_call(t, session, lease, 2, tentaclewire.RemoteBraille{Op: "gesture", Gesture: "route", Index: 3})
braille_call(t, session, lease, 3, tentaclewire.RemoteBraille{Op: "gesture", Gesture: "pan-right"})
wait_until(t, "gestures", func() bool { return len(fake.seen()) >= 4 })
if seen = fake.seen(); seen[2].Route == nil || *seen[2].Route != 3 || seen[3].Pan != "right" {
t.Fatalf("gestures = %+v", seen[2:])
}
fake.responses <- braille.VirtualResponse{Route: &braille.RoutingEvent{ID: exportedID, Offset: 3}}
select {
case routed := <-router.routes:
if routed.Offset != 3 || routed.Line != "hello" {
t.Fatal("route changed", routed)
}
case <-time.After(5 * time.Second):
t.Fatal("peer route did not reach the application")
}
// Reader UI is never exported; application frames under it are, unroutable.
worker.SetInternalUIFrame(&braille.Frame{Text: "private form", Cursor: -1})
worker.EnqueueFrame(braille.Frame{Text: "world", Cursor: -1})
wait_until(t, "frame under reader UI", func() bool { return len(fake.seen()) >= 5 })
for _, request := range fake.seen() {
if request.Frame != nil && request.Frame.Text == "private form" {
t.Fatal("reader UI frame exported")
}
}
if last := fake.seen()[4]; last.Frame == nil || last.Frame.Text != "world" || last.Frame.ID != 0 {
t.Fatalf("frame under UI = %+v", last)
}
policy.SetAvailable(false)
select {
case <-fake.closed:
case <-time.After(5 * time.Second):
t.Fatal("revocation did not stop export")
}
policy.SetAvailable(true)
worker.mu.Lock()
exporter := worker.remoteExport
worker.mu.Unlock()
if exporter != nil {
t.Fatal("exporter left installed")
}
}
func TestRemoteBrailleControllerShowsPeerCellsAndReportsKeys(t *testing.T) {
session, policy, worker, _, _ := braille_fixture(t, []string{"braille-stream", "remote-present"})
worker.EnqueueFrame(braille.Frame{Text: "local", Cursor: -1})
worker.SetContraction(true, "en-ueb-g2.ctb")
lease := braille_open(t, session, "controller", []string{"braille-stream"})
braille_call(t, session, lease, 1, tentaclewire.RemoteBraille{Op: "geometry"})
var event tentaclewire.SessionEvent
if json.Unmarshal(keyboard_owner_invoke(t, session, "event", nil), &event) != nil || event.Braille == nil || event.Braille.Op != "geometry" || event.Braille.Width != 20 {
t.Fatal("geometry not reported", event)
}
braille_call(t, session, lease, 2, tentaclewire.RemoteBraille{Op: "cells", Cells: []int{1, 2, 255}})
wait_until(t, "peer cells", func() bool { return worker.current().Raw })
frame := worker.current()
if frame.Text != "⠁⠂⣿" || frame.Contract || frame.Table != "" || frame.WordWrap {
t.Fatalf("peer frame = %+v", frame)
}
// Local application frames continue updating underneath.
worker.EnqueueFrame(braille.Frame{Text: "newer", Cursor: -1})
if !worker.current().Raw {
t.Fatal("application frame replaced peer cells")
}
frame = worker.current()
worker.route(t.Context(), braille.RoutingEvent{ID: frame.ID, Offset: 1})
worker.route(t.Context(), braille.RoutingEvent{ID: frame.ID, Offset: -1, Pan: "right"})
worker.route(t.Context(), braille.RoutingEvent{ID: frame.ID + 99, Offset: 0}) // Stale.
for _, want := range []tentaclewire.RemoteBraille{{Op: "gesture", Gesture: "route", Index: 1}, {Op: "gesture", Gesture: "pan-right"}} {
if json.Unmarshal(keyboard_owner_invoke(t, session, "event", nil), &event) != nil || event.Braille == nil || !reflect.DeepEqual(*event.Braille, want) {
t.Fatal("gesture changed", event)
}
}
policy.SetAvailable(false)
wait_until(t, "local frame restored", func() bool { return !worker.current().Raw })
if text := worker.current().Text; text != "newer" {
t.Fatal("restored frame", text)
}
policy.SetAvailable(true)
}
+2 -1
View File
@@ -138,9 +138,10 @@ func (owner *tentacleLifecycle) startPending() {
speechDone := owner.runtime.start_remote_speech(owner.ctx, session)
keysDone := owner.runtime.start_remote_keyboard(owner.ctx, session)
captureDone := owner.runtime.start_remote_capture(owner.ctx, session)
brailleDone := owner.runtime.start_remote_braille(owner.ctx, session)
go func() {
defer close(done)
defer func() { <-speechDone; <-keysDone; <-captureDone }()
defer func() { <-speechDone; <-keysDone; <-captureDone; <-brailleDone }()
select {
case <-session.Revoked():
case <-owner.ctx.Done():
+3 -1
View File
@@ -39,6 +39,7 @@ type tentacleRuntime struct {
remoteInput *remoteinput.Supervisor // Retained for the reader lifetime.
remoteInputHelper string // Private fixture seam; production uses the reader's directory.
remoteInputChecked bool
virtualBraille func() (virtualBraille, error) // Private fixture seam.
readerSettings *preferencesWorker
extensionChanges chan struct{}
forms tentacleForms
@@ -191,10 +192,11 @@ func (runtime *tentacleRuntime) run(ctx context.Context) error {
speechDone := runtime.start_remote_speech(ctx, session)
keysDone := runtime.start_remote_keyboard(ctx, session)
captureDone := runtime.start_remote_capture(ctx, session)
brailleDone := runtime.start_remote_braille(ctx, session)
watchers.Add(1)
go func() {
defer watchers.Done()
defer func() { <-speechDone; <-keysDone; <-captureDone }()
defer func() { <-speechDone; <-keysDone; <-captureDone; <-brailleDone }()
select {
case <-session.Revoked():
case <-ctx.Done():
+6
View File
@@ -36,6 +36,9 @@ type Frame struct {
WordWrap bool `json:"wordWrap,omitempty"`
Dots []byte `json:"dots,omitempty"`
// Routing metadata stays in the parent, never in the helper protocol.
// Raw marks final cells from a Remote peer as Unicode Braille runes; the
// parent sends them without contraction or word wrap.
Raw bool `json:"-"`
Routes []TextRoute `json:"-"`
Virtual *VirtualRoute `json:"-"`
Document *DocumentRoute `json:"-"`
@@ -62,9 +65,12 @@ type TextRoute struct {
BusName, Path, SourceText string
}
// RoutingEvent reports a routing key, or, with Pan set to "left" or "right"
// and Offset -1, a pan key that could not move the local viewport.
type RoutingEvent struct {
ID uint64 `json:"id"`
Offset int `json:"offset"`
Pan string `json:"pan,omitempty"`
}
const (
+10 -1
View File
@@ -67,7 +67,7 @@ func RunClient(ctx context.Context, backend Backend, input io.ReadCloser, ready
if err != nil {
return err
}
if _, err := io.WriteString(ready, "ready\n"); err != nil {
if _, err := fmt.Fprintf(ready, "ready %d\n", cells); err != nil {
return err
}
frames := make(chan Frame, 1)
@@ -123,6 +123,15 @@ func RunClient(ctx context.Context, backend Backend, input io.ReadCloser, ready
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 {
+3 -3
View File
@@ -32,7 +32,7 @@ func TestRunClientShowsLiveFrameAndReleasesDisplay(t *testing.T) {
if err := RunClient(ctx, backend, io.NopCloser(input), &ready); err != nil {
t.Fatal(err)
}
if ready.String() != "ready\n" || len(backend.writes) != 1 ||
if ready.String() != "ready 20\n" || len(backend.writes) != 1 ||
backend.writes[0] != "Save, push button " || backend.opens != 1 || backend.closes != 1 {
t.Fatalf("client lifecycle: ready=%q backend=%+v", ready.String(), backend)
}
@@ -74,7 +74,7 @@ func TestRunClientRetriesOpenUntilDisplayReady(t *testing.T) {
if err := RunClient(ctx, backend, io.NopCloser(input), &ready); err != nil {
t.Fatal(err)
}
if ready.String() != "ready\n" || backend.opens != 3 || backend.closes != 1 || len(backend.writes) != 1 {
if ready.String() != "ready 20\n" || backend.opens != 3 || backend.closes != 1 || len(backend.writes) != 1 {
t.Fatalf("ready=%q backend=%+v", ready.String(), backend)
}
}
@@ -178,7 +178,7 @@ func TestRunClientRoutesDisplayedFrameAfterPanningAndJoinsReader(t *testing.T) {
if err := <-done; err != nil {
t.Fatal(err)
}
if output.String() != "ready\n{\"id\":42,\"offset\":5}\n" {
if output.String() != "ready 4\n{\"id\":42,\"offset\":5}\n" {
t.Fatalf("helper output %q", output.String())
}
if backend.closes != 1 {
+119
View File
@@ -0,0 +1,119 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package braille
import (
"bufio"
"context"
"encoding/json"
"errors"
"io"
)
// VirtualComputerTable is the explicit table used to turn ordinary
// (uncontracted) frames into cells for a remote display. The physical display
// leaves that translation to BRLTTY; a virtual display has no BRLTTY, so the
// policy is stated here rather than guessed from local configuration.
const VirtualComputerTable = "en-us-comp8.ctb"
// VirtualCursorDots marks the cursor cell with dots 7 and 8. It is a static
// overlay, not BRLTTY's configured cursor shape or blink timing.
const VirtualCursorDots = 0xc0
// VirtualRequest is one line from the parent. Exactly one field is set.
type VirtualRequest struct {
Width *int `json:"width,omitempty"`
Frame *Frame `json:"frame,omitempty"`
Pan string `json:"pan,omitempty"`
Route *int `json:"route,omitempty"`
}
// VirtualResponse is one line to the parent: new cells, or a routing event
// against the frame ID the parent supplied.
type VirtualResponse struct {
Cells []int `json:"cells,omitempty"`
Route *RoutingEvent `json:"route,omitempty"`
}
// RunVirtual renders frames into a virtual viewport without opening BrlAPI.
// It shares the physical helper's viewport, liblouis translation, wrapping,
// panning and routing maps, so remote cells match local behaviour apart from
// the explicit uncontracted table and static cursor. It exits on EOF.
func RunVirtual(ctx context.Context, input io.Reader, output io.Writer) error {
scanner := bufio.NewScanner(input)
scanner.Buffer(make([]byte, 4096), 65536)
encoder := json.NewEncoder(output)
v := viewport{}
var current *Frame
emit := func() error {
if v.width == 0 || current == nil {
return nil
}
cells, err := v.rawCells(VirtualCursorDots)
if err != nil {
return err
}
values := make([]int, len(cells))
for index, cell := range cells {
values[index] = int(cell)
}
return encoder.Encode(VirtualResponse{Cells: values})
}
render := func() error {
if v.width == 0 || current == nil {
return nil
}
frame := *current
if !frame.Contract {
frame.Contract, frame.Table = true, VirtualComputerTable
}
if err := v.setFrame(frame); err != nil {
return err
}
return emit()
}
for scanner.Scan() {
if ctx.Err() != nil {
return nil
}
var request VirtualRequest
if err := json.Unmarshal(scanner.Bytes(), &request); err != nil {
return errors.New("invalid virtual braille request")
}
var err error
switch {
case request.Width != nil:
if *request.Width < 0 || *request.Width > 4096 {
return errors.New("invalid virtual braille width")
}
v.width = *request.Width
err = render()
case request.Frame != nil:
frame := *request.Frame
current = &frame
err = render()
case request.Pan == "left" || request.Pan == "right":
key := PanLeft
if request.Pan == "right" {
key = PanRight
}
if current != nil && v.width > 0 && v.pan(key) {
err = emit()
}
case request.Route != nil:
if current != nil && v.width > 0 {
if event, ok := v.route(RouteBase + Key(*request.Route)); ok {
err = encoder.Encode(VirtualResponse{Route: &event})
}
}
default:
return errors.New("invalid virtual braille request")
}
if err != nil {
return err
}
}
return scanner.Err()
}
+96
View File
@@ -0,0 +1,96 @@
//go:build brlapi && cgo
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package braille
import (
"bytes"
"context"
"encoding/json"
"strings"
"testing"
)
func runVirtualLines(t *testing.T, lines ...string) []VirtualResponse {
t.Helper()
var output bytes.Buffer
if err := RunVirtual(context.Background(), strings.NewReader(strings.Join(lines, "\n")+"\n"), &output); err != nil {
t.Fatal(err)
}
var responses []VirtualResponse
decoder := json.NewDecoder(&output)
for decoder.More() {
var response VirtualResponse
if err := decoder.Decode(&response); err != nil {
t.Fatal(err)
}
responses = append(responses, response)
}
return responses
}
func TestVirtualHelperRendersPansAndRoutes(t *testing.T) {
responses := runVirtualLines(t,
`{"frame":{"text":"abcdef","cursor":1,"id":9}}`, // No width yet: nothing rendered.
`{"width":4}`,
`{"pan":"right"}`,
`{"route":1}`,
`{"pan":"left"}`,
`{"width":0}`,
`{"frame":{"text":"x","cursor":-1,"id":10}}`,
)
if len(responses) != 4 {
t.Fatalf("responses = %+v", responses)
}
first := responses[0].Cells
// Computer Braille a=dot1, b=dots12; the cursor on b adds dots 7 and 8.
if len(first) != 4 || first[0] != 0x01 || first[1] != 0x03|VirtualCursorDots {
t.Fatalf("first page = %#v", first)
}
if second := responses[1].Cells; len(second) != 4 || second[0] == first[0] {
t.Fatalf("pan right did not change the page: %#v", second)
}
if route := responses[2].Route; route == nil || route.ID != 9 || route.Offset != 5 {
t.Fatalf("route = %+v", route)
}
if !equalCells(responses[3].Cells, first) {
t.Fatalf("pan left did not restore the first page: %#v", responses[3].Cells)
}
}
func equalCells(a, b []int) bool {
if len(a) != len(b) {
return false
}
for index := range a {
if a[index] != b[index] {
return false
}
}
return true
}
func TestVirtualHelperUsesContractedFrameTable(t *testing.T) {
responses := runVirtualLines(t, `{"width":8}`,
`{"frame":{"text":"the cat","cursor":-1,"id":1,"contract":true,"table":"en-ueb-g2.ctb"}}`)
if len(responses) != 1 || len(responses[0].Cells) != 8 {
t.Fatalf("responses = %+v", responses)
}
// Contraction makes "the" a single cell, so the line fits in fewer cells
// than its seven characters: the last cell is blank padding.
if responses[0].Cells[7] != 0 {
t.Fatalf("contracted output was not shorter: %#v", responses[0].Cells)
}
}
func TestVirtualHelperRejectsInvalidRequests(t *testing.T) {
for _, line := range []string{`{"width":5000}`, `{}`, `not json`, `{"pan":"up"}`} {
var output bytes.Buffer
if RunVirtual(context.Background(), strings.NewReader(line+"\n"), &output) == nil {
t.Fatal("invalid request accepted", line)
}
}
}