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

+10 -2
View File
@@ -13,12 +13,20 @@ import (
) )
func main() { func main() {
if len(os.Args) > 2 || len(os.Args) == 2 && os.Args[1] != "--client" { if len(os.Args) > 2 || len(os.Args) == 2 && os.Args[1] != "--client" && os.Args[1] != "--virtual" {
fmt.Fprintln(os.Stderr, "Usage: cthulhu-braille [--client]") fmt.Fprintln(os.Stderr, "Usage: cthulhu-braille [--client|--virtual]")
os.Exit(2) os.Exit(2)
} }
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer cancel() defer cancel()
if len(os.Args) == 2 && os.Args[1] == "--virtual" {
// Remote export: no display is opened; the parent owns the session.
if err := braille.RunVirtual(ctx, os.Stdin, os.Stdout); err != nil {
fmt.Fprintf(os.Stderr, "cthulhu-braille: %v\n", err)
os.Exit(1)
}
return
}
if len(os.Args) == 2 { if len(os.Args) == 2 {
if err := braille.RunClient(ctx, braille.NewRouting(), os.Stdin, os.Stdout); err != nil { if err := braille.RunClient(ctx, braille.NewRouting(), os.Stdin, os.Stdout); err != nil {
fmt.Fprintf(os.Stderr, "cthulhu-braille: %v\n", err) fmt.Fprintf(os.Stderr, "cthulhu-braille: %v\n", err)
+39
View File
@@ -22,3 +22,42 @@ frame only. `Session.EnableRemoteBraille`, `PublishRemoteBraille`, the Go
`valid_remote_braille_request` validators implement the contract. Go/Python `valid_remote_braille_request` validators implement the contract. Go/Python
SDK-process tests cover each direction, role denial, invalid values, SDK-process tests cover each direction, role denial, invalid values,
saturation and revocation. saturation and revocation.
## Reader owners
`start_remote_braille` attaches an owner for sessions installed with
`braille-stream`, from local launch and managed startup.
**Controlled computer.** A `size` request starts `cthulhu-braille --virtual`,
a helper mode that opens no display and reuses the physical helper's
viewport, liblouis translation, wrapping, panning and routing maps. The owner
installs an exporter on the Braille worker that receives only accepted
application frames. Reader UI, flashed messages and secure-input frames are
never exported. A frame stored while reader UI, a protected confirmation or a
peer's cells hide it is exported with ID 0 so it cannot be routed. Rendered
cells become `cells` events. Gestures pan the virtual viewport or route a cell;
a route reaches the application only if it names the frame currently shown
here, so a stale or hidden frame is ignored.
Ordinary frames have no BRLTTY to translate them, so the virtual helper uses an
explicit table, `en-us-comp8.ctb` (computer Braille); contracted frames keep the
user's configured table. The cursor is a static dots-7-8 overlay, not BRLTTY's
configured cursor shape or blink.
**Controller.** The physical helper now reports its width in its ready line
(`ready N`; a bare `ready` is still accepted), answering `geometry`. Peer
`cells` are shown as Unicode Braille runes in a raw frame, sent without
contraction or word wrap so the helper writes the exact dots. They take the
place of the application frame, which keeps updating underneath; reader UI and
flashed messages still take precedence and restore to the peer's cells. The
helper now also reports a pan key that cannot move the local viewport; on a
peer frame that, and a routing key, become `gesture` events. Revocation, lock
or handoff restores the application frame.
Tests: liblouis-backed virtual helper tests (computer Braille, cursor overlay,
pan and pan-back, routing, contracted table, invalid input); a smoke run of the
built helper; and app tests with a fake helper and a real Go SDK child for both
roles: application-only export, unroutable frames under reader UI, routing to
the application, gesture forwarding, peer cells overriding local frames,
stale-route rejection and restoration on revocation. No physical display was
used.
+20 -4
View File
@@ -46,7 +46,7 @@ func (worker *brailleWorker) SetInternalUIFrame(frame *braille.Frame) {
worker.flashContext = nil worker.flashContext = nil
worker.protectedFlash = false worker.protectedFlash = false
worker.internalUIFrame = nil worker.internalUIFrame = nil
next := worker.baseFrame next := worker.shown_base_locked()
if frame != nil { if frame != nil {
next = *frame next = *frame
next.WordWrap = worker.wordWrap next.WordWrap = worker.wordWrap
@@ -107,16 +107,25 @@ func (worker *brailleWorker) enqueueSecureFrameLocked(frame braille.Frame, base
if worker.internalUIFrame != nil { if worker.internalUIFrame != nil {
if base { if base {
worker.baseFrame = frame worker.baseFrame = frame
worker.export_locked(frame, 0) // Not displayed: unroutable.
} }
return false return false
} }
if worker.cancel == nil || worker.stopping { if worker.cancel == nil || worker.stopping {
return false 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 { if base {
worker.flashContext = nil worker.flashContext = nil
if worker.protectedFlash { if worker.protectedFlash {
worker.baseFrame = frame worker.baseFrame = frame
worker.export_locked(frame, 0)
return false return false
} }
worker.flashGeneration++ worker.flashGeneration++
@@ -129,8 +138,15 @@ func (worker *brailleWorker) enqueueSecureFrameLocked(frame braille.Frame, base
frame.Contract = worker.contracted frame.Contract = worker.contracted
frame.Table = worker.table frame.Table = worker.table
frame.ExpandAtCursor = frame.Cursor >= 0 && len(frame.Routes) > 0 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++ worker.sequence++
frame.ID = worker.sequence frame.ID = worker.sequence
if base {
worker.export_locked(frame, frame.ID)
}
frame.Routes = append([]braille.TextRoute(nil), frame.Routes...) frame.Routes = append([]braille.TextRoute(nil), frame.Routes...)
frame.Dots = append([]byte(nil), frame.Dots...) frame.Dots = append([]byte(nil), frame.Dots...)
if worker.routeCancel != nil { if worker.routeCancel != nil {
@@ -202,7 +218,7 @@ func (worker *brailleWorker) flash_message_guarded(text string, options config.B
worker.flashTimer = nil worker.flashTimer = nil
worker.flashContext = nil worker.flashContext = nil
worker.protectedFlash = false worker.protectedFlash = false
queued := worker.enqueueFrameLocked(worker.baseFrame, false) queued := worker.enqueueFrameLocked(worker.shown_base_locked(), false)
worker.mu.Unlock() worker.mu.Unlock()
if queued { if queued {
worker.wakeDisplay() worker.wakeDisplay()
@@ -228,7 +244,7 @@ func (worker *brailleWorker) DismissConfirmation() {
worker.flashTimer.Stop() worker.flashTimer.Stop()
worker.flashTimer = nil worker.flashTimer = nil
} }
queued := worker.enqueueFrameLocked(worker.baseFrame, false) queued := worker.enqueueFrameLocked(worker.shown_base_locked(), false)
worker.mu.Unlock() worker.mu.Unlock()
if queued { if queued {
worker.wakeDisplay() worker.wakeDisplay()
@@ -286,7 +302,7 @@ func (worker *brailleWorker) retirePresentationLocked(owner context.Context) boo
worker.flashTimer.Stop() worker.flashTimer.Stop()
worker.flashTimer = nil worker.flashTimer = nil
} }
return worker.enqueueFrameLocked(worker.baseFrame, false) return worker.enqueueFrameLocked(worker.shown_base_locked(), false)
} }
func (worker *brailleWorker) retirePresentation(owner context.Context) { 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) { func (worker *brailleWorker) route(ctx context.Context, event braille.RoutingEvent) {
worker.mu.Lock() 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 frame := worker.frame
length := utf8.RuneCountInString(frame.Text) length := utf8.RuneCountInString(frame.Text)
if worker.secure || worker.stopping || frame.ID == 0 || event.ID != frame.ID || event.Offset < 0 || 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() defer ioWorkers.Done()
scanner := bufio.NewScanner(stdout) scanner := bufio.NewScanner(stdout)
scanner.Buffer(make([]byte, 1024), 1024) 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") ready <- errors.New("braille helper sent an invalid ready message")
return return
} }
worker.mu.Lock()
worker.cells = cells
worker.mu.Unlock()
ready <- nil ready <- nil
for scanner.Scan() { for scanner.Scan() {
var event braille.RoutingEvent var event braille.RoutingEvent
+8
View File
@@ -45,6 +45,14 @@ type brailleWorker struct {
wake chan struct{} wake chan struct{}
cancel context.CancelFunc cancel context.CancelFunc
done chan struct{} 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) { 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) speechDone := owner.runtime.start_remote_speech(owner.ctx, session)
keysDone := owner.runtime.start_remote_keyboard(owner.ctx, session) keysDone := owner.runtime.start_remote_keyboard(owner.ctx, session)
captureDone := owner.runtime.start_remote_capture(owner.ctx, session) captureDone := owner.runtime.start_remote_capture(owner.ctx, session)
brailleDone := owner.runtime.start_remote_braille(owner.ctx, session)
go func() { go func() {
defer close(done) defer close(done)
defer func() { <-speechDone; <-keysDone; <-captureDone }() defer func() { <-speechDone; <-keysDone; <-captureDone; <-brailleDone }()
select { select {
case <-session.Revoked(): case <-session.Revoked():
case <-owner.ctx.Done(): case <-owner.ctx.Done():
+3 -1
View File
@@ -39,6 +39,7 @@ type tentacleRuntime struct {
remoteInput *remoteinput.Supervisor // Retained for the reader lifetime. remoteInput *remoteinput.Supervisor // Retained for the reader lifetime.
remoteInputHelper string // Private fixture seam; production uses the reader's directory. remoteInputHelper string // Private fixture seam; production uses the reader's directory.
remoteInputChecked bool remoteInputChecked bool
virtualBraille func() (virtualBraille, error) // Private fixture seam.
readerSettings *preferencesWorker readerSettings *preferencesWorker
extensionChanges chan struct{} extensionChanges chan struct{}
forms tentacleForms forms tentacleForms
@@ -191,10 +192,11 @@ func (runtime *tentacleRuntime) run(ctx context.Context) error {
speechDone := runtime.start_remote_speech(ctx, session) speechDone := runtime.start_remote_speech(ctx, session)
keysDone := runtime.start_remote_keyboard(ctx, session) keysDone := runtime.start_remote_keyboard(ctx, session)
captureDone := runtime.start_remote_capture(ctx, session) captureDone := runtime.start_remote_capture(ctx, session)
brailleDone := runtime.start_remote_braille(ctx, session)
watchers.Add(1) watchers.Add(1)
go func() { go func() {
defer watchers.Done() defer watchers.Done()
defer func() { <-speechDone; <-keysDone; <-captureDone }() defer func() { <-speechDone; <-keysDone; <-captureDone; <-brailleDone }()
select { select {
case <-session.Revoked(): case <-session.Revoked():
case <-ctx.Done(): case <-ctx.Done():
+6
View File
@@ -36,6 +36,9 @@ type Frame struct {
WordWrap bool `json:"wordWrap,omitempty"` WordWrap bool `json:"wordWrap,omitempty"`
Dots []byte `json:"dots,omitempty"` Dots []byte `json:"dots,omitempty"`
// Routing metadata stays in the parent, never in the helper protocol. // 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:"-"` Routes []TextRoute `json:"-"`
Virtual *VirtualRoute `json:"-"` Virtual *VirtualRoute `json:"-"`
Document *DocumentRoute `json:"-"` Document *DocumentRoute `json:"-"`
@@ -62,9 +65,12 @@ type TextRoute struct {
BusName, Path, SourceText string 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 { type RoutingEvent struct {
ID uint64 `json:"id"` ID uint64 `json:"id"`
Offset int `json:"offset"` Offset int `json:"offset"`
Pan string `json:"pan,omitempty"`
} }
const ( const (
+10 -1
View File
@@ -67,7 +67,7 @@ func RunClient(ctx context.Context, backend Backend, input io.ReadCloser, ready
if err != nil { if err != nil {
return err return err
} }
if _, err := io.WriteString(ready, "ready\n"); err != nil { if _, err := fmt.Fprintf(ready, "ready %d\n", cells); err != nil {
return err return err
} }
frames := make(chan Frame, 1) 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 { if err := writeViewport(backend, &v); err != nil {
return err 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 event, ok := v.route(key); ok {
if err := encoder.Encode(event); err != nil { 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 { if err := RunClient(ctx, backend, io.NopCloser(input), &ready); err != nil {
t.Fatal(err) 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 { backend.writes[0] != "Save, push button " || backend.opens != 1 || backend.closes != 1 {
t.Fatalf("client lifecycle: ready=%q backend=%+v", ready.String(), backend) 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 { if err := RunClient(ctx, backend, io.NopCloser(input), &ready); err != nil {
t.Fatal(err) 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) t.Fatalf("ready=%q backend=%+v", ready.String(), backend)
} }
} }
@@ -178,7 +178,7 @@ func TestRunClientRoutesDisplayedFrameAfterPanningAndJoinsReader(t *testing.T) {
if err := <-done; err != nil { if err := <-done; err != nil {
t.Fatal(err) 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()) t.Fatalf("helper output %q", output.String())
} }
if backend.closes != 1 { 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)
}
}
}