238 lines
7.9 KiB
Go
238 lines
7.9 KiB
Go
// Copyright (c) 2026 Storm Dragon
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
// Package tentacle implements the experimental command, presentation and interaction SDK.
|
|
// It has no reader, input, or output backend dependencies.
|
|
package tentacle
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"strconv"
|
|
"sync"
|
|
|
|
"git.stormux.org/storm/cthulhu/internal/interaction"
|
|
"git.stormux.org/storm/cthulhu/internal/tentaclewire"
|
|
)
|
|
|
|
type Binding struct {
|
|
Layout string `json:"layout"`
|
|
Scope string `json:"scope"`
|
|
Key string `json:"key"`
|
|
}
|
|
|
|
// Presentation returns independent plain-text speech and braille messages.
|
|
// Declare Command.Presentation and obtain the local host grant before using it.
|
|
// Uninterruptible protects accepted speech through ordinary input/focus changes;
|
|
// session revocation and core lifecycle/output policy still take precedence.
|
|
type Presentation = tentaclewire.Presentation
|
|
|
|
// Interaction requires the explicit interaction grant as well as presentation.
|
|
type Interaction = tentaclewire.Interaction
|
|
type Submission = tentaclewire.Submission
|
|
type Item = tentaclewire.Item
|
|
type Form = interaction.Form
|
|
type Element = interaction.Element
|
|
type ListItem = interaction.Item
|
|
type Value = interaction.Value
|
|
|
|
const (
|
|
List = interaction.List
|
|
Entry = interaction.Entry
|
|
CheckBox = interaction.CheckBox
|
|
Button = interaction.Button
|
|
)
|
|
|
|
// Handlers may run concurrently. Cancellation applies to the entire session;
|
|
// check ctx before side effects. Returning an error produces a generic RPC error,
|
|
// never the error text (which may contain private data).
|
|
type Handler func(ctx context.Context, arguments json.RawMessage) (any, error)
|
|
|
|
type Command struct {
|
|
ID string `json:"id"`
|
|
Title string `json:"title"`
|
|
Description string `json:"description"`
|
|
Bindings []Binding `json:"bindings,omitempty"`
|
|
Presentation bool `json:"presentation,omitempty"`
|
|
Interaction bool `json:"interaction,omitempty"`
|
|
// Settings exposes this interactive command through global Preferences.
|
|
// Register at most one; its handler opens a form without requiring arguments.
|
|
Settings bool `json:"settings,omitempty"`
|
|
Handle Handler `json:"-"`
|
|
}
|
|
|
|
var ErrProtocol = tentaclewire.ErrProtocol
|
|
|
|
type request struct {
|
|
JSONRPC string `json:"jsonrpc"`
|
|
ID string `json:"id,omitempty"`
|
|
Method string `json:"method"`
|
|
Params json.RawMessage `json:"params"`
|
|
}
|
|
|
|
// Serve owns the streams and closes them on return. Use stdin/stdout in a
|
|
// dedicated child process and exit when Serve returns. Shutdown/EOF cancels all
|
|
// handlers and waits for them; the host kills a child that ignores cancellation.
|
|
// Never print to stdout. Command declarations must remain unchanged during Serve.
|
|
func Serve(input io.ReadCloser, output io.WriteCloser, commands []Command) error {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
var workers sync.WaitGroup
|
|
var writeMutex sync.Mutex
|
|
defer func() { cancel(); input.Close(); output.Close(); workers.Wait() }()
|
|
handlers := make(map[string]Handler)
|
|
if len(commands) == 0 || len(commands) > 32 {
|
|
return ErrProtocol
|
|
}
|
|
settings := false
|
|
for _, command := range commands {
|
|
if command.Settings && (!command.Interaction || !command.Presentation || settings) || command.ID == "" || command.Handle == nil || handlers[command.ID] != nil {
|
|
return ErrProtocol
|
|
}
|
|
settings = settings || command.Settings
|
|
handlers[command.ID] = command.Handle
|
|
}
|
|
writeReply := func(id string, value any, failed bool, release func()) error {
|
|
reply := map[string]any{"jsonrpc": "2.0", "id": id, "result": value}
|
|
if failed {
|
|
delete(reply, "result")
|
|
reply["error"] = map[string]any{"code": -32603, "message": "Command failed"}
|
|
}
|
|
frame, err := json.Marshal(reply)
|
|
if err != nil || len(frame) > tentaclewire.MaxFrameBytes || tentaclewire.ValidateJSON(frame) != nil {
|
|
frame, _ = json.Marshal(map[string]any{"jsonrpc": "2.0", "id": id,
|
|
"error": map[string]any{"code": -32603, "message": "Invalid or oversized result"}})
|
|
}
|
|
writeMutex.Lock()
|
|
defer writeMutex.Unlock()
|
|
// A completed reply may reach the host before Write returns. Release
|
|
// its handler slot first, with at most one additional serialized writer.
|
|
if release != nil {
|
|
release()
|
|
}
|
|
if ctx.Err() != nil {
|
|
return ctx.Err()
|
|
}
|
|
_, err = output.Write(append(frame, '\n'))
|
|
return err
|
|
}
|
|
reader := bufio.NewReader(input)
|
|
var sequence uint64
|
|
session := ""
|
|
active := false
|
|
slots := make(chan struct{}, 8)
|
|
for {
|
|
frame, err := tentaclewire.ReadFrame(reader)
|
|
if errors.Is(err, io.EOF) {
|
|
return nil
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var message request
|
|
if tentaclewire.DecodeStrict(frame, &message) != nil || message.JSONRPC != "2.0" {
|
|
return ErrProtocol
|
|
}
|
|
var fields map[string]json.RawMessage
|
|
_ = json.Unmarshal(frame, &fields)
|
|
for key := range fields {
|
|
if key != "jsonrpc" && key != "id" && key != "method" && key != "params" {
|
|
return ErrProtocol
|
|
}
|
|
}
|
|
if _, present := fields["id"]; present && message.ID == "" {
|
|
return ErrProtocol
|
|
}
|
|
if message.ID != "" {
|
|
number, err := strconv.ParseUint(message.ID, 10, 64)
|
|
if err != nil || number <= sequence || strconv.FormatUint(number, 10) != message.ID {
|
|
return ErrProtocol
|
|
}
|
|
sequence = number
|
|
}
|
|
switch message.Method {
|
|
case "initialize":
|
|
var params struct {
|
|
Protocol struct {
|
|
Major *int `json:"major"`
|
|
Minor *int `json:"minor"`
|
|
} `json:"protocol"`
|
|
Session string `json:"session"`
|
|
APIs []string `json:"apis"`
|
|
Capabilities []string `json:"capabilities"`
|
|
}
|
|
if session != "" || message.ID == "" || tentaclewire.DecodeStrict(message.Params, ¶ms) != nil ||
|
|
params.Protocol.Major == nil || params.Protocol.Minor == nil || *params.Protocol.Major != 1 || *params.Protocol.Minor != 0 || params.Session == "" || len(params.Session) > 128 ||
|
|
!tentaclewire.ValidAPIs(params.APIs, params.Capabilities) {
|
|
return ErrProtocol
|
|
}
|
|
for _, command := range commands {
|
|
if command.Presentation && len(params.APIs) < 2 || command.Interaction && (!command.Presentation || len(params.APIs) != 3) {
|
|
return ErrProtocol
|
|
}
|
|
}
|
|
session = params.Session
|
|
if err := writeReply(message.ID, map[string]any{"protocol": params.Protocol, "session": session, "commands": commands}, false, nil); err != nil {
|
|
return err
|
|
}
|
|
case "initialized":
|
|
var params struct {
|
|
Session string `json:"session"`
|
|
}
|
|
if session == "" || active || message.ID != "" || tentaclewire.DecodeStrict(message.Params, ¶ms) != nil || params.Session != session {
|
|
return ErrProtocol
|
|
}
|
|
active = true
|
|
case "command.invoke":
|
|
var params struct {
|
|
Command string `json:"command"`
|
|
Arguments json.RawMessage `json:"arguments"`
|
|
}
|
|
if !active || message.ID == "" || tentaclewire.DecodeStrict(message.Params, ¶ms) != nil || len(params.Arguments) == 0 || len(params.Arguments) > 16*1024 || handlers[params.Command] == nil {
|
|
return ErrProtocol
|
|
}
|
|
select {
|
|
case slots <- struct{}{}:
|
|
default:
|
|
return tentaclewire.ErrLimit
|
|
}
|
|
workers.Add(1)
|
|
go func(id string, handler Handler, arguments json.RawMessage) {
|
|
defer workers.Done()
|
|
released := false
|
|
release := func() {
|
|
if !released {
|
|
<-slots
|
|
released = true
|
|
}
|
|
}
|
|
defer release()
|
|
value, err := call_handler(ctx, handler, arguments)
|
|
if writeReply(id, value, err != nil, release) != nil {
|
|
cancel()
|
|
input.Close()
|
|
}
|
|
}(message.ID, handlers[params.Command], params.Arguments)
|
|
case "shutdown":
|
|
if message.ID != "" || (len(message.Params) != 0 && string(message.Params) != "null") {
|
|
return ErrProtocol
|
|
}
|
|
return nil
|
|
default:
|
|
return ErrProtocol
|
|
}
|
|
}
|
|
}
|
|
|
|
func call_handler(ctx context.Context, handler Handler, arguments json.RawMessage) (value any, err error) {
|
|
defer func() {
|
|
if recover() != nil {
|
|
value = nil
|
|
err = errors.New("command panicked")
|
|
}
|
|
}()
|
|
return handler(ctx, arguments)
|
|
}
|