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():