261 lines
7.6 KiB
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
|
|
}
|