AccessibilityReader.swift 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271
  1. import AirAcceptCore
  2. import AppKit
  3. import ApplicationServices
  4. import Foundation
  5. struct PromptCandidateWithButton {
  6. var candidate: PromptCandidate
  7. var match: AirPlayPromptMatch
  8. var acceptButton: AXUIElement
  9. var acceptActionName: String
  10. }
  11. enum AccessibilityReader {
  12. private static let notificationCenterBundleID = "com.apple.notificationcenterui"
  13. static func isTrusted(prompt: Bool) -> Bool {
  14. let options = [kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String: prompt] as CFDictionary
  15. return AXIsProcessTrustedWithOptions(options)
  16. }
  17. static func notificationCenterCandidate() -> PromptCandidateWithButton? {
  18. guard let window = notificationCenterWindow() else {
  19. return nil
  20. }
  21. return LocalTreeSearch.findCandidate(
  22. in: window,
  23. hostProcess: notificationCenterBundleID,
  24. maxDepth: 9,
  25. maxNodes: 450
  26. )
  27. }
  28. static func press(_ element: AXUIElement, actionName: String) -> AXError {
  29. AXUIElementPerformAction(element, actionName as CFString)
  30. }
  31. private static func notificationCenterWindow() -> AXUIElement? {
  32. guard
  33. let app = NSRunningApplication
  34. .runningApplications(withBundleIdentifier: notificationCenterBundleID)
  35. .first
  36. else {
  37. return nil
  38. }
  39. let appElement = AXUIElementCreateApplication(app.processIdentifier)
  40. var windowsRef: CFTypeRef?
  41. guard
  42. AXUIElementCopyAttributeValue(appElement, kAXWindowsAttribute as CFString, &windowsRef) == .success,
  43. let windows = windowsRef as? [AXUIElement],
  44. let window = windows.first
  45. else {
  46. return nil
  47. }
  48. return window
  49. }
  50. }
  51. private enum LocalTreeSearch {
  52. static func findCandidate(
  53. in root: AXUIElement,
  54. hostProcess: String,
  55. maxDepth: Int,
  56. maxNodes: Int
  57. ) -> PromptCandidateWithButton? {
  58. var visited = 0
  59. return visit(
  60. root,
  61. hostProcess: hostProcess,
  62. depth: 0,
  63. maxDepth: maxDepth,
  64. maxNodes: maxNodes,
  65. visited: &visited
  66. )
  67. }
  68. private static func visit(
  69. _ element: AXUIElement,
  70. hostProcess: String,
  71. depth: Int,
  72. maxDepth: Int,
  73. maxNodes: Int,
  74. visited: inout Int
  75. ) -> PromptCandidateWithButton? {
  76. guard depth <= maxDepth, visited < maxNodes else {
  77. return nil
  78. }
  79. visited += 1
  80. let snapshot = SnapshotBuilder.build(from: element, maxDepth: 4, maxNodes: 90)
  81. let candidate = PromptCandidate(
  82. hostProcess: hostProcess,
  83. texts: snapshot.texts,
  84. buttonTitles: snapshot.buttons.map(\.title)
  85. )
  86. if
  87. let match = AirPlayPromptMatcher.match(candidate),
  88. let acceptButton = snapshot.buttons.first(where: { $0.title == match.acceptButtonTitle })
  89. {
  90. return PromptCandidateWithButton(
  91. candidate: candidate,
  92. match: match,
  93. acceptButton: acceptButton.element,
  94. acceptActionName: acceptButton.actionName
  95. )
  96. }
  97. for child in SnapshotBuilder.children(of: element) {
  98. if let result = visit(
  99. child,
  100. hostProcess: hostProcess,
  101. depth: depth + 1,
  102. maxDepth: maxDepth,
  103. maxNodes: maxNodes,
  104. visited: &visited
  105. ) {
  106. return result
  107. }
  108. }
  109. return nil
  110. }
  111. }
  112. private struct SnapshotBuilder {
  113. struct Button {
  114. var title: String
  115. var element: AXUIElement
  116. var actionName: String
  117. }
  118. var texts: [String] = []
  119. var buttons: [Button] = []
  120. static func build(from root: AXUIElement, maxDepth: Int, maxNodes: Int) -> SnapshotBuilder {
  121. var builder = SnapshotBuilder()
  122. var visited = 0
  123. builder.visit(root, depth: 0, maxDepth: maxDepth, maxNodes: maxNodes, visited: &visited)
  124. return builder
  125. }
  126. private mutating func visit(
  127. _ element: AXUIElement,
  128. depth: Int,
  129. maxDepth: Int,
  130. maxNodes: Int,
  131. visited: inout Int
  132. ) {
  133. guard depth <= maxDepth, visited < maxNodes else {
  134. return
  135. }
  136. visited += 1
  137. let role = stringAttribute(element, kAXRoleAttribute)
  138. let title = stringAttribute(element, kAXTitleAttribute)
  139. let value = stringAttribute(element, kAXValueAttribute)
  140. let description = stringAttribute(element, kAXDescriptionAttribute)
  141. let help = stringAttribute(element, kAXHelpAttribute)
  142. let actionNames = actionNames(element)
  143. for text in [title, value, description, help].compactMap({ $0 }) {
  144. appendUnique(text, to: &texts)
  145. }
  146. if let button = buttonMetadata(
  147. role: role,
  148. title: title,
  149. value: value,
  150. description: description,
  151. help: help,
  152. actionNames: actionNames
  153. ) {
  154. buttons.append(Button(title: button.title, element: element, actionName: button.actionName))
  155. }
  156. for child in Self.children(of: element) {
  157. visit(child, depth: depth + 1, maxDepth: maxDepth, maxNodes: maxNodes, visited: &visited)
  158. }
  159. }
  160. private func stringAttribute(_ element: AXUIElement, _ attribute: String) -> String? {
  161. var value: CFTypeRef?
  162. guard AXUIElementCopyAttributeValue(element, attribute as CFString, &value) == .success else {
  163. return nil
  164. }
  165. if let string = value as? String {
  166. return string.trimmingCharacters(in: .whitespacesAndNewlines)
  167. }
  168. if let attributed = value as? NSAttributedString {
  169. return attributed.string.trimmingCharacters(in: .whitespacesAndNewlines)
  170. }
  171. return nil
  172. }
  173. static func children(of element: AXUIElement) -> [AXUIElement] {
  174. var value: CFTypeRef?
  175. guard
  176. AXUIElementCopyAttributeValue(element, kAXChildrenAttribute as CFString, &value) == .success,
  177. let children = value as? [AXUIElement]
  178. else {
  179. return []
  180. }
  181. return children
  182. }
  183. private func actionNames(_ element: AXUIElement) -> [String] {
  184. var value: CFArray?
  185. guard AXUIElementCopyActionNames(element, &value) == .success else {
  186. return []
  187. }
  188. return (value as? [CFString] ?? []).map { String($0) }
  189. }
  190. private func buttonMetadata(
  191. role: String?,
  192. title: String?,
  193. value: String?,
  194. description: String?,
  195. help: String?,
  196. actionNames: [String]
  197. ) -> (title: String, actionName: String)? {
  198. let labels = [title, value, description, help]
  199. .compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) }
  200. .filter { !$0.isEmpty }
  201. if let namedAction = actionNames.first(where: { actionTitle($0).map(AirPlayPromptMatcher.isAcceptTitle) == true }) {
  202. return (actionTitle(namedAction) ?? namedAction, namedAction)
  203. }
  204. if role == kAXButtonRole as String, let label = labels.first {
  205. return (label, kAXPressAction as String)
  206. }
  207. guard
  208. actionNames.contains(kAXPressAction as String),
  209. let label = labels.first(where: AirPlayPromptMatcher.isAcceptTitle)
  210. else {
  211. return nil
  212. }
  213. return (label, kAXPressAction as String)
  214. }
  215. private func actionTitle(_ actionName: String) -> String? {
  216. let normalized = actionName.trimmingCharacters(in: .whitespacesAndNewlines)
  217. guard normalized.localizedCaseInsensitiveContains("name:") else {
  218. return nil
  219. }
  220. let parts = normalized.split(separator: ":", maxSplits: 1)
  221. guard parts.count == 2 else {
  222. return nil
  223. }
  224. return String(parts[1]).trimmingCharacters(in: .whitespacesAndNewlines)
  225. }
  226. private func appendUnique(_ text: String, to array: inout [String]) {
  227. let cleaned = text.trimmingCharacters(in: .whitespacesAndNewlines)
  228. guard !cleaned.isEmpty, !array.contains(cleaned) else {
  229. return
  230. }
  231. array.append(cleaned)
  232. }
  233. }