425 lines
14 KiB
Go
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])
|
|
}
|
|
}
|