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) + } + } +}