Files
ak47 573d15d7b4 Add Remote session foundations and feasibility checks
Add opt-in Tentacle protocol 1.1, explicit remote grants, lifecycle IPC and expiring core leases in both SDKs. Revoke access before update preparation, including failed updates.

Add transient secret entries, shared Braille cell export and isolated Xorg recovery experiments with focused tests and documented limitations. Production consent, desktop services and live interoperability remain pending.
2026-11-03 22:33:26 -05:00

251 lines
9.9 KiB
Go

// Copyright (c) 2026 Storm Dragon
// SPDX-License-Identifier: GPL-3.0-or-later
package tentaclewire
import (
"encoding/json"
"errors"
"strings"
"git.stormux.org/storm/cthulhu/internal/config"
"git.stormux.org/storm/cthulhu/internal/interaction"
"git.stormux.org/storm/cthulhu/internal/speech"
)
// SpeechConfiguration is a complete voice configuration, never arbitrary reader
// state. Enablement, interruption policy, input and unrelated preferences remain
// under the core's control. Nil voice parameters retain server/inherited defaults.
type SpeechConfiguration struct {
Server string `json:"server"`
Module string `json:"module"`
Device string `json:"device,omitempty"`
Baud int `json:"baud,omitempty"`
DefaultVoice config.Voice `json:"default_voice"`
UppercaseVoice config.Voice `json:"uppercase_voice"`
HyperlinkVoice config.Voice `json:"hyperlink_voice"`
SystemVoice config.Voice `json:"system_voice"`
RaisePitchForCapitals bool `json:"raise_pitch_for_capitals"`
BeepForCapitals bool `json:"beep_for_capitals"`
SayCapForCapitals bool `json:"say_cap_for_capitals"`
Punctuation int `json:"punctuation"`
CustomEcho bool `json:"custom_echo"`
CustomEchoServer bool `json:"custom_echo_server"`
EchoServer string `json:"echo_server,omitempty"`
EchoVoice config.Voice `json:"echo_voice"`
EchoForKey bool `json:"echo_for_key"`
EchoForCharacter bool `json:"echo_for_character"`
EchoForWord bool `json:"echo_for_word"`
EchoForSentence bool `json:"echo_for_sentence"`
}
func SpeechFromSettings(settings config.Settings) SpeechConfiguration {
return SpeechConfiguration{
Server: settings.SpeechServerFactory, Module: settings.SpeechOutputModule,
Device: settings.HardwareSpeechDevice, Baud: settings.HardwareSpeechBaudRate,
DefaultVoice: settings.DefaultVoice, UppercaseVoice: settings.UppercaseVoice,
HyperlinkVoice: settings.HyperlinkVoice, SystemVoice: settings.SystemVoice,
RaisePitchForCapitals: settings.RaisePitchForCapitals,
BeepForCapitals: settings.BeepForCapitals, SayCapForCapitals: settings.SayCapForCapitals,
Punctuation: settings.VerbalizePunctuationStyle,
CustomEcho: settings.UseCustomEchoVoice, CustomEchoServer: settings.UseCustomEchoSpeechServer,
EchoServer: settings.EchoSpeechServer, EchoVoice: settings.EchoVoice,
EchoForKey: settings.UseCustomEchoForKey, EchoForCharacter: settings.UseCustomEchoForCharacter,
EchoForWord: settings.UseCustomEchoForWord, EchoForSentence: settings.UseCustomEchoForSentence,
}
}
func (value SpeechConfiguration) Apply(settings config.Settings) config.Settings {
settings.SpeechServerFactory, settings.SpeechOutputModule = value.Server, value.Module
settings.HardwareSpeechDevice, settings.HardwareSpeechBaudRate = value.Device, value.Baud
settings.DefaultVoice, settings.UppercaseVoice = value.DefaultVoice, value.UppercaseVoice
settings.HyperlinkVoice, settings.SystemVoice = value.HyperlinkVoice, value.SystemVoice
settings.RaisePitchForCapitals, settings.BeepForCapitals, settings.SayCapForCapitals = value.RaisePitchForCapitals, value.BeepForCapitals, value.SayCapForCapitals
settings.VerbalizePunctuationStyle = value.Punctuation
settings.UseCustomEchoVoice, settings.UseCustomEchoSpeechServer = value.CustomEcho, value.CustomEchoServer
settings.EchoSpeechServer, settings.EchoVoice = value.EchoServer, value.EchoVoice
settings.UseCustomEchoForKey, settings.UseCustomEchoForCharacter = value.EchoForKey, value.EchoForCharacter
settings.UseCustomEchoForWord, settings.UseCustomEchoForSentence = value.EchoForWord, value.EchoForSentence
return settings
}
type PreferencePage struct {
ID string `json:"id"`
Title string `json:"title"`
Command string `json:"command"`
Load bool `json:"load,omitempty"`
Elements []interaction.Element `json:"elements"`
Values map[string]interaction.Value `json:"values"`
}
type QuickSetting struct {
ID string `json:"id"`
Label string `json:"label"`
Command string `json:"command"`
Options []interaction.Item `json:"options"`
Selected string `json:"selected"`
}
// Extensions are copied at registration/update. IDs are local to one session.
type Extensions struct {
Pages []PreferencePage `json:"pages,omitempty"`
QuickSettings []QuickSetting `json:"quick_settings,omitempty"`
}
// ReaderRequest is serviced on the settings worker. Snapshot returns a copied
// state to Next; Module optionally requests that module's catalog. Mutations
// are atomic within this request and never persist the active speech override.
type ReaderRequest struct {
Snapshot bool `json:"snapshot,omitempty"`
Module string `json:"module,omitempty"`
Speech *SpeechConfiguration `json:"speech_configuration,omitempty"`
RestoreSpeech bool `json:"restore_speech,omitempty"`
Extensions *Extensions `json:"extensions,omitempty"`
}
type ReaderState struct {
Speech SpeechConfiguration `json:"speech_configuration"`
DefaultSpeech SpeechConfiguration `json:"default_speech_configuration"`
Modules []string `json:"modules,omitempty"`
Voices []speech.Voice `json:"voices,omitempty"`
CatalogTruncated bool `json:"catalog_truncated,omitempty"`
Extensions Extensions `json:"extensions"`
}
// ExtensionEvent handlers propose effects only. The host commits page values
// and quick selections after validating all proposals and applying core settings.
type ExtensionEvent struct {
Event string `json:"event"`
ID string `json:"id"`
Values map[string]interaction.Value `json:"values,omitempty"`
Selected string `json:"selected,omitempty"`
Reader ReaderState `json:"reader"`
}
func valid_label(value string) bool {
return strings.TrimSpace(value) != "" && len(value) <= 256 && validFormText(value)
}
func ValidExtensionID(value string) bool {
if len(value) == 0 || len(value) > 64 {
return false
}
for _, character := range value {
if !(character >= 'a' && character <= 'z' || character >= '0' && character <= '9' || character == '-' || character == '.') {
return false
}
}
return true
}
func ValidateExtensions(value Extensions) error {
if len(value.Pages) > 4 || len(value.QuickSettings) > 8 {
return ErrLimit
}
ids := map[string]bool{}
for _, page := range value.Pages {
if !ValidExtensionID(page.ID) || ids[page.ID] || !valid_label(page.Title) || !ValidExtensionID(page.Command) {
return ErrProtocol
}
ids[page.ID] = true
if err := interaction.ValidatePreferences(interaction.Form{Title: page.Title, Elements: page.Elements}); err != nil {
return ErrProtocol
}
for _, element := range page.Elements {
if !ValidExtensionID(element.ID) || !valid_label(element.Label) || element.Kind == interaction.Button {
return ErrProtocol
}
for _, item := range element.Items {
if !ValidExtensionID(item.ID) || !valid_label(item.Label) {
return ErrProtocol
}
}
}
if err := ValidatePageValues(page, page.Values); err != nil {
return err
}
}
clear(ids)
for _, setting := range value.QuickSettings {
if !ValidExtensionID(setting.ID) || ids[setting.ID] || !valid_label(setting.Label) || !ValidExtensionID(setting.Command) || len(setting.Options) == 0 || len(setting.Options) > 64 {
return ErrProtocol
}
ids[setting.ID] = true
options := map[string]bool{}
selected := false
for _, option := range setting.Options {
if !ValidExtensionID(option.ID) || options[option.ID] || !valid_label(option.Label) {
return ErrProtocol
}
options[option.ID] = true
selected = selected || option.ID == setting.Selected && !option.Disabled
}
if !selected {
return ErrProtocol
}
}
data, err := json.Marshal(value)
if err != nil || len(data) > 32*1024 {
return ErrLimit
}
return nil
}
func ValidatePageValues(page PreferencePage, values map[string]interaction.Value) error {
if len(values) != len(page.Elements) {
return errors.New("page values do not match its controls")
}
for _, element := range page.Elements {
if element.Secret {
return ErrProtocol
}
value, found := values[element.ID]
if !found {
return ErrProtocol
}
switch element.Kind {
case interaction.Entry:
limit := element.MaxLength
if limit == 0 {
limit = 8192
}
if !validFormText(value.Text) || len([]rune(value.Text)) > limit || value.Selected != "" || value.Checked || element.Required && strings.TrimSpace(value.Text) == "" {
return ErrProtocol
}
case interaction.CheckBox:
if value.Text != "" || value.Selected != "" {
return ErrProtocol
}
case interaction.List:
valid := false
for _, item := range element.Items {
valid = valid || item.ID == value.Selected && !item.Disabled
}
if !valid || value.Text != "" || value.Checked {
return ErrProtocol
}
default:
return ErrProtocol
}
}
return nil
}
func ValidateReaderRequest(value ReaderRequest) error {
if value.Snapshot && (value.Speech != nil || value.RestoreSpeech || value.Extensions != nil) || value.Speech != nil && value.RestoreSpeech || value.Module != "" && !value.Snapshot || len(value.Module) > 256 || !validFormText(value.Module) {
return ErrProtocol
}
if !value.Snapshot && value.Speech == nil && !value.RestoreSpeech && value.Extensions == nil {
return ErrProtocol
}
if value.Speech != nil {
if err := config.Validate(value.Speech.Apply(config.DefaultSettings())); err != nil {
return err
}
}
if value.Extensions != nil {
return ValidateExtensions(*value.Extensions)
}
return nil
}