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:
1 parent
2651b5e9bd
commit
551ef86d5f
16 files changed
+925
-13
No files matched your search
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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():
|
||||
|
||||
@@ -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():
|
||||
|
||||
Reference in new issue
Block a user