Files
cthulhu/internal/app/tentacle_interaction.go

232 lines
7.4 KiB
Go

// 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
}