Files
cthulhu/internal/app/tentacle_remote_braille.go

261 lines
7.6 KiB
Go

// 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"
"os"
"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 *os.File
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 {
_ = stdin.Close()
return nil, err
}
if err := cmd.Start(); err != nil {
return nil, err
}
helper := &processVirtualBraille{cmd: cmd, stdin: stdin.(*os.File), 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 {
// A wedged translator must not prevent lease cleanup or owner shutdown.
if err := helper.stdin.SetWriteDeadline(time.Now().Add(time.Second)); err != nil {
return err
}
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()
worker.SetRemoteExport(offer)
}
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 {
// A buffered helper response can race lease revocation.
leaseCtx, err := session.RemoteContext(exportLease, "braille-stream")
if err == nil && allowed() {
worker.RouteExported(leaseCtx, *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
}