// 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 }() commandContext := ctx parent := ctx if len(command.session.RemoteGrants()) != 0 && runtime.remotePolicy != nil { parent = runtime.remotePolicy.Context() } ctx, cancel := context.WithCancel(parent) defer cancel() stopCommand := context.AfterFunc(commandContext, cancel) defer stopCommand() stop := context.AfterFunc(command.session.OutputContext(), cancel) defer stop() // Policy cancellation can interrupt setup before approval exists. // Tell the original continuation to stop its // inspection/setup workers before releasing the interaction slot. Never pass // cancelled form values or reuse the cancelled interaction context. cleanupNext := "" var approvedEpoch uint64 defer func() { if approvedEpoch != 0 { command.session.RevokeRemoteEpoch(approvedEpoch) } if len(command.session.RemoteGrants()) != 0 && cleanupNext != "" && command.session.OutputContext().Err() == nil { cleanupCtx, stopCleanup := context.WithTimeout(context.Background(), 2*time.Second) defer stopCleanup() runtime.callInteraction(cleanupCtx, command, cleanupNext, tentaclewire.Submission{Cancelled: true}) } }() 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.RemoteBackground != nil { lease := *value.RemoteBackground lifetime, leaseErr := command.session.RemoteLeaseContext(lease) if leaseErr != nil || ctx.Err() != nil || lifetime.Err() != nil || approvedEpoch != 0 && approvedEpoch != lease.Epoch { runtime.report("Remote connection ended. Start a fresh access review.") return } // The reader lease already belongs to the policy/session owner. // Returning to the desktop releases form and interaction capture, // without extending consent or running the cancellation continuation. approvedEpoch, cleanupNext = 0, "" runtime.output.Present(runtime.output.Capture(command.session.OutputContext()), value.Presentation, "tentacle/"+command.id) return } cleanupNext = value.Next if value.Remote != nil { action, approvalErr := runtime.authorize_remote_request(ctx, command.session, *value.Remote) submission := tentaclewire.Submission{} if approvalErr != nil { submission.Cancelled = true submission.Error = "Remote access was not approved." } else { approvedEpoch = action.Epoch submission.Remote = &action } raw, err = runtime.callInteraction(ctx, command, value.Next, submission) if err == nil && submission.Cancelled { cleanupNext = "" } if err != nil || approvalErr != nil { return } continue } if value.Clipboard != nil { copyCtx, stopCopy := context.WithTimeout(ctx, time.Second) err = writeClipboardText(copyCtx, *value.Clipboard) stopCopy() if err != nil { value.Presentation = clipboardWriteFailure(value.Presentation) } } if value.Reader != nil { if !command.reader || runtime.readerSettings == nil || value.Next != "" && !runtime.reader_continuation(command, value.Next) { command.session.Disable() return } state, readerErr := runtime.readerSettings.request_reader(ctx, command.session, *value.Reader) if value.Next == "" { if readerErr != nil { runtime.report("Tentacle settings were not applied: " + readerErr.Error()) } return } submission := tentaclewire.Submission{Reader: state} if readerErr != nil { submission.Error = readerErr.Error() } raw, err = runtime.callInteraction(ctx, command, value.Next, submission) if err != nil { return } continue } 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}) cleanupNext = "" runtime.report("Internal UI unavailable.") return } formCtx, closeForm := context.WithCancel(ctx) replies, openErr := runtime.open_tentacle_form(formCtx, command, *value.Form) if openErr != nil { closeForm() runtime.callInteraction(ctx, command, value.Next, tentaclewire.Submission{Cancelled: true}) cleanupNext = "" 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} if submission.Cancelled { submission.Activated, submission.Values = "", 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 { cleanupNext = "" // A status form may close while retaining its already-open lease. // Validate that terminal result in the ordinary ownership path above. terminal, decodeErr := tentaclewire.DecodeInteraction(raw) if decodeErr == nil && terminal.RemoteBackground != nil { continue } return } } } func (runtime *tentacleRuntime) open_tentacle_form(ctx context.Context, command tentacleCommand, form interaction.Form) (<-chan internalUIResult, error) { if len(command.session.RemoteGrants()) != 0 { local, ok := runtime.forms.(localTentacleForms) if !ok { return nil, errors.New("local approval input unavailable") } return local.OpenLocal(ctx, form) } return runtime.forms.Open(ctx, form) } 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.Source != "" || status.Reader != nil || status.Form == nil && status.Remote == nil && !status.Done && status.Next != "" { command.session.Disable() return internalUIResult{Cancelled: true}, nil } if status.Done || status.Form != nil || status.Remote != nil { 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 := writeClipboardText(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 }