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

562 lines
16 KiB
Go

// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
// Package interaction implements focus-free controls. It owns no keyboard,
// accessibility objects, output services or callbacks; the reader owns those.
package interaction
import (
"errors"
"fmt"
"strings"
"unicode"
"unicode/utf8"
)
type Kind uint8
const (
List Kind = iota + 1
Entry
CheckBox
Button
)
type Item struct {
ID string `json:"id"`
Label string `json:"label"`
Disabled bool `json:"disabled,omitempty"`
}
type Element struct {
ID string `json:"id"`
Label string `json:"label"`
Kind Kind `json:"kind"`
Items []Item `json:"items,omitempty"`
Text string `json:"text,omitempty"`
Checked bool `json:"checked,omitempty"`
Disabled bool `json:"disabled,omitempty"`
Required bool `json:"required,omitempty"`
MaxLength int `json:"max_length,omitempty"`
// Secret is supported only on transient entries, never preference pages.
Secret bool `json:"secret,omitempty"`
}
type Form struct {
Title string `json:"title"`
Elements []Element `json:"elements"`
}
type Key uint8
const (
Text Key = iota
Up
Down
Left
Right
Home
End
PageUp
PageDown
Tab
Enter
Escape
Backspace
Delete
)
type Event struct {
Key Key
Text string
Shift, Control, Alt bool
}
type Presentation struct {
Speech, Braille string
Cursor int
}
// Effect describes a host operation. Activation does not close the form: the
// caller can validate, replace it with another form, or finish the interaction.
type Effect struct {
Presentation
Activated string
Cancelled, Paste bool
Copy *string
}
type Value struct {
Text string `json:"text,omitempty"`
Selected string `json:"selected,omitempty"`
Checked bool `json:"checked,omitempty"`
}
type elementState struct {
element Element
selected, caret, anchor int
prefix string
}
type Session struct {
grid *gridSession
title string
elements []elementState
focus int
closed bool
}
const maxTextLength = 8192
func NewSession(form Form) (*Session, error) {
return new_session(form, 256, 64)
}
// ValidatePreferences shares control validation with internal forms, while
// allowing a native, scrolling page to contain several full voice editors.
func ValidatePreferences(form Form) error {
for _, element := range form.Elements {
if element.Secret {
return errors.New("secret entries are not supported in preferences")
}
}
_, err := new_session(form, 256, 256)
return err
}
// NewNavigationSession is for core document lists. External forms retain
// their smaller protocol limit. The selected row follows the reading caret.
func NewNavigationSession(title string, items []Item, selected int) (*Session, error) {
session, err := new_session(Form{Title: title, Elements: []Element{{ID: "items", Label: title, Kind: List, Items: items}}}, 65536, 64)
if err == nil && selected >= 0 && selected < len(items) && !items[selected].Disabled {
session.elements[0].selected = selected
}
return session, err
}
func new_session(form Form, maxItems, maxElements int) (*Session, error) {
if form.Title == "" || len(form.Title) > 1024 || len(form.Elements) == 0 || len(form.Elements) > maxElements {
return nil, fmt.Errorf("a titled form with 1 to %d controls is required", maxElements)
}
session := &Session{title: form.Title, focus: -1}
ids := map[string]bool{}
for _, element := range form.Elements {
if element.ID == "" || len(element.ID) > 1024 || element.Label == "" || len(element.Label) > 1024 || ids[element.ID] || element.Kind < List || element.Kind > Button {
return nil, errors.New("controls need unique IDs, labels and supported kinds")
}
ids[element.ID] = true
if element.Secret && element.Kind != Entry {
return nil, errors.New("only entries may be secret")
}
if element.MaxLength == 0 {
element.MaxLength = maxTextLength
}
if element.MaxLength < 1 || element.MaxLength > maxTextLength || len([]rune(element.Text)) > element.MaxLength {
return nil, errors.New("invalid entry length")
}
if !utf8.ValidString(element.Text) || strings.ContainsAny(element.Text, "\r\n\x00") {
return nil, errors.New("entries must contain single-line text")
}
for _, character := range element.Text {
if unicode.IsControl(character) {
return nil, errors.New("entries must contain single-line text")
}
}
element.Items = append([]Item(nil), element.Items...)
state := elementState{element: element, selected: -1, caret: len([]rune(element.Text)), anchor: len([]rune(element.Text))}
itemIDs := map[string]bool{}
if len(element.Items) > maxItems {
return nil, errors.New("too many list items")
}
for index, item := range element.Items {
if item.ID == "" || len(item.ID) > 1024 || item.Label == "" || len(item.Label) > 1024 || itemIDs[item.ID] {
return nil, errors.New("list items need unique IDs and labels")
}
itemIDs[item.ID] = true
if state.selected < 0 && !item.Disabled {
state.selected = index
}
}
if element.Kind == List && state.selected < 0 {
state.element.Disabled = true
}
session.elements = append(session.elements, state)
if session.focus < 0 && !state.element.Disabled {
session.focus = len(session.elements) - 1
}
}
if session.focus < 0 {
return nil, errors.New("form has no enabled controls")
}
return session, nil
}
func (session *Session) Presentation() Presentation {
if session.grid != nil {
return session.grid.presentation()
}
if session.closed {
return Presentation{}
}
state := &session.elements[session.focus]
element := state.element
text := element.Label
cursor := -1
switch element.Kind {
case List:
text += fmt.Sprintf(": %s, %d of %d", element.Items[state.selected].Label, state.selected+1, len(element.Items))
case Entry:
prefix, value := ", edit: ", element.Text
if element.Secret {
prefix, value = ", password edit: ", strings.Repeat("*", utf8.RuneCountInString(element.Text))
}
text += prefix + value
cursor = len([]rune(element.Label+prefix)) + state.caret
if state.caret != state.anchor {
text += fmt.Sprintf(" (selected %d characters)", abs(state.caret-state.anchor))
}
case CheckBox:
if element.Checked {
text += ", checked"
} else {
text += ", unchecked"
}
case Button:
text += ", button"
}
speech := text
if element.Secret {
speech = element.Label + ", password edit, " + characterCount(utf8.RuneCountInString(element.Text))
}
if element.Kind == List {
speech = element.Items[state.selected].Label
}
return Presentation{Speech: speech, Braille: text, Cursor: cursor}
}
func (session *Session) Opening() Presentation {
if session.grid != nil {
output := session.grid.presentation()
output.Speech = session.title + ". " + output.Speech
return output
}
output := session.Presentation()
if !session.closed && len(session.elements) > 1 && session.elements[session.focus].element.Kind == List {
output.Speech = session.elements[session.focus].element.Label + ". " + output.Speech
}
output.Speech = session.title + ". " + output.Speech
return output
}
// Values includes plaintext secrets for the submitting caller. It must never
// be used for presentation, logging or generic settings persistence.
func (session *Session) Values() map[string]Value {
if session.closed {
return nil
}
if session.grid != nil {
return session.grid.values()
}
values := make(map[string]Value, len(session.elements))
for _, state := range session.elements {
value := Value{Text: state.element.Text, Checked: state.element.Checked}
if state.element.Kind == List && state.selected >= 0 {
value.Selected = state.element.Items[state.selected].ID
}
values[state.element.ID] = value
}
return values
}
// Close drops the editor's references to secrets on submission, cancellation
// or capture revocation. Go strings do not provide guaranteed memory erasure.
func (session *Session) Close() {
session.closed = true
for index := range session.elements {
if session.elements[index].element.Secret {
session.elements[index].element.Text = ""
}
}
}
func (session *Session) Handle(event Event) Effect {
if session.grid != nil {
return session.grid.handle(event)
}
if session.closed {
return Effect{}
}
if event.Key == Escape {
session.Close()
return Effect{Cancelled: true}
}
if event.Key == Tab {
step := 1
if event.Shift {
step = -1
}
for range len(session.elements) {
session.focus = (session.focus + step + len(session.elements)) % len(session.elements)
if !session.elements[session.focus].element.Disabled {
break
}
}
output := session.Presentation()
// A list in a multi-control form needs its field label when Tab enters
// it. A menu's only control uses its opening title instead.
state := &session.elements[session.focus]
if len(session.elements) > 1 && state.element.Kind == List {
output.Speech = state.element.Label + ". " + output.Speech
}
return Effect{Presentation: output}
}
state := &session.elements[session.focus]
if event.Alt {
return Effect{}
}
if event.Key == Enter || state.element.Kind == Button && event.Key == Text && event.Text == " " && !event.Control {
for _, candidate := range session.elements {
if candidate.element.Kind == Entry && candidate.element.Required && !candidate.element.Disabled && strings.TrimSpace(candidate.element.Text) == "" {
return Effect{Presentation: Presentation{Speech: candidate.element.Label + " is required", Braille: candidate.element.Label + " is required", Cursor: -1}}
}
}
id := state.element.ID
if state.element.Kind == List {
id = state.element.Items[state.selected].ID
}
return Effect{Activated: id}
}
switch state.element.Kind {
case List:
step := 0
switch event.Key {
case Up:
step = -1
case Down:
step = 1
case PageUp:
step = -10
case PageDown:
step = 10
case Home:
state.selected = 0
step = 1
case End:
state.selected = len(state.element.Items) - 1
step = -1
case Text:
if event.Control || event.Text == "" {
return Effect{}
}
prefix := state.prefix + strings.ToLower(event.Text)
if !state.find_prefix(prefix) {
prefix = strings.ToLower(event.Text)
state.find_prefix(prefix)
}
state.prefix = prefix
return Effect{Presentation: session.Presentation()}
default:
return Effect{}
}
state.prefix = ""
if event.Key == Home || event.Key == End {
for state.element.Items[state.selected].Disabled {
state.selected += step
}
} else {
for count := 0; count < abs(step); count++ {
previous := state.selected
for next := previous + sign(step); next >= 0 && next < len(state.element.Items); next += sign(step) {
if !state.element.Items[next].Disabled {
state.selected = next
break
}
}
}
}
case CheckBox:
if event.Key != Text || event.Text != " " || event.Control {
return Effect{}
}
state.element.Checked = !state.element.Checked
case Button:
return Effect{}
case Entry:
return session.edit(event)
}
return Effect{Presentation: session.Presentation()}
}
func (state *elementState) find_prefix(prefix string) bool {
for offset := 1; offset <= len(state.element.Items); offset++ {
index := (state.selected + offset) % len(state.element.Items)
item := state.element.Items[index]
if !item.Disabled && strings.HasPrefix(strings.ToLower(item.Label), prefix) {
state.selected = index
return true
}
}
return false
}
func sign(value int) int {
if value < 0 {
return -1
}
return 1
}
func abs(value int) int {
if value < 0 {
return -value
}
return value
}
func characterCount(count int) string {
if count == 1 {
return "1 character"
}
return fmt.Sprintf("%d characters", count)
}
func (session *Session) edit(event Event) Effect {
state := &session.elements[session.focus]
characters := []rune(state.element.Text)
start, end := min(state.caret, state.anchor), max(state.caret, state.anchor)
if event.Control && event.Key == Text {
switch strings.ToLower(event.Text) {
case "a":
state.anchor, state.caret = 0, len(characters)
output := session.Presentation()
output.Speech = "Selected all"
return Effect{Presentation: output}
case "v":
return Effect{Paste: true}
case "c", "x":
if state.element.Secret {
output := session.Presentation()
output.Speech = "Copy and cut are unavailable for password fields"
return Effect{Presentation: output}
}
if start == end {
return Effect{}
}
text := string(characters[start:end])
// Cutting is completed by the host only after clipboard write succeeds.
return Effect{Copy: &text}
default:
return Effect{}
}
}
if event.Key == Left || event.Key == Right || event.Key == Home || event.Key == End {
next := state.caret
switch event.Key {
case Left:
if !event.Shift && start != end {
next = start
} else {
next = max(0, next-1)
if event.Control {
for next > 0 && unicode.IsSpace(characters[next]) {
next--
}
for next > 0 && !unicode.IsSpace(characters[next-1]) {
next--
}
}
}
case Right:
if !event.Shift && start != end {
next = end
} else {
next = min(len(characters), next+1)
if event.Control {
for next < len(characters) && !unicode.IsSpace(characters[next]) {
next++
}
for next < len(characters) && unicode.IsSpace(characters[next]) {
next++
}
}
}
case Home:
next = 0
case End:
next = len(characters)
}
state.caret = next
if !event.Shift {
state.anchor = next
}
output := session.Presentation()
if next == len(characters) {
output.Speech = "End of text"
} else if state.element.Secret {
output.Speech = "Hidden character"
} else {
output.Speech = string(characters[next])
}
if event.Shift {
output.Speech += ", selected"
}
return Effect{Presentation: output}
}
if event.Key == Backspace || event.Key == Delete {
if start == end {
if event.Key == Backspace {
start = max(0, start-1)
} else {
end = min(len(characters), end+1)
}
}
if start == end {
return Effect{}
}
removed := string(characters[start:end])
state.element.Text = string(append(characters[:start], characters[end:]...))
state.caret, state.anchor = start, start
output := session.Presentation()
if state.element.Secret {
output.Speech = "Deleted " + characterCount(end-start)
} else {
output.Speech = "Deleted " + removed
}
return Effect{Presentation: output}
}
if event.Key == Text && event.Text != "" && !event.Control {
return session.Insert(event.Text)
}
return Effect{}
}
// Insert handles typing and paste atomically; invalid/oversized input leaves
// the field and selection untouched.
func (session *Session) Insert(text string) Effect {
if session.grid != nil {
return Effect{}
}
if session.closed {
return Effect{}
}
state := &session.elements[session.focus]
if state.element.Kind != Entry {
return Effect{}
}
if !utf8.ValidString(text) {
return Effect{Presentation: Presentation{Speech: "Invalid text", Braille: "Invalid text", Cursor: -1}}
}
characters := []rune(state.element.Text)
start, end := min(state.caret, state.anchor), max(state.caret, state.anchor)
inserted := []rune(text)
for _, character := range inserted {
if unicode.IsControl(character) {
return Effect{Presentation: Presentation{Speech: "Only single-line text is accepted", Braille: "Only single-line text is accepted", Cursor: -1}}
}
}
if len(characters)-(end-start)+len(inserted) > state.element.MaxLength {
return Effect{Presentation: Presentation{Speech: "Text limit reached", Braille: "Text limit reached", Cursor: -1}}
}
state.element.Text = string(characters[:start]) + text + string(characters[end:])
state.caret, state.anchor = start+len(inserted), start+len(inserted)
output := session.Presentation()
if state.element.Secret {
output.Speech = "Inserted " + characterCount(len(inserted))
} else {
output.Speech = text
}
return Effect{Presentation: output}
}