Cthulhu is no longer a fork. Now it is a screen reader written in Golang.

This commit is contained in:
Storm Dragon committed 2026-10-14 00:16:34 -04:00
1 parent a998fe67de
commit 069fc674dc
708 files changed
+112482 -532

No files matched your search

+424
View File
@@ -0,0 +1,424 @@
package app
import (
"context"
"errors"
"log/slog"
"strings"
"sync"
"time"
"git.stormux.org/storm/cthulhu/internal/input"
"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
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 {
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,
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)
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
}
var manager *tentacle.Manager
if runtime.newManager != nil {
manager, err = runtime.newManager()
} else {
parent := runtime.parent
if parent == "auto" {
parent, err = tentacle.DelegatedParent()
}
if err == nil {
manager, err = tentacle.NewContainedManager(tentacle.DefaultLimits(), nil, parent, tentacle.DefaultResourceLimits())
}
}
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()
watchers.Add(1)
go func() {
defer watchers.Done()
select {
case <-session.Revoked():
case <-ctx.Done():
}
runtime.output.RetireRevoked()
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()
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,
}
}
}
}
// 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:
}
}