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)
}
+214
View File
@@ -0,0 +1,214 @@
# Copyright (c) 2026 Storm Dragon
# SPDX-License-Identifier: GPL-3.0-or-later
"""Experimental command/presentation SDK. stdout is reserved for the protocol."""
import json
import sys
import threading
from dataclasses import dataclass, field
MAX_FRAME = 64 * 1024
class ProtocolError(Exception):
pass
@dataclass
class Command:
id: str
title: str
description: str
handle: object
bindings: list = field(default_factory=list)
presentation: bool = False
def declaration(self):
declaration = {"id": self.id, "title": self.title, "description": self.description,
"bindings": self.bindings}
if self.presentation:
declaration["presentation"] = True
return declaration
def present(speech="", braille="", uninterruptible=False):
"""Return independent text messages from a presentation-enabled command."""
if type(uninterruptible) is not bool or (uninterruptible and not speech):
raise ValueError("Uninterruptible output requires speech and a boolean option")
result = {key: text for key, text in (("speech", speech), ("braille", braille)) if text}
if uninterruptible:
result["uninterruptible"] = True
return result
def _object(pairs):
result = {}
for key, value in pairs:
if key in result:
raise ProtocolError("Duplicate key")
result[key] = value
return result
def _depth(value, depth=0):
if depth > 32:
raise ProtocolError("JSON nesting limit")
if isinstance(value, (dict, list)):
for child in value.values() if isinstance(value, dict) else value:
_depth(child, depth + 1)
def _constant(value):
raise ProtocolError("Invalid JSON number")
def _encode(value):
_depth(value)
return json.dumps(value, ensure_ascii=False, allow_nan=False,
separators=(",", ":")).encode("utf-8")
def _fields(value, required, optional=()):
if (not isinstance(value, dict) or not set(required) <= value.keys()
or value.keys() - set(required) - set(optional)):
raise ProtocolError("Invalid fields")
def serve(commands, inputStream=None, outputStream=None):
"""Run in a dedicated child, then exit. Handlers receive (cancelled, arguments).
cancelled is a threading.Event shared by the session. Up to eight handlers
run concurrently. Shutdown/EOF sets it and joins handlers; the host terminates
children that ignore cancellation. Exceptions become generic RPC errors.
Streams are binary, owned by this call, and closed on return.
"""
inputStream = inputStream if inputStream is not None else sys.stdin.buffer
outputStream = outputStream if outputStream is not None else sys.stdout.buffer
cancelled = threading.Event()
writeLock = threading.Lock()
workers = []
slots = threading.BoundedSemaphore(8)
def write_reply(requestId, value=None, failed=False, release=None):
reply = {"jsonrpc": "2.0", "id": requestId}
if failed:
reply["error"] = {"code": -32603, "message": "Command failed"}
else:
reply["result"] = value
try:
frame = _encode(reply)
if len(frame) > MAX_FRAME:
raise ValueError()
except (ValueError, TypeError, UnicodeError, RecursionError, ProtocolError):
frame = _encode({"jsonrpc": "2.0", "id": requestId, "error": {
"code": -32603, "message": "Invalid or oversized result"}})
with writeLock:
# Bound eight handlers/waiting replies plus one serialized writer.
if release is not None:
release()
if not cancelled.is_set():
outputStream.write(frame + b"\n")
outputStream.flush()
def invoke(requestId, handler, arguments):
released = False
def release_slot():
nonlocal released
if not released:
slots.release()
released = True
try:
value = handler(cancelled, arguments)
except BaseException:
value, failed = None, True
else:
failed = False
try:
write_reply(requestId, value, failed, release_slot)
except (OSError, ValueError):
cancelled.set()
finally:
release_slot()
try:
commands = list(commands)
handlers = {command.id: command.handle for command in commands}
if (not 1 <= len(commands) <= 32 or len(handlers) != len(commands)
or any(not key or not callable(value) for key, value in handlers.items())):
raise ProtocolError("Invalid commands")
session, active, sequence = None, False, 0
while not cancelled.is_set():
frame = inputStream.readline(MAX_FRAME + 2)
if not frame:
return
if not frame.endswith(b"\n") or len(frame) > MAX_FRAME + 1:
raise ProtocolError("Invalid frame size or termination")
try:
message = json.loads(frame.decode("utf-8"), object_pairs_hook=_object,
parse_constant=_constant)
_depth(message)
except (ValueError, UnicodeError, RecursionError) as error:
raise ProtocolError("Invalid JSON") from error
_fields(message, ("jsonrpc", "method"), ("id", "params"))
if message["jsonrpc"] != "2.0":
raise ProtocolError("Invalid JSON-RPC version")
requestId = message.get("id", "")
if requestId:
if (not isinstance(requestId, str) or not requestId.isascii()
or not requestId.isdecimal() or len(requestId) > 20
or not sequence < int(requestId) < 2**64
or str(int(requestId)) != requestId):
raise ProtocolError("Invalid request sequence")
sequence = int(requestId)
elif "id" in message:
raise ProtocolError("Invalid request ID")
method, params = message["method"], message.get("params")
if method == "initialize":
_fields(params, ("protocol", "session", "apis", "capabilities"))
version = params["protocol"]
if (session is not None or not requestId or version != {"major": 1, "minor": 0}
or any(type(value) is not int for value in version.values())
or not isinstance(params["session"], str) or not params["session"]
or len(params["session"].encode("utf-8")) > 128
or params["apis"] not in (["commands"], ["commands", "presentation"])
or params["capabilities"] != params["apis"]
or (any(command.presentation for command in commands)
and params["apis"] != ["commands", "presentation"])):
raise ProtocolError("Invalid initialization")
session = params["session"]
write_reply(requestId, {"protocol": version, "session": session,
"commands": [command.declaration() for command in commands]})
elif method == "initialized":
_fields(params, ("session",))
if session is None or active or requestId or params["session"] != session:
raise ProtocolError("Invalid activation")
active = True
elif method == "command.invoke":
_fields(params, ("command", "arguments"))
if (not active or not requestId or not isinstance(params["command"], str)
or params["command"] not in handlers
or len(_encode(params["arguments"])) > 16 * 1024):
raise ProtocolError("Invalid invocation")
workers = [worker for worker in workers if worker.is_alive()]
if not slots.acquire(blocking=False):
raise ProtocolError("Command limit")
worker = threading.Thread(target=invoke, args=(requestId,
handlers[params["command"]], params["arguments"]))
workers.append(worker)
worker.start()
elif method == "shutdown":
if requestId or params is not None:
raise ProtocolError("Invalid shutdown")
return
else:
raise ProtocolError("Unknown method")
finally:
cancelled.set()
# Join before closing buffered stdout: a blocked writer must remain
# subject to the host deadline rather than race a Python buffer close.
for worker in workers:
worker.join()
inputStream.close()
outputStream.close()