// 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 . 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 // 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 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 } // 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 { // 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]) } }