Well, it's a true Heisenbug: the bug disappeared when I added logging to the relevant functions! Through a painful ablation process, I determined that the 'fixing' log was:
if let role = appElement.role() { print("Role: \(role)")}
While it's impossible to know what's going on under the hood in Accessibility APIs, this strongly implies that it's a matter of lazy initialization. Adding an observer or reading child elements does not trigger the initialization, but somehow reading the kAXRoleAttribute does. Strangely, reading the kAXTitleAttribute didn't work: there's something special about role. Opening the Accessibility Inspector must also have the same effect.
After reading and printing the role, the kAXSelectedTextChangedNotifications start coming through correctly. Moreover, reading the kAXSelectedTextAttribute on the Application's AXUIElement returns the proper value (instead of nil, before). A whole host of other Accessibility-related logic that was previously broken also started working.
So the fix is simple: just read out the Role attribute. You can store the role as an unused variable if you don't want the print statement. The interpreter won't like it, but hey, you can please some of the people, some of the time.
let role = appElement.role()
For completeness, the 'role()' function in my sample code is a helper function that reads the kAXRoleAttribute, per the popular AXUIElement+Accessors extension pattern:
func role() -> String? {
return self.attribute(forAttribute: kAXRoleAttribute as CFString) as? String
}
func attribute(forAttribute attribute: CFString) -> Any? {
var value: CFTypeRef?
let result = AXUIElementCopyAttributeValue(self, attribute, &value)
if result == .success {
return value
} else {
return nil
}
}