Cthulhu is no longer a fork. Now it is a screen reader written in Golang.
This commit is contained in:
1 parent
a998fe67de
commit
069fc674dc
708 files changed
+112482
-532
No files matched your search
@@ -0,0 +1,237 @@
|
||||
// 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)
|
||||
}
|
||||
Reference in new issue
Block a user