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>
459 lines
14 KiB
Go
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)
|
|
}
|