From 551ef86d5fc2c97828b0907442fe8fbf845b303f Mon Sep 17 00:00:00 2001 From: Chase Cichorz Date: Fri, 6 Nov 2026 14:34:07 -0600 Subject: [PATCH] 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 --- cmd/cthulhu-braille/main.go | 12 +- docs/tentacle-remote-braille.md | 39 +++ internal/app/braille_frames.go | 24 +- internal/app/braille_remote.go | 133 ++++++++++ internal/app/braille_routing.go | 9 + internal/app/braille_session.go | 13 +- internal/app/braille_worker.go | 8 + internal/app/tentacle_remote_braille.go | 253 +++++++++++++++++++ internal/app/tentacle_remote_braille_test.go | 202 +++++++++++++++ internal/app/tentacle_startup.go | 3 +- internal/app/tentacles.go | 4 +- internal/braille/braille.go | 6 + internal/braille/client.go | 11 +- internal/braille/client_test.go | 6 +- internal/braille/virtual.go | 119 +++++++++ internal/braille/virtual_native_test.go | 96 +++++++ 16 files changed, 925 insertions(+), 13 deletions(-) create mode 100644 internal/app/braille_remote.go create mode 100644 internal/app/tentacle_remote_braille.go create mode 100644 internal/app/tentacle_remote_braille_test.go create mode 100644 internal/braille/virtual.go create mode 100644 internal/braille/virtual_native_test.go diff --git a/cmd/cthulhu-braille/main.go b/cmd/cthulhu-braille/main.go index 52fefa9..74dbbb4 100644 --- a/cmd/cthulhu-braille/main.go +++ b/cmd/cthulhu-braille/main.go @@ -13,12 +13,20 @@ import ( ) func main() { - if len(os.Args) > 2 || len(os.Args) == 2 && os.Args[1] != "--client" { - fmt.Fprintln(os.Stderr, "Usage: cthulhu-braille [--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|--virtual]") os.Exit(2) } ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) 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 err := braille.RunClient(ctx, braille.NewRouting(), os.Stdin, os.Stdout); err != nil { fmt.Fprintf(os.Stderr, "cthulhu-braille: %v\n", err) diff --git a/docs/tentacle-remote-braille.md b/docs/tentacle-remote-braille.md index d2bd462..48c2c3c 100644 --- a/docs/tentacle-remote-braille.md +++ b/docs/tentacle-remote-braille.md @@ -22,3 +22,42 @@ frame only. `Session.EnableRemoteBraille`, `PublishRemoteBraille`, the Go `valid_remote_braille_request` validators implement the contract. Go/Python SDK-process tests cover each direction, role denial, invalid values, 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. diff --git a/internal/app/braille_frames.go b/internal/app/braille_frames.go index 019f6dc..0078fbc 100644 --- a/internal/app/braille_frames.go +++ b/internal/app/braille_frames.go @@ -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) { diff --git a/internal/app/braille_remote.go b/internal/app/braille_remote.go new file mode 100644 index 0000000..59ca745 --- /dev/null +++ b/internal/app/braille_remote.go @@ -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 +} diff --git a/internal/app/braille_routing.go b/internal/app/braille_routing.go index 1e9fc64..8f8e5e7 100644 --- a/internal/app/braille_routing.go +++ b/internal/app/braille_routing.go @@ -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 || diff --git a/internal/app/braille_session.go b/internal/app/braille_session.go index 8536ec7..5c55aef 100644 --- a/internal/app/braille_session.go +++ b/internal/app/braille_session.go @@ -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 diff --git a/internal/app/braille_worker.go b/internal/app/braille_worker.go index ad0762a..c90346a 100644 --- a/internal/app/braille_worker.go +++ b/internal/app/braille_worker.go @@ -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) { diff --git a/internal/app/tentacle_remote_braille.go b/internal/app/tentacle_remote_braille.go new file mode 100644 index 0000000..3c28185 --- /dev/null +++ b/internal/app/tentacle_remote_braille.go @@ -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 +} diff --git a/internal/app/tentacle_remote_braille_test.go b/internal/app/tentacle_remote_braille_test.go new file mode 100644 index 0000000..27b8696 --- /dev/null +++ b/internal/app/tentacle_remote_braille_test.go @@ -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) +} diff --git a/internal/app/tentacle_startup.go b/internal/app/tentacle_startup.go index 232ea25..35d595b 100644 --- a/internal/app/tentacle_startup.go +++ b/internal/app/tentacle_startup.go @@ -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(): diff --git a/internal/app/tentacles.go b/internal/app/tentacles.go index 3dea249..684d629 100644 --- a/internal/app/tentacles.go +++ b/internal/app/tentacles.go @@ -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(): diff --git a/internal/braille/braille.go b/internal/braille/braille.go index c4eb8d8..6067aae 100644 --- a/internal/braille/braille.go +++ b/internal/braille/braille.go @@ -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 ( diff --git a/internal/braille/client.go b/internal/braille/client.go index 6f66248..775ff16 100644 --- a/internal/braille/client.go +++ b/internal/braille/client.go @@ -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 { diff --git a/internal/braille/client_test.go b/internal/braille/client_test.go index 3d6a045..3a3b901 100644 --- a/internal/braille/client_test.go +++ b/internal/braille/client_test.go @@ -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 { diff --git a/internal/braille/virtual.go b/internal/braille/virtual.go new file mode 100644 index 0000000..ae66ee8 --- /dev/null +++ b/internal/braille/virtual.go @@ -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() +} diff --git a/internal/braille/virtual_native_test.go b/internal/braille/virtual_native_test.go new file mode 100644 index 0000000..9956fda --- /dev/null +++ b/internal/braille/virtual_native_test.go @@ -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) + } + } +}