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

+108
View File
@@ -0,0 +1,108 @@
// 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
}
+57
View File
@@ -0,0 +1,57 @@
package tentaclewire
import (
"encoding/json"
"git.stormux.org/storm/cthulhu/internal/interaction"
"strings"
"testing"
)
func TestInteractionContract(t *testing.T) {
form := &interaction.Form{Title: "Wormhole menu", Elements: []interaction.Element{{ID: "choices", Label: "Options", Kind: interaction.List, Items: []interaction.Item{{ID: "send", Label: "Send focused item"}}}}}
for _, value := range []Interaction{
{Form: form, Next: "choice"}, {Form: form, Next: "choice", Poll: "status"}, {Source: "focus", Next: "send"}, {Source: "clipboard", Next: "send"},
{Presentation: Presentation{Speech: "Ready"}, Done: true}, {Done: true}, {},
} {
data, _ := json.Marshal(value)
if _, err := DecodeInteraction(data); err != nil {
t.Fatalf("%s: %v", data, err)
}
}
for _, raw := range []string{`null`, ` null `, `{"form":null}`, `{"clipboard":null}`, `{"unknown":true}`, `{"source":"desktop","next":"send"}`, `{"source":"focus"}`, `{"next":"send"}`, `{"poll":"status"}`, `{"speech":"bad\u0000"}`, `{"uninterruptible":true}`} {
if _, err := DecodeInteraction([]byte(raw)); err == nil {
t.Fatal("accepted", raw)
}
}
for _, value := range []Interaction{{Form: form}, {Form: form, Next: "choice", Done: true}, {Form: form, Next: "choice", Source: "focus"}} {
data, _ := json.Marshal(value)
if _, err := DecodeInteraction(data); err == nil {
t.Fatal("accepted", string(data))
}
}
copy := strings.Repeat("x", MaxPresentationBytes+1)
data, _ := json.Marshal(Interaction{Clipboard: &copy})
if _, err := DecodeInteraction(data); err == nil {
t.Fatal("accepted oversized clipboard write")
}
form.Title = "Bad\x1btitle"
data, _ = json.Marshal(Interaction{Form: form, Next: "choice"})
if _, err := DecodeInteraction(data); err == nil {
t.Fatal("accepted control in title")
}
}
func TestInteractionGrantsRemainExplicit(t *testing.T) {
for _, apis := range [][]string{{"commands"}, {"commands", "presentation"}, {"commands", "presentation", "interaction"}} {
if !ValidAPIs(apis, apis) {
t.Fatal("rejected", apis)
}
}
for _, apis := range [][]string{{"commands", "interaction"}, {"commands", "presentation", "unknown"}, {"commands", "interaction", "presentation"}} {
if ValidAPIs(apis, apis) {
t.Fatal("accepted", apis)
}
}
if ValidAPIs([]string{"commands", "presentation", "interaction"}, []string{"commands", "presentation"}) {
t.Fatal("missing grant accepted")
}
}
+73
View File
@@ -0,0 +1,73 @@
// Copyright (c) 2026 Storm Dragon
// SPDX-License-Identifier: GPL-3.0-or-later
package tentaclewire
import (
"bytes"
"encoding/json"
"unicode"
"unicode/utf8"
)
const MaxPresentationBytes = 8192 // Per channel, measured in UTF-8 bytes.
// Presentation is a command result, never an unsolicited announcement. Empty
// channels are omitted; neither channel supplies positions or routing targets.
type Presentation struct {
Speech string `json:"speech,omitempty"`
Braille string `json:"braille,omitempty"`
Uninterruptible bool `json:"uninterruptible,omitempty"`
}
func DecodePresentation(data []byte) (Presentation, error) {
var value Presentation
if err := DecodeStrict(data, &value); err != nil {
return value, err
}
var fields map[string]json.RawMessage
if json.Unmarshal(data, &fields) != nil || fields == nil || len(fields) == 0 {
return value, ErrProtocol
}
for _, field := range fields {
if bytes.Equal(field, []byte("null")) {
return value, ErrProtocol
}
}
if value.Speech == "" && value.Braille == "" {
return value, ErrProtocol
}
if value.Uninterruptible && value.Speech == "" {
return value, ErrProtocol
}
for _, text := range []string{value.Speech, value.Braille} {
if len(text) > MaxPresentationBytes {
return value, ErrLimit
}
if !utf8.ValidString(text) {
return value, ErrProtocol
}
for _, char := range text {
if unicode.IsControl(char) && char != '\n' && char != '\t' {
return value, ErrProtocol
}
}
}
return value, nil
}
// The experimental optional API is additive to the command-only handshake.
func ValidAPIs(apis, capabilities []string) bool {
if len(apis) != len(capabilities) || len(apis) < 1 || len(apis) > 3 || apis[0] != "commands" {
return false
}
if len(apis) >= 2 && apis[1] != "presentation" || len(apis) == 3 && apis[2] != "interaction" {
return false
}
for index := range apis {
if apis[index] != capabilities[index] {
return false
}
}
return true
}
@@ -0,0 +1,37 @@
package tentaclewire
import (
"encoding/json"
"errors"
"strings"
"testing"
)
func TestPresentationContract(t *testing.T) {
for _, value := range []Presentation{
{Speech: "Hello, é猫😀é"}, {Braille: "é猫😀é"},
{Speech: "Speech text", Braille: "Display text"},
{Speech: "Hello world!", Braille: "Hello world!", Uninterruptible: true},
{Speech: strings.Repeat("é", MaxPresentationBytes/2)},
} {
data, _ := json.Marshal(value)
decoded, err := DecodePresentation(data)
if err != nil || decoded != value {
t.Fatalf("round trip: %+v, %v", decoded, err)
}
}
for _, data := range []string{`null`, `[]`, `{}`, `{"speech":null}`, `{"speech":42}`,
`{"speech":""}`, `{"speech":"hello","voice":"default"}`, `{"speech":"one","speech":"two"}`,
`{"braille":"bad\u0000text"}`, `{"speech":"bad\u001btext"}`,
`{"braille":"hello","uninterruptible":true}`, `{"uninterruptible":true}`,
`{"speech":"hello","uninterruptible":null}`, `{"speech":"hello","uninterruptible":"true"}`,
`{"speech":"hello","uninterruptible":true,"uninterruptible":false}`} {
if _, err := DecodePresentation([]byte(data)); !errors.Is(err, ErrProtocol) {
t.Fatalf("accepted %s: %v", data, err)
}
}
data, _ := json.Marshal(Presentation{Braille: strings.Repeat("é", MaxPresentationBytes/2+1)})
if _, err := DecodePresentation(data); !errors.Is(err, ErrLimit) {
t.Fatal("UTF-8 byte bound", err)
}
}
+106
View File
@@ -0,0 +1,106 @@
// Copyright (c) 2026 Storm Dragon
// SPDX-License-Identifier: GPL-3.0-or-later
// Package tentaclewire shares bounded framing between the host and Go SDK.
package tentaclewire
import (
"bufio"
"bytes"
"encoding/json"
"errors"
"io"
"unicode/utf8"
)
const MaxFrameBytes = 64 * 1024
var ErrProtocol = errors.New("invalid tentacle protocol message")
var ErrLimit = errors.New("tentacle resource limit exceeded")
// ReadFrame bounds allocation even when the child never writes a newline.
func ReadFrame(reader *bufio.Reader) ([]byte, error) {
var frame []byte
for {
part, err := reader.ReadSlice('\n')
if len(frame)+len(part) > MaxFrameBytes+1 {
return nil, ErrLimit
}
frame = append(frame, part...)
if err == nil {
return frame[:len(frame)-1], nil
}
if errors.Is(err, bufio.ErrBufferFull) {
continue
}
if errors.Is(err, io.EOF) && len(frame) != 0 {
return nil, io.ErrUnexpectedEOF
}
return nil, err
}
}
// Reject duplicate keys and excessive nesting, including in command arguments
// and results. Different languages must not interpret the same frame differently.
func ValidateJSON(data []byte) error {
if !utf8.Valid(data) {
return ErrProtocol
}
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.UseNumber()
if err := validate_value(decoder, 0); err != nil {
return ErrProtocol
}
if _, err := decoder.Token(); err != io.EOF {
return ErrProtocol
}
return nil
}
func validate_value(decoder *json.Decoder, depth int) error {
if depth > 32 {
return ErrLimit
}
token, err := decoder.Token()
if err != nil {
return err
}
delimiter, composite := token.(json.Delim)
if !composite {
return nil
}
if delimiter != '{' && delimiter != '[' {
return ErrProtocol
}
keys := map[string]bool{}
for decoder.More() {
if delimiter == '{' {
keyToken, err := decoder.Token()
if err != nil {
return err
}
key, ok := keyToken.(string)
if !ok || keys[key] {
return ErrProtocol
}
keys[key] = true
}
if err := validate_value(decoder, depth+1); err != nil {
return err
}
}
_, err = decoder.Token()
return err
}
func DecodeStrict(data []byte, target any) error {
if err := ValidateJSON(data); err != nil {
return err
}
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.DisallowUnknownFields()
if err := decoder.Decode(target); err != nil {
return ErrProtocol
}
return nil
}