Files

425 lines
14 KiB
Go

// Cthulhu screen reader.
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
//
// This program is free software: you can redistribute it and/or modify it
// under the terms of the GNU General Public License as published by the Free
// Software Foundation, either version 3 of the License, or (at your option)
// any later version.
//
// This program is distributed in the hope that it will be useful, but WITHOUT
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
// more details.
//
// You should have received a copy of the GNU General Public License along with
// this program. If not, see <https://www.gnu.org/licenses/gpl-3.0.html>.
package atspi
import (
"strings"
"time"
"unicode"
"unicode/utf8"
"git.stormux.org/storm/cthulhu/internal/webscripts"
)
const maxSnapshotFieldLength = 4096
type Snapshot struct {
TextAttributeRun TextAttributeRun
TextAttributeRuns []TextAttributeRun
Mnemonic string
Misspelled bool
SpeechContext []Snapshot // Bounded copied ancestor metadata, without native handles.
BrailleContext []Snapshot // Nearest ancestor first, including window/application.
Spreadsheet bool
LibreOffice *LibreOfficeContext
// BrowserTab identifies browser chrome, excluding page-authored tab widgets.
BrowserTab bool
// ExplicitName distinguishes an author-supplied link label from a name
// derived from descendants. Only the former makes a link one browse stop.
ExplicitName bool
// Autocomplete describes a single-line completion entry. Popup ownership
// comes from the native POPUP_FOR relation, not a control's name.
Autocomplete, AutocompletePopup bool
SupportsClick bool
LiveRegion bool
LivePoliteness string
LiveAtomic bool
LayoutTable bool
// ChildCount is copied only for list containers. A failed provider query
// leaves ChildCountKnown false so zero is not mistaken for an empty list.
ChildCount int
ChildCountKnown bool
TableRowCount int
TableColumnCount int
TableName string
TablePath string
TableRowIndex int
TableColumnIndex int
TablePositionKnown bool
TableRowSpan, TableColumnSpan int
TableRowHeaders, TableColumnHeaders string
TableRowText string
GUITable bool
CharacterCount int
EmbeddedObjectCount int
// Selection offsets are captured for text-bearing focused events.
SelectionStart, SelectionEnd int
SelectionText string
CompletionPrefixCharacter string
// CaretWord is captured only for caret movement, not tree traversal.
CaretWord string
// StatesKnown reports whether native capture returned a state set.
// A missing set must not be interpreted as known non-focusable content.
Visible, Showing bool
StatesKnown bool
Active bool
Focusable bool
Focused bool
HasPopup bool
HeadingLevel int
Checked bool
Defunct bool
Editable bool
Expandable bool
Expanded bool
Indeterminate bool
Invalid bool
Multiselectable bool
ReadOnly bool
Required bool
Selected bool
Sensitive bool
SensitiveKnown bool
ValueKnown bool
Visited bool
CurrentValue float64
MaximumValue float64
MinimumValue float64
MinimumIncrement float64
BusName string
Path string
Name string
Role string
RadioGroupName string
Description string
Text string
// BrailleLine preserves the captured line's spacing; Text is normalized
// for speech and cannot locate a cursor after repeated spaces.
BrailleLine string
// Absolute Unicode offsets, before speech normalization.
BrailleLineStart int
BrailleCaret int
CaretCharacter string
TextBeforeCaret string
TextCaretKnown bool
ValueText string
ApplicationName string
// ApplicationProcess is resolved from the provider PID, not page content.
ApplicationProcess string
// ToolkitName is captured for name-change events to select web policy
// even before document focus or an address-bar event has arrived.
ToolkitName string
XMLRoles string
HTMLTag string
ElementID string
// PositionInSet is one-based; zero means unavailable. SetSize must be
// positive and at least PositionInSet before presentation uses it.
PositionInSet, SetSize int
// SoundGeometry contains immutable screen geometry copied on the AT-SPI
// event-loop thread. Presentation and audio workers never retain or query
// the native accessibles from which it was derived.
SoundGeometry SoundGeometry
}
// TextAttributeRun is the native attribute range at the caret, copied without
// retaining an AT-SPI object. Offsets are Unicode character offsets.
type TextAttributeRun struct {
Start, End int
Values map[string]string
}
func captureBrailleLine(snapshot *Snapshot, text, beforeCaret string, start int) {
snapshot.BrailleLine = sanitizeTextContext(text)
// sanitizeTextContext retains a bounded suffix. Retain its source origin,
// and hide a caret which is outside that suffix rather than misrouting it.
snapshot.BrailleLineStart = start + utf8.RuneCountInString(text) - utf8.RuneCountInString(snapshot.BrailleLine)
snapshot.BrailleCaret = start + utf8.RuneCountInString(beforeCaret)
}
type SoundGeometry struct {
Object, Reference Rectangle
ObjectKnown bool
ReferenceKnown bool
}
// ObjectID identifies one accessible for the lifetime of its AT-SPI bus
// connection. Object paths alone are not unique because different
// applications can publish the same path.
type ObjectID struct {
BusName string
Path string
}
func NewObjectID(busName, path string) ObjectID {
return ObjectID{
BusName: sanitizeSnapshotField(busName),
Path: sanitizeSnapshotField(path),
}
}
func (id ObjectID) Valid() bool {
return id.BusName != "" && id.Path != "" &&
validBusName(id.BusName) && validObjectPath(id.Path) &&
id == NewObjectID(id.BusName, id.Path)
}
func validBusName(name string) bool {
if len(name) > 255 {
return false
}
unique := strings.HasPrefix(name, ":")
if unique {
name = strings.TrimPrefix(name, ":")
}
parts := strings.Split(name, ".")
if len(parts) < 2 {
return false
}
for _, part := range parts {
if part == "" {
return false
}
for index, character := range []byte(part) {
letter := character >= 'A' && character <= 'Z' || character >= 'a' && character <= 'z'
digit := character >= '0' && character <= '9'
if !letter && !digit && character != '_' && character != '-' {
return false
}
if index == 0 && !unique && digit {
return false
}
}
}
return true
}
func validObjectPath(path string) bool {
if path == "/" {
return true
}
if !strings.HasPrefix(path, "/") || strings.HasSuffix(path, "/") {
return false
}
for _, part := range strings.Split(path[1:], "/") {
if part == "" {
return false
}
for _, character := range []byte(part) {
letter := character >= 'A' && character <= 'Z' || character >= 'a' && character <= 'z'
digit := character >= '0' && character <= '9'
if !letter && !digit && character != '_' {
return false
}
}
}
return true
}
func (snapshot Snapshot) ID() ObjectID {
return ObjectID{BusName: snapshot.BusName, Path: snapshot.Path}
}
type Event struct {
// WebNotification is selected on the native owner before background-event
// filtering. It contains copied content and no retained native references.
WebNotification *webscripts.Notification
Foreground ForegroundState
ForegroundMismatch bool
// ForegroundCheck contains numeric, text-free diagnostics for rejected
// window activations. Other events leave it empty.
ForegroundCheck ForegroundCheck
// PopupFor is the autocomplete entry named by the popup's AT-SPI relation.
PopupFor ObjectID
// FocusOutsideDocument is a freshly verified chrome focus at load
// completion. A page loading behind it must not acquire browse keys.
FocusOutsideDocument ObjectID
// DocumentFragment is the URL fragment of a completed web document load.
DocumentFragment string
// Window identifies the nearest dialog/window/frame which owns Source.
// It is captured from the original event source before activation may be
// redirected to a focused descendant.
Window ObjectID
// WindowRecovered reports an owned frame restored after a browser tab
// temporarily detached its document's AT-SPI parent chain.
WindowRecovered bool
// WindowTitle is the top-level AT-SPI name captured on activation, even
// when Source is the focused descendant.
WindowTitle string
// WindowDescription is the top-level accessible description on activation.
WindowDescription string
// WindowMessage is a simple frame prompt recovered when its first button
// takes focus after activation arrived before the label was available.
WindowMessage string
NavigationEcho bool
SnapshotDuration time.Duration
Type string
// NativeType retains an AT-SPI text-change origin suffix for debug logs.
// Presentation policies use the canonical Type.
NativeType string
Source Snapshot
OriginPath string
Payload string
Detail1 int
}
type ForegroundCheck struct {
Reason string
AccessiblePID int
Active bool
Showing bool
Iconified bool
Bounds Rectangle
}
func NewSnapshot(path, name, role, description, text string) Snapshot {
return NewSnapshotWithCaretCharacter(path, name, role, description, text, "")
}
func NewSnapshotWithCaretCharacter(path, name, role, description, text, caretCharacter string) Snapshot {
return Snapshot{
Path: sanitizeSnapshotField(path),
Name: sanitizeSnapshotField(name),
Role: sanitizeSnapshotField(role),
Description: sanitizeSnapshotField(description),
Text: sanitizeSnapshotField(text),
CaretCharacter: sanitizeCaretCharacter(caretCharacter),
}
}
func NewEvent(eventType string, source Snapshot) Event {
eventType = sanitizeSnapshotField(eventType)
event := Event{Type: canonicalTextChangeEventType(eventType), Source: source, OriginPath: source.Path}
if event.Type != eventType {
event.NativeType = eventType
}
return event
}
// AT-SPI appends an origin to some text mutations (for example, :system).
// The reader handles those mutations by kind; leaving the suffix on Type
// makes them fall through as ordinary object presentation events.
func canonicalTextChangeEventType(eventType string) string {
for _, kind := range []string{TextInsertedEvent, TextDeletedEvent} {
if eventType == kind || strings.HasPrefix(eventType, kind+":") {
return kind
}
}
return eventType
}
func isBrowserChromeTab(snapshot Snapshot, inDocument bool) bool {
if snapshot.Role != "page tab" || inDocument {
return false
}
switch strings.ToLower(snapshot.ToolkitName) {
case "chromium", "gecko", "webkit", "webkitgtk":
return true
}
return false
}
// Firefox exposes its address bar as an editable popup combo box without the
// supports-autocompletion state used by Chromium. Keep the fallback in chrome:
// page controls with the same role still use their own document behavior.
func isFirefoxChromeAutocomplete(snapshot Snapshot, inDocument bool) bool {
return !inDocument && strings.EqualFold(snapshot.ApplicationProcess, "firefox") &&
snapshot.Role == "combo box" && snapshot.Editable && !snapshot.ReadOnly && snapshot.HasPopup
}
func NewManagedDescendantEvent(eventType, originPath string, source Snapshot) Event {
event := NewEvent(eventType, source)
event.OriginPath = sanitizeSnapshotField(originPath)
return event
}
func NewEventWithPayload(eventType string, source Snapshot, payload string) Event {
event := NewEvent(eventType, source)
event.Payload = sanitizeTextPayload(payload)
return event
}
func sanitizeTextPayload(value string) string {
// Preserve single-character delimiters so typing-echo policy can
// distinguish space, tab, and line boundaries. Other snapshot fields
// continue to trim surrounding whitespace.
switch value {
case " ", "\t", "\n":
return value
case "\r", "\r\n":
return "\n"
}
return sanitizeSnapshotField(value)
}
func sanitizeTextContext(value string) string {
value = strings.ToValidUTF8(value, "�")
value = strings.Map(func(character rune) rune {
if character == '\r' {
return '\n'
}
if character != '\n' && character != '\t' && unicode.IsControl(character) {
return '�'
}
return character
}, value)
if len(value) > maxSnapshotFieldLength {
value = value[len(value)-maxSnapshotFieldLength:]
for !utf8.ValidString(value) {
value = value[1:]
}
}
return value
}
func sanitizeSnapshotField(value string) string {
value = strings.ToValidUTF8(value, "�")
value = strings.Map(func(character rune) rune {
if character == '\n' || character == '\r' || character == '\t' {
return ' '
}
return character
}, value)
value = strings.Join(strings.Fields(value), " ")
value = strings.TrimSpace(value)
if len(value) > maxSnapshotFieldLength {
value = value[:maxSnapshotFieldLength]
for !utf8.ValidString(value) {
value = value[:len(value)-1]
}
}
return value
}
func sanitizeCaretCharacter(value string) string {
value = strings.ToValidUTF8(value, "�")
characters := []rune(value)
if len(characters) == 0 {
return ""
}
switch characters[0] {
case '\n', '\r':
return "blank"
default:
return string(characters[0])
}
}