// Copyright (c) 2026 Storm Dragon // SPDX-License-Identifier: GPL-3.0-or-later package app import ( "context" "encoding/json" "errors" "fmt" "os" "path/filepath" "time" "git.stormux.org/storm/cthulhu/internal/atspi" "git.stormux.org/storm/cthulhu/internal/interaction" "git.stormux.org/storm/cthulhu/internal/tentaclewire" ) type tentacleForms interface { Open(context.Context, interaction.Form) (<-chan internalUIResult, error) Notice(context.Context, tentaclewire.Presentation) } // One interaction per runtime. RPC requests remain short; human input and // background transfer polling belong to this joined worker, never the input // consumer. Session revocation cancels its form and retires all results. func (runtime *tentacleRuntime) interact(ctx context.Context, command tentacleCommand, raw json.RawMessage) { select { case runtime.interactionSlots <- struct{}{}: default: runtime.report("A tentacle menu is already open.") return } defer func() { <-runtime.interactionSlots }() ctx, cancel := context.WithCancel(ctx) defer cancel() stop := context.AfterFunc(command.session.OutputContext(), cancel) defer stop() var temporaryFiles []string defer func() { for _, path := range temporaryFiles { os.RemoveAll(filepath.Dir(path)) } }() for transitions := 0; transitions < 1024 && ctx.Err() == nil; transitions++ { value, err := tentaclewire.DecodeInteraction(raw) if err != nil || !runtime.validContinuation(command, value.Next) || !runtime.validContinuation(command, value.Poll) { command.session.Disable() runtime.output.RetireRevoked() runtime.report("A tentacle returned an invalid interaction and was disabled.") return } if runtime.output.gate.Locked() || runtime.output.gate.Suspended() { return } if value.Clipboard != nil { copyCtx, stopCopy := context.WithTimeout(ctx, time.Second) err = copyTentacleText(copyCtx, *value.Clipboard) stopCopy() if err != nil { value.Presentation = clipboardWriteFailure(value.Presentation) } } if value.Form == nil && value.Source == "" { runtime.output.Present(runtime.output.Capture(command.session.OutputContext()), value.Presentation, "tentacle/"+command.id) return } var submission tentaclewire.Submission if value.Form != nil { if runtime.forms == nil { runtime.callInteraction(ctx, command, value.Next, tentaclewire.Submission{Cancelled: true}) runtime.report("Internal UI unavailable.") return } formCtx, closeForm := context.WithCancel(ctx) replies, openErr := runtime.forms.Open(formCtx, *value.Form) if openErr != nil { closeForm() runtime.callInteraction(ctx, command, value.Next, tentaclewire.Submission{Cancelled: true}) runtime.report("Internal UI unavailable.") return } if value.Speech != "" || value.Braille != "" { runtime.forms.Notice(formCtx, value.Presentation) } reply, completed := runtime.waitTentacleForm(formCtx, command, value, replies) closeForm() if completed != nil { // Wait for EndInternalUI before announcing completion or copying text. select { case <-replies: case <-ctx.Done(): return } raw = completed continue } submission = tentaclewire.Submission{Activated: reply.Activated, Values: reply.Values, Cancelled: reply.Cancelled || reply.Err != nil} } else { itemCtx, stopItem := context.WithTimeout(ctx, time.Second) var item tentaclewire.Item if value.Source == "focus" { if runtime.focusedItem == nil { err = errors.New("focused item unavailable") } else { item, err = runtime.focusedItem(itemCtx) } } else { item, err = readTentacleClipboard(itemCtx) if item.Temporary { temporaryFiles = append(temporaryFiles, item.Path) item.Temporary = false } } stopItem() if err != nil { submission.Error = "The requested item is unavailable or is not a single item" } else { submission.Item = &item } } // A cancelled UI still gets a short cleanup invocation while its session is // live. That lets the tentacle stop its background process on Escape/focus loss. raw, err = runtime.callInteraction(ctx, command, value.Next, submission) if err != nil { return } if submission.Cancelled { return } } } func (runtime *tentacleRuntime) validContinuation(command tentacleCommand, id string) bool { if id == "" { return true } for _, registered := range command.session.Commands() { if registered.ID == id { return registered.Interaction && registered.Presentation } } return false } func (runtime *tentacleRuntime) callInteraction(ctx context.Context, command tentacleCommand, id string, arguments any) (json.RawMessage, error) { data, err := json.Marshal(arguments) if err != nil { return nil, err } replies, err := command.session.Invoke(ctx, id, data) if err != nil { return nil, err } result := <-replies return result.Value, result.Err } func (runtime *tentacleRuntime) waitTentacleForm(ctx context.Context, command tentacleCommand, value tentaclewire.Interaction, replies <-chan internalUIResult) (internalUIResult, json.RawMessage) { var ticks <-chan time.Time if value.Poll != "" { ticker := time.NewTicker(time.Second) defer ticker.Stop() ticks = ticker.C } for { select { case reply := <-replies: return reply, nil case <-ctx.Done(): return internalUIResult{Cancelled: true}, nil case <-ticks: raw, err := runtime.callInteraction(ctx, command, value.Poll, tentaclewire.Submission{}) if err != nil { return internalUIResult{Cancelled: true}, nil } status, err := tentaclewire.DecodeInteraction(raw) if err != nil || status.Form != nil || status.Source != "" || status.Next != "" { command.session.Disable() return internalUIResult{Cancelled: true}, nil } if status.Done { return internalUIResult{}, raw } if status.Clipboard != nil { if runtime.output.gate.Locked() || runtime.output.gate.Suspended() || ctx.Err() != nil { return internalUIResult{Cancelled: true}, nil } copyCtx, stopCopy := context.WithTimeout(ctx, time.Second) copyErr := copyTentacleText(copyCtx, *status.Clipboard) stopCopy() if copyErr != nil { status.Presentation = clipboardWriteFailure(status.Presentation) } } runtime.forms.Notice(ctx, status.Presentation) } } } func focusedTentacleItem(source any, ui *internalUI) func(context.Context) (tentaclewire.Item, error) { reader, ok := source.(interface { ReadTransferItem(context.Context, atspi.ObjectID, int) (atspi.TransferItem, error) }) if !ok || ui.interest == nil { return nil } return func(ctx context.Context) (tentaclewire.Item, error) { interest, err := ui.interest.ObjectOfInterest(ctx) if err != nil { return tentaclewire.Item{}, err } if interest.HasZone { return tentaclewire.Item{}, errors.New("flat review transfer unavailable") } item, err := reader.ReadTransferItem(ctx, interest.Object, interest.Offset) return tentaclewire.Item{Text: item.Text, Path: item.Path}, err } } func clipboardWriteFailure(message tentaclewire.Presentation) tentaclewire.Presentation { if message.Speech == "" { message.Speech = "Clipboard text could not be saved" } else { message.Speech = fmt.Sprintf("%s. Clipboard text could not be saved", message.Speech) } if message.Braille == "" { message.Braille = "Clipboard text could not be saved" } else { message.Braille = fmt.Sprintf("%s. Clipboard text could not be saved", message.Braille) } return message }