// Copyright (c) 2026 Storm Dragon // SPDX-License-Identifier: GPL-3.0-or-later package tentaclewire import ( "bytes" "encoding/json" "unicode" "unicode/utf8" "git.stormux.org/storm/cthulhu/internal/interaction" ) // Interaction is an opt-in command result. The host runs forms outside the // bounded RPC deadline and invokes Next with the submitted values. Poll is a // registered command sampled once per second while the form remains open. // A polling reply may announce a changed status or finish the form with Done. type Interaction struct { Presentation Form *interaction.Form `json:"form,omitempty"` Next string `json:"next,omitempty"` Poll string `json:"poll,omitempty"` Source string `json:"source,omitempty"` // "focus" or "clipboard", supplied to Next. Clipboard *string `json:"clipboard,omitempty"` Done bool `json:"done,omitempty"` } type Submission struct { Activated string `json:"activated,omitempty"` Values map[string]interaction.Value `json:"values,omitempty"` Cancelled bool `json:"cancelled,omitempty"` Item *Item `json:"item,omitempty"` Error string `json:"error,omitempty"` } // Item is a single copied value, without accessible identities or native handles. type Item struct { Temporary bool `json:"-"` // Host-only ownership of materialized clipboard data. Text string `json:"text,omitempty"` Path string `json:"path,omitempty"` } func DecodeInteraction(data []byte) (Interaction, error) { var value Interaction var fields map[string]json.RawMessage if json.Unmarshal(data, &fields) != nil || fields == nil { return value, ErrProtocol } for _, field := range fields { if bytes.Equal(bytes.TrimSpace(field), []byte("null")) { return value, ErrProtocol } } if err := DecodeStrict(data, &value); err != nil { return value, err } if value.Speech != "" || value.Braille != "" { message, _ := json.Marshal(value.Presentation) if _, err := DecodePresentation(message); err != nil { return value, err } } else if value.Uninterruptible { return value, ErrProtocol } if value.Clipboard != nil && (!utf8.ValidString(*value.Clipboard) || len(*value.Clipboard) > MaxPresentationBytes) { return value, ErrLimit } if value.Source != "" && value.Source != "focus" && value.Source != "clipboard" { return value, ErrProtocol } if value.Form != nil { for _, element := range value.Form.Elements { if !validFormText(element.Label) || !validFormText(element.ID) { return value, ErrProtocol } for _, item := range element.Items { if !validFormText(item.Label) || !validFormText(item.ID) { return value, ErrProtocol } } } if !validFormText(value.Form.Title) { return value, ErrProtocol } if _, err := interaction.NewSession(*value.Form); err != nil { return value, ErrProtocol } if value.Next == "" || value.Done || value.Source != "" { return value, ErrProtocol } } else if value.Poll != "" || value.Next != "" && value.Source == "" || value.Source != "" && value.Next == "" { return value, ErrProtocol } return value, nil } func validFormText(text string) bool { if !utf8.ValidString(text) { return false } for _, r := range text { if unicode.IsControl(r) { return false } } return true }