// 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 }