Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# CONTEXT — Mozilla Apps Enhancements

Domain glossary for this NVDA add-on. The add-on reads Firefox and Thunderbird
UI by walking their IAccessible2 (a11y) trees. Those trees are **not** a stable
interface — Mozilla restructures them between releases — so the recurring domain
problem is "navigate an a11y subtree whose shape changed."

Use these terms (not synonyms) in code, tests, issues, and proposals.

## Glossary

### address field
The element in Firefox's toolbar that bears the page URL — the `#urlbar-input`
combobox. "Find the address field" is a single piece of version/DOM knowledge
with one home: `addressField(foreground, ffVersion)` in
`addon/appModules/firefox.py` (with `getURL()` as a thin reader over it). Both
`script_url` (the NVDA+A "read address bar" command) and `event_alert` (which
tags notifications with the page domain) locate it through that function — they
must not re-encode the path themselves.

From Firefox 133 on, the address field is located resiliently: anchor on the
stable `#urlbar`, then `findInSubtree` for `#urlbar-input` at any depth. This
survives Mozilla inserting wrapper elements between `#urlbar` and the input —
e.g. `.urlbar-input-box` (FF133–150) and `.urlbar-input-container` (introduced
in FF151, the change that broke NVDA+A and prompted this work). Pre-133 Firefox
trees genuinely differ and keep explicit version paths.

### anchored descendant search
The resilient navigation primitive: `findInSubtree(anchor, predicate)` in
`addon/appModules/shared/__init__.py`. Given a **stable anchor** object and a
predicate, it returns the first descendant (depth-first) that matches, at any
depth — tolerating wrapper elements inserted between anchor and target. Contrast
with `searchObject(path)`, the rigid sibling primitive that matches a fixed
sequence of **direct-child** milestones and fails the moment a wrapper appears.
Build predicates with `byIA2Attribute(key, value)` (exact match — so searching
for id `urlbar-input` never matches `urlbar-input-container`) or
`byIA2Class(value)` (CSS class-token membership, since an IA2 `class` attribute
can hold several space-separated tokens).

Two adapters use this primitive: the Firefox **address field** above, and the
Thunderbird **message header recipients** below.

### message header recipients
The sender, To and CC address fields in a Thunderbird message header, located by
`messageHeaderRecipients(messageHeader)` in `addon/appModules/thunderbird.py`.
The message header (a LANDMARK) is the stable anchor; the sender's
`#fromRecipient0` is found at any depth, and each recipient row (`#expandedtoRow`
/ `#expandedccRow`) then its `.recipients-list` within. This replaced rigid
direct-child paths up to five levels deep — the same brittleness class as the
Firefox address field, and the second adapter that proves the
[anchored descendant search](#anchored-descendant-search) seam.
56 changes: 41 additions & 15 deletions addon/appModules/firefox.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,42 @@

addonHandler.initTranslation()

def addressField(foreground, ffVersion):
"""Locate Firefox's address field -- the object bearing the page URL.

This is the single home for "how do I find the URL in this Firefox version's
a11y tree". Firefox restructures that subtree between releases; rather than
spread the knowledge across callers, both script_url and event_alert come
here.

From FF133 on the subtree is navigated resiliently: anchor on the stable
#urlbar, then search its descendants for #urlbar-input. That single path
survives Mozilla inserting wrapper elements -- the .urlbar-input-box of
FF133-150 and the .urlbar-input-container introduced in FF151 are both simply
traversed. The genuinely different pre-133 trees keep their explicit paths:
those a11y layouts can't be verified now and don't share the #urlbar anchor.
"""
if ffVersion >= 133:
urlbar = shared.searchObject((("id", "nav-bar"), ("id", "urlbar")), foreground)
return shared.findInSubtree(urlbar, shared.byIA2Attribute("id", "urlbar-input"))
if ffVersion < 70:
path = (("id", "nav-bar"), ("id", "urlbar"), ("id", "identity-box"))
elif ffVersion < 76:
path = (("id", "nav-bar"), ("id", "identity-box"), ("id", "identity-icon"))
elif ffVersion < 87:
path = (("id", "nav-bar"), ("id", "identity-box"))
else: # FF 87..132
path = (("id", "nav-bar"), ("class", "urlbar-input-box"), ("id", "urlbar-input"))
return shared.searchObject(path, foreground)

def getURL(foreground, ffVersion):
"""Return the page URL string from the address field, or None. A thin reader
over addressField for callers that just want the value."""
field = addressField(foreground, ffVersion)
if not field:
return None
return field.value if getattr(field, "value", None) else None

class AppModule(AppModule):

#TRANSLATORS: category for Firefox input gestures
Expand Down Expand Up @@ -77,10 +113,10 @@ def event_alert(self, obj, nextHandler):
return
alertText = shared.getAlertText(obj)
# Appends the domain of the page where it was when the alert originated.
path = (("id", "nav-bar"), ("id", "urlbar"), ("class", "urlbar-input textbox-input",))
url = shared.searchObject(path)
if url and url.value:
url = url.value if "://" in url.value else "None://"+url.value
ffVersion = int(self.productVersion.split(".")[0])
url = getURL(api.getForegroundObject(), ffVersion)
if url:
url = url if "://" in url else "None://"+url
domain = urlparse(url).hostname if url else ""
if domain and domain not in alertText: alertText = "%s\n\n%s" % (alertText, domain)
shared.notificationsDialog.registerFirefoxNotification((datetime.now(), alertText))
Expand Down Expand Up @@ -123,17 +159,7 @@ def script_url(self, gesture):
ui.message(_("Not available here"))
return
ffVersion = int(self.productVersion.split(".")[0])
if ffVersion < 70:
path = (("id", "nav-bar"), ("id", "urlbar"), ("id", "identity-box",))
elif ffVersion < 76:
path = (("id", "nav-bar"), ("id", "identity-box"), ("id", "identity-icon"))
elif ffVersion < 87:
path = (("id", "nav-bar"), ("id", "identity-box"))
elif ffVersion < 133:
path = (("id", "nav-bar"), ("class" ,"urlbar-input-box"), ("id","urlbar-input"))
else:
path = (("id","nav-bar"),("id","urlbar"),("class","urlbar-input-box"))
secInfoButton = shared.searchObject(path)
secInfoButton = addressField(api.getForegroundObject(), ffVersion)
if secInfoButton:
securInfo = secInfoButton.description # This has changed in FF 57. Keeping this line for compatibility with earlier versions.
try: # This one is for FF 57 and later.
Expand Down
71 changes: 70 additions & 1 deletion addon/appModules/shared/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

from datetime import datetime, timedelta
from gui import guiHelper
from logHandler import log
from NVDAObjects.IAccessible import IAccessible
from threading import Timer
import addonHandler
Expand Down Expand Up @@ -81,11 +82,32 @@ def getAlertText(alertPopup):
def searchObject(path, startAtObject=None):
obj = startAtObject if startAtObject else api.getForegroundObject()
for milestone in path:
obj = searchAmongTheChildren(milestone, obj)
parent = obj
obj = searchAmongTheChildren(milestone, parent)
if not obj:
# Mozilla restructures these a11y trees between releases; when a
# milestone goes missing the only previous signal was a generic
# "not found" spoken to the user. Record which milestone missed and
# what was actually there, so the next restructure self-reports from
# a user's NVDA log instead of needing a hand-injected probe.
_logSearchMiss(milestone, parent)
return
return obj

def _logSearchMiss(milestone, parent):
key, value = milestone
present = []
obj = parent.firstChild if parent else None
while obj:
attributes = getattr(obj, "IA2Attributes", None)
if attributes:
identifier = attributes.get("id") or attributes.get("class") or attributes.get("tag")
if identifier:
present.append(identifier)
obj = obj.next
log.info("searchObject: milestone %s=%r not found; present at this level: %s" % (
key, value, ", ".join(present) if present else "(none)"))

def searchAmongTheChildren(id, into):
if not into:
return(None)
Expand All @@ -101,6 +123,53 @@ def searchAmongTheChildren(id, into):
obj = obj.next
return(obj)

def byIA2Attribute(key, value):
"""Build a predicate for findInSubtree that matches an object whose
IA2Attribute `key` equals `value` exactly. Tolerates objects with no
IA2Attributes. Exact (not prefix) match -- so searching for id
"urlbar-input" never accidentally matches "urlbar-input-container"."""
def predicate(obj):
attributes = getattr(obj, "IA2Attributes", None)
if not attributes or key not in attributes:
return False
return attributes[key] == value
return predicate

def byIA2Class(value):
"""Build a predicate for findInSubtree that matches an object whose IA2
'class' attribute contains the CSS class token `value`. IA2 class attributes
can hold several space-separated tokens (e.g. "urlbar-input textbox-input"),
so this matches token membership rather than the whole string. Tolerates
objects with no IA2Attributes."""
def predicate(obj):
attributes = getattr(obj, "IA2Attributes", None)
if not attributes:
return False
return value in attributes.get("class", "").split()
return predicate

def findInSubtree(anchor, predicate):
"""Anchored descendant search: starting from a stable `anchor` object,
return the first descendant (depth-first, pre-order) for which `predicate`
is true, or None. Unlike searchObject's direct-child milestone walk, this
tolerates any number of wrapper elements between the anchor and the target
-- the resilience the rigid path lacks (an inserted wrapper kills the
rigid path; here it is simply traversed)."""
if not anchor:
return None
obj = anchor.firstChild
while obj:
try:
if predicate(obj):
return obj
except (AttributeError, KeyError):
pass
found = findInSubtree(obj, predicate)
if found:
return found
obj = obj.next
return None

class TabPanel(wx.Panel):

def __init__(self, parent, lbLabel):
Expand Down
44 changes: 25 additions & 19 deletions addon/appModules/thunderbird.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,30 @@

addonHandler.initTranslation()

def messageHeaderRecipients(messageHeader):
"""Locate the address fields in a Thunderbird message header: the sender
plus the To and CC recipients, as a flat list of objects (sender first).

This is the Thunderbird application of the same anchored descendant search
that locates Firefox's address field. The message header is the stable
anchor; the sender's #fromRecipient0 is found at any depth (collapsing the
old headerSenderToolbarContainer -> expandedfromRow -> multi-recipient-row
-> recipients-list -> fromRecipient0 path), and each recipient row is found
by id, then its .recipients-list within. Wrapper elements Thunderbird may
insert between those landmarks are simply traversed rather than breaking a
rigid direct-child path."""
sender = shared.findInSubtree(messageHeader, shared.byIA2Attribute('id', 'fromRecipient0'))
addresses = [sender]
toRow = shared.findInSubtree(messageHeader, shared.byIA2Attribute('id', 'expandedtoRow'))
toRecipients = shared.findInSubtree(toRow, shared.byIA2Class('recipients-list'))
if toRecipients:
addresses = addresses + toRecipients.children
ccRow = shared.findInSubtree(messageHeader, shared.byIA2Attribute('id', 'expandedccRow'))
ccRecipients = shared.findInSubtree(ccRow, shared.byIA2Class('recipients-list'))
if ccRecipients:
addresses = addresses + ccRecipients.children
return addresses

class AppModule(thunderbird.AppModule):

#TRANSLATORS: category for Thunderbird input gestures
Expand Down Expand Up @@ -272,25 +296,7 @@ def addressField(self, index, rightClick):
except StopIteration:
ui.message(_("Not found"))
return
sender = shared.searchObject((
('id', 'headerSenderToolbarContainer'),
('id', 'expandedfromRow'),
('class', 'multi-recipient-row'),
('class', 'recipients-list'),
('id', 'fromRecipient0')),
messageHeader)
toRecipients = shared.searchObject((
('id', 'expandedtoRow'),
('id', 'expandedtoBox'),
('class', 'recipients-list')),
messageHeader)
addresses = [sender]+toRecipients.children if toRecipients else [sender]
ccRecipients = shared.searchObject((
('id', 'expandedccRow'),
('id', 'expandedccBox'),
('class', 'recipients-list')),
messageHeader)
addresses = addresses+ccRecipients.children if ccRecipients else addresses
addresses = messageHeaderRecipients(messageHeader)
if addresses[0]: self.messageHeadersCache[(url, doc.IA2UniqueID)] = addresses
try:
o = addresses[index]
Expand Down
Loading