232 lines
7.4 KiB
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
|
|
}
|