Files

109 lines
3.4 KiB
Go

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