Bring the fragmented-line browser fixture into agreement with current browse navigation. Rich editors are read through their text descendants, so Down from the preceding heading must read "Review the code." directly. Remove the stale empty editor-control stop while retaining forward and reverse line navigation, character and word movement, Read All, and inline Braille-routing assertions. Fix Firefox's missing announcement when creating a second tab. Firefox can emit selection events before the new tab enters its accessible child list, causing the native selected-child lookup to return nothing. Use the subsequent child-added event only when its target is a live, freshly selected browser-chrome tab with the emitting tab list as its direct parent. Keep document-authored tabs and unselected background additions excluded, retain foreground filtering and tab-identity deduplication, and release native references on the GLib owner. Normalize child-added origin suffixes such as :system before event dispatch, preserving the original native type in diagnostics. Rename the shared mutation normalizer to reflect its expanded scope and update its callers, including the Pidgin insertion check. Extend the normalization regression to cover child additions and reject unrelated event names. Firefox keeps its normal animations and all existing tab-announcement and address-editing assertions. Make private-session teardown tolerate the document portal's asynchronous FUSE unmount after D-Bus exits. Retry only ENOTCONN and EBUSY cleanup failures for at most five seconds. Keep unrelated errors and persistent mount failures visible. Add regressions for transient teardown, preservation of a failing child's exit status, unrelated cleanup errors, and the cleanup deadline. Runtime directories and accessibility buses remain private, with no init-system dependency. Update browser verification notes and the parity ledger with the fixes, evidence, regression risks, and concrete live acceptance procedures. Validation: - All 33 Chromium browser proofs passed in the final combined run. - Firefox baseline and tab checks passed repeatedly with normal animations. - All eight private-session isolation and cleanup regressions passed. - AT-SPI, app, and review package race suites passed during development; focused race regressions passed again after the final event-normalization change. - Go vet passed for internal/atspi, internal/app, and internal/review. - A separate reader binary built successfully. - Parity-ledger validation passed for 75 rows; git diff --check passed.
471 lines
16 KiB
Go
471 lines
16 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/messaging"
|
|
"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
|
|
// TextChildren maps embedded-object characters to native child offsets.
|
|
// Captured for hypertext containers; nil leaves ordinary leaf traversal.
|
|
TextChildren []TextChild
|
|
// 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
|
|
Checkable bool
|
|
Checked bool
|
|
Defunct bool
|
|
Editable bool
|
|
// EditableRoot identifies the nearest focusable writable editor or editable
|
|
// web document. Paragraph events can belong to its native focus.
|
|
EditableRoot ObjectID
|
|
// ReadOnlyCombo identifies the direct combo parent of a read-only text
|
|
// child. Its caret changes mirror selection; the combo owns presentation.
|
|
ReadOnlyCombo ObjectID
|
|
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
|
|
// ApplicationPID identifies owned preferences controls during voice previews.
|
|
ApplicationPID int
|
|
// 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
|
|
}
|
|
|
|
// ControlRole includes checkable menu semantics even when a provider exposes
|
|
// the ordinary menu-item role. Checked and mixed states also establish this
|
|
// capability when the provider omits STATE_CHECKABLE.
|
|
func (snapshot Snapshot) ControlRole() string {
|
|
if snapshot.Role == "menu item" && (snapshot.Checkable || snapshot.Checked || snapshot.Indeterminate) {
|
|
return "check menu item"
|
|
}
|
|
return snapshot.Role
|
|
}
|
|
|
|
// TextChild is a single embedded-object character in an owned text container.
|
|
// Its offset belongs to the parent; Child retains no native handle.
|
|
type TextChild struct {
|
|
Offset int
|
|
Child ObjectID
|
|
}
|
|
|
|
// 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 {
|
|
// ChatMessage is copied by a verified application adapter before routine
|
|
// background filtering. It never transfers focus or retains a native object.
|
|
ChatMessage *messaging.Message
|
|
// 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 the original AT-SPI type when an event is normalized.
|
|
// Presentation policies use the canonical Type.
|
|
NativeType string
|
|
Source Snapshot
|
|
OriginPath string
|
|
Payload string
|
|
Detail1 int
|
|
// TextChangeLength and TextChangeEndsLine describe the original payload,
|
|
// before speech normalization collapses whitespace and limits its length.
|
|
TextChangeLength int
|
|
TextChangeEndsLine bool
|
|
}
|
|
|
|
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)
|
|
// A checked-state transition proves checkability even when the provider
|
|
// omits STATE_CHECKABLE and the new checked value is false.
|
|
if eventType == "object:state-changed:checked" && source.Role == "menu item" {
|
|
source.Checkable = true
|
|
}
|
|
event := Event{Type: canonicalObjectChangeEventType(eventType), Source: source, OriginPath: source.Path}
|
|
if event.Type != eventType {
|
|
event.NativeType = eventType
|
|
}
|
|
return event
|
|
}
|
|
|
|
// AT-SPI appends an origin to some object 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 canonicalObjectChangeEventType(eventType string) string {
|
|
for _, kind := range []string{TextInsertedEvent, TextDeletedEvent, ChildrenAddedEvent} {
|
|
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)
|
|
if event.Type == TextInsertedEvent || event.Type == TextDeletedEvent {
|
|
event.TextChangeLength = utf8.RuneCountInString(payload)
|
|
event.TextChangeEndsLine = strings.HasSuffix(payload, "\n")
|
|
}
|
|
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])
|
|
}
|
|
}
|