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.
562 lines
16 KiB
Go
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}
|
|
}
|