Cthulhu is no longer a fork. Now it is a screen reader written in Golang.

This commit is contained in:
Storm Dragon committed 2026-10-14 00:16:34 -04:00
1 parent a998fe67de
commit 069fc674dc
708 files changed
+112482 -532

No files matched your search

+237
View File
@@ -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, &params) != 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, &params) != 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, &params) != 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)
}