Files
Chase CichorzandClaude Opus 5.5 551ef86d5f 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>
2026-11-06 16:06:38 -05:00

459 lines
14 KiB
Go

package app
import (
"context"
"errors"
"log/slog"
"os"
"strings"
"sync"
"time"
"git.stormux.org/storm/cthulhu/internal/input"
"git.stormux.org/storm/cthulhu/internal/remoteinput"
"git.stormux.org/storm/cthulhu/internal/shortcutui"
"git.stormux.org/storm/cthulhu/internal/tentacle"
"git.stormux.org/storm/cthulhu/internal/tentaclewire"
)
type tentacleInput interface {
SetTentacleBindings(context.Context, []input.BindingOverride) error
TentacleBindings() []input.BindingOverride
}
type tentacleCommand struct {
session *tentacle.Session
entryID string
id, title, description string
presentation bool
interactive bool
reader bool
settings bool
}
// tentacleRuntime owns local sessions for one reader lifetime. Its worker owns
// native publication; mutex protects dispatch snapshots and immediate disable.
// Only explicitly granted presentation commands reach the core output owner.
type tentacleRuntime struct {
remotePolicy *tentacle.RemotePolicy
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
focusedItem func(context.Context) (tentaclewire.Item, error)
interactionSlots chan struct{}
registry *tentacle.Registry
requests chan tentacleManagementCall
snapshot tentacle.Snapshot
guardianProgram []string // Private fixture seam; production uses this executable.
output *tentacleOutput
logger *slog.Logger
input tentacleInput
path, parent string
newManager func() (*tentacle.Manager, error) // private fixture seam
fetchRepository func(context.Context, string) (tentacle.Repository, error) // private fixture seam
preparePackage func(context.Context, string, tentacle.Repository, string) (*tentacle.PreparedPackage, error) // private fixture seam
repositoryBusy bool
mutex sync.Mutex
commands map[input.Command]tentacleCommand
sessions []*tentacle.Session
disabled bool
cancel context.CancelFunc
readerContext context.Context
lifecycleContext context.Context
done chan struct{}
failures chan error
announcements chan string
stopError error
results sync.WaitGroup
resultSlots chan struct{}
}
func newTentacleRuntime(logger *slog.Logger, commands commandListener, path, parent string) *tentacleRuntime {
owner, _ := commands.(tentacleInput)
return &tentacleRuntime{logger: logger, input: owner, path: path, parent: parent,
extensionChanges: make(chan struct{}, 1), commands: make(map[input.Command]tentacleCommand), failures: make(chan error, 1), announcements: make(chan string, 1), interactionSlots: make(chan struct{}, 1), resultSlots: make(chan struct{}, 64)}
}
func (runtime *tentacleRuntime) Start(ctx context.Context) error {
runtime.mutex.Lock()
defer runtime.mutex.Unlock()
runtime.readerContext = ctx
ctx, runtime.cancel = context.WithCancel(ctx)
runtime.lifecycleContext = ctx
runtime.done = make(chan struct{})
go func() {
defer close(runtime.done)
run := runtime.run
if runtime.registry != nil {
run = runtime.run_managed
}
if err := run(ctx); err != nil {
runtime.logger.Warn("tentacle startup failed", "error", err)
runtime.report("Tentacles could not start.")
}
}()
return nil
}
func (runtime *tentacleRuntime) Disable() {
if runtime == nil {
return
}
runtime.mutex.Lock()
defer runtime.mutex.Unlock()
runtime.disabled = true
runtime.output.Stop()
clear(runtime.commands)
runtime.signal_extensions()
for _, session := range runtime.sessions {
session.Disable()
}
if runtime.cancel != nil {
runtime.cancel()
}
}
func (runtime *tentacleRuntime) Stop(ctx context.Context) error {
if runtime == nil {
return nil
}
runtime.Disable()
if runtime.done == nil {
return nil
}
select {
case <-runtime.done:
return runtime.stopError
case <-ctx.Done():
return ctx.Err()
}
}
func (runtime *tentacleRuntime) run(ctx context.Context) error {
if runtime.input == nil {
return errors.New("tentacles require native binding support")
}
specs, err := tentacle.LoadLocalSpecs(runtime.path)
if err != nil || len(specs) == 0 {
return err
}
manager, err := runtime.makeManager()
if err != nil {
return err
}
var watchers sync.WaitGroup
defer func() {
runtime.Disable()
cleanup, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
bindingError := runtime.input.SetTentacleBindings(cleanup, nil)
if runtime.readerContext.Err() != nil {
bindingError = nil
}
runtime.stopError = errors.Join(bindingError, manager.Shutdown(cleanup))
watchers.Wait()
runtime.results.Wait()
if runtime.stopError != nil && runtime.readerContext.Err() == nil {
select {
case runtime.failures <- runtime.stopError:
default:
}
}
}()
wake := make(chan struct{}, 1)
var current *tentacle.Session
var ready <-chan struct{}
var active []*tentacle.Session
index := 0
for {
if ctx.Err() != nil {
return nil
}
if current == nil && index < len(specs) {
spec := specs[index]
index++
session, startError := manager.Start(ctx, spec)
if startError != nil {
runtime.logger.Warn("start local tentacle", "tentacle", spec.ID, "error", startError)
runtime.report("A tentacle could not start.")
continue
}
runtime.mutex.Lock()
runtime.sessions = append(runtime.sessions, session)
if runtime.disabled {
session.Disable()
}
runtime.mutex.Unlock()
current, ready = session, session.Ready()
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; <-brailleDone }()
select {
case <-session.Revoked():
case <-ctx.Done():
}
runtime.output.RetireRevoked()
runtime.signal_extensions()
select {
case wake <- struct{}{}:
default:
}
}()
}
select {
case <-ctx.Done():
return nil
case <-wake:
retained := active[:0]
for _, session := range active {
if state, failure := session.Status(); state == tentacle.Active {
retained = append(retained, session)
} else if state == tentacle.Failed {
runtime.logger.Warn("local tentacle failed", "session", session.Identity(), "error", failure)
runtime.report("A tentacle stopped unexpectedly.")
}
}
active = retained
runtime.publishCommands(active)
if err := runtime.bind(ctx, active); err != nil {
if ctx.Err() != nil {
return nil
}
runtime.failures <- err // Stop the reader if stale grabs cannot be removed.
return err
}
case <-ready:
session := current
current, ready = nil, nil
if state, failure := session.Status(); state != tentacle.Ready {
runtime.logger.Warn("local tentacle handshake failed", "error", failure)
runtime.report("A tentacle could not start.")
continue
}
candidate := append(append([]*tentacle.Session(nil), active...), session)
if err := runtime.bind(ctx, candidate); err != nil {
session.Disable()
runtime.logger.Warn("local tentacle bindings rejected", "error", err)
runtime.report("A tentacle could not acquire its keyboard shortcuts.")
continue
}
if err := session.Activate(); err != nil {
session.Disable()
if restore := runtime.bind(ctx, active); restore != nil && ctx.Err() == nil {
runtime.failures <- restore
return restore
}
continue
}
active = candidate
runtime.publishCommands(active)
runtime.logger.Info("local tentacle active", "session", session.Identity())
}
}
}
func (runtime *tentacleRuntime) bind(ctx context.Context, sessions []*tentacle.Session) error {
var bindings []input.BindingOverride
for _, session := range sessions {
for _, command := range session.Commands() {
for _, binding := range command.Bindings {
layout := input.DesktopKeyboardLayout
if binding.Layout == "laptop" {
layout = input.LaptopKeyboardLayout
}
bindings = append(bindings, input.BindingOverride{Layout: layout, Scope: binding.Scope,
Command: input.Command("tentacle/" + session.Identity() + "/" + command.ID), Keys: []string{binding.Key}})
}
}
}
ctx, cancel := context.WithTimeout(ctx, time.Second)
defer cancel()
return runtime.input.SetTentacleBindings(ctx, bindings)
}
func (runtime *tentacleRuntime) publishCommands(sessions []*tentacle.Session) {
runtime.mutex.Lock()
defer runtime.mutex.Unlock()
defer runtime.signal_extensions()
clear(runtime.commands)
if runtime.disabled {
return
}
for _, session := range sessions {
for _, command := range session.Commands() {
runtime.commands[input.Command("tentacle/"+session.Identity()+"/"+command.ID)] = tentacleCommand{
session: session, entryID: session.TentacleID(), id: command.ID, title: command.Title,
description: command.Description, presentation: command.Presentation,
interactive: command.Interaction, settings: command.Settings, reader: command.Reader,
}
}
}
}
// Invoke never waits for a child or native binding transaction. Every result
// worker has a bounded slot and joins before lifecycle shutdown completes.
func (runtime *tentacleRuntime) Invoke(ctx context.Context, command input.Command) bool {
if runtime == nil || !strings.HasPrefix(string(command), "tentacle/") {
return false
}
runtime.mutex.Lock()
defer runtime.mutex.Unlock()
registered, ok := runtime.commands[command]
if !ok || runtime.disabled {
return true
}
if err := runtime.invokeLocked(ctx, command, registered); err != nil {
runtime.logger.Debug("tentacle command rejected", "command", command, "error", err)
}
return true
}
// Caller holds runtime.mutex so shutdown cannot race worker admission.
func (runtime *tentacleRuntime) invokeLocked(ctx context.Context, command input.Command, registered tentacleCommand) error {
select {
case runtime.resultSlots <- struct{}{}:
default:
return tentacle.ErrBusy
}
result, err := registered.session.Invoke(ctx, registered.id, nil)
if err != nil {
<-runtime.resultSlots
return err
}
var ticket tentacleOutputTicket
if registered.presentation {
ticket = runtime.output.Capture(registered.session.OutputContext())
}
runtime.results.Add(1)
go func() {
defer runtime.results.Done()
defer func() { <-runtime.resultSlots }()
value := <-result
runtime.logger.Debug("tentacle command completed", "command", command, "request", value.ID, "error", value.Err)
if value.Err != nil || !registered.presentation {
return
}
if registered.interactive {
if ticket.ctx == nil || ticket.ctx.Err() != nil {
return
}
runtime.interact(ctx, registered, value.Value)
return
}
presentation, err := tentaclewire.DecodePresentation(value.Value)
if err != nil {
registered.session.Disable()
runtime.output.RetireRevoked()
runtime.logger.Warn("tentacle returned invalid presentation", "command", command, "error", err)
runtime.report("A tentacle returned an invalid message and was disabled.")
return
}
runtime.mutex.Lock()
defer runtime.mutex.Unlock()
if !runtime.disabled && value.Session == registered.session.Identity() {
runtime.output.Present(ticket, presentation, string(command))
}
}()
return nil
}
func (runtime *tentacleRuntime) Describe(chord input.CommandChord, layout input.KeyboardLayout, browse bool) string {
if runtime == nil || runtime.input == nil {
return ""
}
bindings := runtime.input.TentacleBindings()
runtime.mutex.Lock()
defer runtime.mutex.Unlock()
for _, binding := range bindings {
if binding.Layout != layout || binding.Scope == "browse" && !browse {
continue
}
bound, _ := input.ParseCommandChord(binding.Keys[0])
registered, ok := runtime.commands[binding.Command]
if bound != chord || !ok {
continue
}
if state, _ := registered.session.Status(); state != tentacle.Active {
continue
}
if registered.description != "" {
return registered.description
}
return registered.title
}
return ""
}
// ShortcutRows uses the acknowledged native assignments, including core remaps.
func (runtime *tentacleRuntime) ShortcutRows(layout input.KeyboardLayout) []shortcutui.Row {
if runtime == nil || runtime.input == nil {
return nil
}
bindings := runtime.input.TentacleBindings()
runtime.mutex.Lock()
defer runtime.mutex.Unlock()
var rows []shortcutui.Row
for _, binding := range bindings {
registered, ok := runtime.commands[binding.Command]
if binding.Layout != layout || !ok {
continue
}
if state, _ := registered.session.Status(); state != tentacle.Active {
continue
}
label := registered.description
if label == "" {
label = registered.title
}
group := "Tentacles"
if binding.Scope == "browse" {
group = "Tentacles in browse mode"
}
rows = append(rows, shortcutui.Row{Group: group, Label: label, Keys: binding.Keys[0]})
}
return rows
}
func (runtime *tentacleRuntime) report(message string) {
select {
case runtime.announcements <- message:
default:
}
}
// makeManager runs on the lifecycle worker, never on input or GTK callbacks.
func (runtime *tentacleRuntime) makeManager() (*tentacle.Manager, error) {
manager, err := runtime.make_manager()
if err == nil && runtime.remotePolicy != nil {
err = manager.UseRemotePolicy(runtime.remotePolicy)
}
if err == nil && runtime.output != nil && runtime.output.speech != nil {
runtime.output.speech.observe_remote_output(manager)
}
return manager, err
}
func (runtime *tentacleRuntime) make_manager() (*tentacle.Manager, error) {
if runtime.newManager != nil {
return runtime.newManager()
}
program := runtime.guardianProgram
if len(program) == 0 {
executable, err := os.Executable()
if err != nil {
return nil, err
}
program = []string{executable, "--tentacle-guardian"}
}
return tentacle.NewSupervisedManager(program, runtime.parent)
}