├── .gitignore ├── LICENSE ├── Package.swift ├── README.md └── Sources ├── Access ├── Access.swift ├── AccessActor.swift ├── AccessEntity.swift ├── AccessFocus.swift ├── AccessReader.swift └── AccessReader │ ├── AccessContainerReader.swift │ ├── AccessGenericReader.swift │ └── AccessPassThroughReader.swift ├── Element ├── Element.swift ├── ElementActor.swift ├── ElementAttribute.swift ├── ElementError.swift ├── ElementEvent.swift ├── ElementLegacy.swift ├── ElementNotification.swift ├── ElementObserver.swift ├── ElementParameterizedAttribute.swift ├── ElementRole.swift └── ElementSubrole.swift ├── Input ├── Input.swift ├── InputKeyCode.swift └── InputModifierKeyCode.swift ├── Output ├── Output.swift └── OutputSemantic.swift └── Vosh ├── Vosh.swift ├── VoshAgent.swift └── VoshAppDelegate.swift /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | xcuserdata/ 5 | DerivedData/ 6 | .swiftpm 7 | .netrc 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2024 João Santos 2 | 3 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 4 | 5 | 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 6 | 7 | 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 8 | 9 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 10 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.9 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "Vosh", 7 | platforms: [.macOS(.v14)], 8 | products: [.executable(name: "Vosh", targets: ["Vosh"])], 9 | targets: [ 10 | .executableTarget( 11 | name: "Vosh", 12 | dependencies: [ 13 | .byName(name: "Element"), 14 | .byName(name: "Access"), 15 | .byName(name: "Input"), 16 | .byName(name: "Output") 17 | ] 18 | ), 19 | .target( 20 | name: "Access", 21 | dependencies: [ 22 | .byName(name: "Input"), 23 | .byName(name: "Output"), 24 | .byName(name: "Element") 25 | ] 26 | ), 27 | .target( 28 | name: "Input", 29 | dependencies: [.byName(name: "Output")] 30 | ), 31 | .target(name: "Output"), 32 | .target(name: "Element") 33 | ] 34 | ) 35 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Vosh 2 | 3 | Vosh, a contraction between Vision and Macintosh, is my attempt at creating a screen-reader for MacOS from scratch. This project draws inspiration, though not code, from a similar open-source project for Windows called NVDA, and is motivated by Apple's neglect of VoiceOver on the Mac compared to its mobile counterpart. While this is very experimental code with lots of missing features and known bugs, I just wanted to get something out there in order to follow through with a promise that I made of making it free and open-source. 4 | 5 | This project depends on very poorly documented APIs from Apple, and as such I cannot guarantee anything about its future, but will continue to work on it for as long as I can keep coming up with ways to work around the MacOS consumer-side accessibility API's quirks. Will it ever be able to compete with VoiceOver? I'm not sure, but my motivation is also fueled by the challenge to find solutions for hard problems even if what I do ends up not being very useful. 6 | 7 | At the moment you can already navigate apps that have windows and can become active including Safari, however there are a number of major issues to solve before I can even consider this code ready for testing. If you wish to help, check out the the project's issues page, which I will be updating with new issues as I become aware of them. 8 | 9 | ## Building 10 | 11 | Before you begin, I strongly recommend you to try this on a virtual machine, because even if you trust me (which you shouldn't) this software might have bugs and security issues and the instructions provided here will require granting Terminal and any application run from it the ability control your computer, unless you remember to revoke those permissions later. 12 | 13 | Vosh is distributed as a Swift package, so in order to build it you will need at least Xcode's command-line tools, however don't worry much about it since MacOS will prompt you and guide you through their installation process once you start entering the commands below. Xcode is also supported, and you will likely want to use it if you decide to tinker with the code, but the advantage of the command-line is that it makes it much easier to provide instructions on the Internet. If you don't feel comfortable following these instructions then you are not yet the target audience for this project. 14 | 15 | Start off by downloading this git repository by typing: 16 | 17 | git clone https://github.com/Choominator/Vosh.git 18 | 19 | If everything goes well you should now have a new directory named Vosh in your current working directory, so type the following to get inside: 20 | 21 | cd Vosh 22 | 23 | And then run a debug build by typing: 24 | 25 | swift run 26 | 27 | Doing this will result in a prompt asking you to grant accessibility permissions to Terminal, which will allow any applications started by it (Vosh in this case) to control your computer. This is intentional, because without these permissions Vosh cannot tap into the input event stream or communicate with accessible applications through the accessibility consumer interface. Since Vosh exits immediately when it lacks permissions, you'll have to execute the above command once more to start it normally after granting the requested permissions. 28 | 29 | If you just want to mess around with Vosh then the above is all you need to do in order to get it running. However, if you wish to tinker with the code in Xcode, instead of building the code using the command above, there are a few things that need to be done. 30 | 31 | To open the project in Xcode, type: 32 | 33 | xed . 34 | 35 | This project does not ship with any Xcode schemes, which is intentional as there's currently no portable way to code-sign it. As a result you'll need to take some extra steps if you wish to take advantage of Xcode to compile, debug, and run the project. 36 | 37 | First you'll need to create the default scheme, which Xcode should prompt you to do the first time you open this package in it, but if it doesn't you can do so manually by going to Product -> Scheme -> Manage Schemes... and clicking on the autocreate Schemes Now button. 38 | 39 | At this point you can already build Vosh, but there's a problem, which is that the final executable will have an ad-hoc code signature whose authenticity cannot be verified by Gatekeeper, and as such you will need to revoke and grant the accessibility permissions after every change to the code, which is not very comfortable. To work around this problem I believe that you need to be enrolled in the paid Apple Developer Program, and here I say that I believe because I'm on a paid membership and cannot check whether the instructions below also work with the free membership. 40 | 41 | Before setting up the default scheme to code-sign you will need two obtain two pieces of information: the identity of your development certificate in Keychain and the location of Xcode's DerivedData directory in your home directory. 42 | 43 | To obtain the identity of your development certificate, type the following: 44 | 45 | security find-identity -p basic -v 46 | 47 | Which should output something like: 48 | 49 | 1) XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX "Apple Development: John Doe (XXXXXXXXXX)" 50 | 1 valid identities found 51 | 52 | Where the text we're interested in is `Apple Development: John Doe (XXXXXXXXXX)`. 53 | 54 | By default the location of Xcode's DerivedData directory is at `~/Library/Developer/Xcode/DerivedData`, however this can be changed in Xcode's preferences, so check there for the right location, or use the following command to do so from the command-line: 55 | 56 | defaults read com.apple.dt.Xcode IDECustomDerivedDataLocation 57 | 58 | Finally, to automatically code-sign the executable you will need to edit the default scheme by going to Product -> Scheme -> Edit Schemes..., expand the Build row in the Scheme Type table, select Post-actions, and add a new Run Script action with the following code, replacing `~/Library/Developer/Xcode/DerivedData` with the path to the DerivedData directory for your user, as well as `Apple Development: John Doe (XXXXXXXXXX)` with the identity of your code-signing development certificate, and leaving everything else intact: 59 | 60 | find ~/Library/Developer/Xcode/DerivedData -name Vosh -type f -perm -700 -mtime -1 -exec codesign -s 'Apple Development: John Doe (XXXXXXXXXX)' '{}' ';' 61 | 62 | After making this change, go to Product -> Build to build the project, and then read the topmost entry of Report Navigator to verify that the scheme did not produce any errors. 63 | 64 | Once everything is set up correctly you will be free to make changes to the code without having to grant accessibility privileges to Vosh all the time. 65 | 66 | ## Usage 67 | 68 | Vosh uses CapsLock as its special key, referred from here on as the Vosh key, and as such modifies its behavior so that you need to double-press it to turn CapsLock on or off. 69 | 70 | The following is the list of key combinations currently supported by Vosh: 71 | 72 | * Vosh+Tab - Read the focused element. 73 | * Vosh+Left - Focus the previous element; 74 | * Vosh+Right - Focus the next element; 75 | * Vosh+Down - Focus the first child of the focused element; 76 | * Vosh+Up - Focus the parent of the focused element; 77 | * Vosh+Slash - Dump the system-wide element to a property list file; 78 | * Vosh+Period - Dump all elements of the active application to a property list file; 79 | * Vosh+Comma - Dump the focused element and all its children to a property list file; 80 | * Control - Interrupt speech. 81 | 82 | At present, the only user interfaces presented by Vosh are the save panel where you can choose the location of the element dump property list files and a menu extras that can be used to exit Vosh, though neither of these interfaces work with Vosh itself, and even VoiceOver has very poor support for graphical user interfaces in modal windows, so expect some accessibility issues using them. 83 | 84 | The element dumping commands are used to provide information that can be attached to issues for me to analyze on bug reports. These commands dump the hierarchy of an accessible application or a focused element within an accessible application complete with all of their respective attribute values, meaning that the dump files might contain personal or confidential information, which is yet another reason to only run this project on a virtual machine. 85 | -------------------------------------------------------------------------------- /Sources/Access/Access.swift: -------------------------------------------------------------------------------- 1 | import AppKit 2 | import OSLog 3 | 4 | import Element 5 | import Output 6 | 7 | /// Accessibility context. 8 | @AccessActor public final class Access { 9 | /// System-wide element. 10 | private let system: Element 11 | /// Active application. 12 | private var application: Element? 13 | /// Process identifier of the active application. 14 | private var processIdentifier: pid_t = 0 15 | /// Active application observer. 16 | private var observer: ElementObserver? 17 | /// Entity with user focus. 18 | private var focus: AccessFocus? 19 | /// Trigger to refocus when the frontmost application changes. 20 | private var refocusTrigger: NSKeyValueObservation? 21 | /// System logging facility. 22 | private static let logger = Logger() 23 | 24 | /// Initializes the accessibility framework. 25 | public init?() async { 26 | guard await Element.confirmProcessTrustedStatus() else { 27 | return nil 28 | } 29 | system = await Element() 30 | Task() {[weak self] in 31 | while let self = self { 32 | var eventIterator = await self.observer?.eventStream.makeAsyncIterator() 33 | while let event = await eventIterator?.next() { 34 | await handleEvent(event) 35 | } 36 | try? await Task.sleep(nanoseconds: 100_000_000) 37 | } 38 | } 39 | await refocus(processIdentifier: NSWorkspace.shared.frontmostApplication?.processIdentifier) 40 | refocusTrigger = NSWorkspace.shared.observe(\.frontmostApplication, options: .new) {[weak self] (_, value) in 41 | guard let runningApplication = value.newValue else { 42 | return 43 | } 44 | let processIdentifier = runningApplication?.processIdentifier 45 | Task {[self] in 46 | await self?.refocus(processIdentifier: processIdentifier) 47 | } 48 | } 49 | } 50 | 51 | /// Sets the response timeout of the accessibility framework. 52 | /// - Parameter seconds: Time in seconds. 53 | public func setTimeout(seconds: Float) async { 54 | do { 55 | try await system.setTimeout(seconds: seconds) 56 | } catch { 57 | await handleError(error) 58 | } 59 | } 60 | 61 | /// Reads the accessibility contents of the element with user focus. 62 | public func readFocus() async { 63 | do { 64 | guard let focus = focus else { 65 | let content = [OutputSemantic.noFocus] 66 | await Output.shared.convey(content) 67 | return 68 | } 69 | let content = try await focus.reader.read() 70 | await Output.shared.convey(content) 71 | } catch { 72 | await handleError(error) 73 | } 74 | } 75 | 76 | /// Moves the user focus to its interesting parent. 77 | public func focusParent() async { 78 | do { 79 | guard let oldFocus = focus else { 80 | let content = [OutputSemantic.noFocus] 81 | await Output.shared.convey(content) 82 | return 83 | } 84 | guard let parent = try await oldFocus.entity.getParent() else { 85 | var content = [OutputSemantic.boundary] 86 | content.append(contentsOf: try await oldFocus.reader.read()) 87 | await Output.shared.convey(content) 88 | return 89 | } 90 | let newFocus = try await AccessFocus(on: parent) 91 | self.focus = newFocus 92 | try await newFocus.entity.setKeyboardFocus() 93 | var content = [OutputSemantic.exiting] 94 | content.append(contentsOf: try await newFocus.reader.readSummary()) 95 | await Output.shared.convey(content) 96 | } catch { 97 | await handleError(error) 98 | } 99 | } 100 | 101 | /// Moves the user focus to its next interesting sibling. 102 | /// - Parameter backwards: Whether to search backwards. 103 | public func focusNextSibling(backwards: Bool) async { 104 | do { 105 | guard let oldFocus = focus else { 106 | let content = [OutputSemantic.noFocus] 107 | await Output.shared.convey(content) 108 | return 109 | } 110 | guard let sibling = try await oldFocus.entity.getNextSibling(backwards: backwards) else { 111 | var content = [OutputSemantic.boundary] 112 | content.append(contentsOf: try await oldFocus.reader.read()) 113 | await Output.shared.convey(content) 114 | return 115 | } 116 | let newFocus = try await AccessFocus(on: sibling) 117 | self.focus = newFocus 118 | try await newFocus.entity.setKeyboardFocus() 119 | var content = [!backwards ? OutputSemantic.next : OutputSemantic.previous] 120 | content.append(contentsOf: try await newFocus.reader.read()) 121 | await Output.shared.convey(content) 122 | } catch { 123 | await handleError(error) 124 | } 125 | } 126 | 127 | /// Sets the user focus to the first child of this entity. 128 | public func focusFirstChild() async { 129 | do { 130 | guard let oldFocus = focus else { 131 | let content = [OutputSemantic.noFocus] 132 | await Output.shared.convey(content) 133 | return 134 | } 135 | guard let child = try await oldFocus.entity.getFirstChild() else { 136 | var content = [OutputSemantic.boundary] 137 | content.append(contentsOf: try await oldFocus.reader.read()) 138 | await Output.shared.convey(content) 139 | return 140 | } 141 | let newFocus = try await AccessFocus(on: child) 142 | self.focus = newFocus 143 | try await newFocus.entity.setKeyboardFocus() 144 | var content = [OutputSemantic.entering] 145 | content.append(contentsOf: try await oldFocus.reader.readSummary()) 146 | content.append(contentsOf: try await newFocus.reader.read()) 147 | await Output.shared.convey(content) 148 | } catch { 149 | await handleError(error) 150 | } 151 | } 152 | 153 | /// Dumps the system wide element to a property list file chosen by the user. 154 | @MainActor public func dumpSystemWide() async { 155 | await dumpElement(system) 156 | } 157 | 158 | /// Dumps all accessibility elements of the currently active application to a property list file chosen by the user. 159 | @MainActor public func dumpApplication() async { 160 | guard let application = await application else { 161 | let content = [OutputSemantic.noFocus] 162 | Output.shared.convey(content) 163 | return 164 | } 165 | await dumpElement(application) 166 | } 167 | 168 | /// Dumps all descendant accessibility elements of the currently focused element to a property list file chosen by the user. 169 | @MainActor public func dumpFocus() async { 170 | guard let focus = await focus else { 171 | let content = [OutputSemantic.noFocus] 172 | Output.shared.convey(content) 173 | return 174 | } 175 | await dumpElement(focus.entity.element) 176 | } 177 | 178 | /// Resets the user focus to the system keyboard focusor the first interesting child of the focused window. 179 | private func refocus(processIdentifier: pid_t?) async { 180 | do { 181 | guard let processIdentifier = processIdentifier else { 182 | application = nil 183 | self.processIdentifier = 0 184 | observer = nil 185 | focus = nil 186 | let content = [OutputSemantic.noFocus] 187 | await Output.shared.convey(content) 188 | return 189 | } 190 | var content = [OutputSemantic]() 191 | if processIdentifier != self.processIdentifier { 192 | let application = await Element(processIdentifier: processIdentifier) 193 | let observer = try await ElementObserver(element: application) 194 | try await observer.subscribe(to: .applicationDidAnnounce) 195 | try await observer.subscribe(to: .elementDidDisappear) 196 | try await observer.subscribe(to: .elementDidGetFocus) 197 | self.application = application 198 | self.processIdentifier = processIdentifier 199 | self.observer = observer 200 | let applicationLabel = try await application.getAttribute(.title) as? String 201 | content.append(.application(applicationLabel ?? "Application")) 202 | } 203 | guard let application = self.application, let observer = self.observer else { 204 | fatalError("Logic failed") 205 | } 206 | if let keyboardFocus = try await application.getAttribute(.focusedElement) as? Element { 207 | if let window = try await keyboardFocus.getAttribute(.windowElement) as? Element { 208 | if let windowLabel = try await window.getAttribute(.title) as? String, !windowLabel.isEmpty { 209 | content.append(.window(windowLabel)) 210 | } else { 211 | content.append(.window("Untitled")) 212 | } 213 | } 214 | let focus = try await AccessFocus(on: keyboardFocus) 215 | self.focus = focus 216 | content.append(contentsOf: try await focus.reader.read()) 217 | } else if let window = try await application.getAttribute(.focusedWindow) as? Element, let child = try await AccessEntity(for: window).getFirstChild() { 218 | if let windowLabel = try await window.getAttribute(.title) as? String, !windowLabel.isEmpty { 219 | content.append(.window(windowLabel)) 220 | } else { 221 | content.append(.window("Untitled")) 222 | } 223 | let focus = try await AccessFocus(on: child) 224 | self.focus = focus 225 | content.append(contentsOf: try await focus.reader.read()) 226 | } else { 227 | self.focus = nil 228 | try await observer.subscribe(to: .elementDidAppear) 229 | content.append(.noFocus) 230 | } 231 | await Output.shared.convey(content) 232 | } catch { 233 | await handleError(error) 234 | } 235 | } 236 | 237 | /// Handles events generated by the application element. 238 | /// - Parameter event: Generated event. 239 | private func handleEvent(_ event: ElementEvent) async { 240 | do { 241 | switch event.notification { 242 | case .applicationDidAnnounce: 243 | if let announcement = event.payload?[.announcement] as? String { 244 | await Output.shared.announce(announcement) 245 | } 246 | case .elementDidAppear: 247 | guard focus == nil else { 248 | try await observer?.unsubscribe(from: .elementDidAppear) 249 | break 250 | } 251 | await refocus(processIdentifier: processIdentifier) 252 | if self.focus != nil { 253 | try await observer?.unsubscribe(from: .elementDidAppear) 254 | } 255 | case .elementDidDisappear: 256 | guard event.subject == focus?.entity.element else { 257 | break 258 | } 259 | let entity = try await AccessEntity(for: event.subject) 260 | guard let isFocusableAncestor = try await focus?.entity.isInFocusGroup(of: entity), !isFocusableAncestor else { 261 | break 262 | } 263 | focus = nil 264 | await refocus(processIdentifier: self.processIdentifier) 265 | case .elementDidGetFocus: 266 | guard event.subject != focus?.entity.element else { 267 | break 268 | } 269 | let newFocus = try await AccessFocus(on: event.subject) 270 | guard let oldFocus = focus, try await !oldFocus.entity.isInFocusGroup(of: newFocus.entity) else { 271 | break 272 | } 273 | self.focus = newFocus 274 | await readFocus() 275 | default: 276 | fatalError("Received an unexpected event notification \(event.notification)") 277 | } 278 | } catch { 279 | await handleError(error) 280 | } 281 | } 282 | 283 | /// Dumps the entire hierarchy of elements rooted at the specified element to a property list file chosen by the user. 284 | /// - Parameter element: Root element. 285 | @MainActor private func dumpElement(_ element: Element) async { 286 | do { 287 | guard let label = try await application?.getAttribute(.title) as? String, let dump = try await element.dump() else { 288 | let content = [OutputSemantic.noFocus] 289 | Output.shared.convey(content) 290 | return 291 | } 292 | let data = try PropertyListSerialization.data(fromPropertyList: dump, format: .binary, options: .zero) 293 | let savePanel = NSSavePanel() 294 | savePanel.canCreateDirectories = true 295 | savePanel.message = "Choose a location to dump the selected accessibility elements." 296 | savePanel.nameFieldLabel = "Accessibility Dump Property List" 297 | savePanel.nameFieldStringValue = "\(label) Dump.plist" 298 | savePanel.title = "Save \(label) dump property list" 299 | let response = await savePanel.begin() 300 | if response == .OK, let url = savePanel.url { 301 | try data.write(to: url) 302 | } 303 | } catch { 304 | await handleError(error) 305 | } 306 | } 307 | 308 | /// Handles errors returned by the Element module. 309 | /// - Parameter error: Error to handle. 310 | private func handleError(_ error: any Error) async { 311 | guard let error = error as? ElementError else { 312 | fatalError("Unexpected error \(error)") 313 | } 314 | switch error { 315 | case .apiDisabled: 316 | let content = [OutputSemantic.apiDisabled] 317 | await Output.shared.convey(content) 318 | case .invalidElement: 319 | await refocus(processIdentifier: processIdentifier) 320 | case .notImplemented: 321 | let content = [OutputSemantic.notAccessible] 322 | await Output.shared.convey(content) 323 | case .timeout: 324 | let content = [OutputSemantic.timeout] 325 | await Output.shared.convey(content) 326 | default: 327 | Self.logger.warning("Unexpected error \(error, privacy: .public)") 328 | return 329 | } 330 | } 331 | } 332 | -------------------------------------------------------------------------------- /Sources/Access/AccessActor.swift: -------------------------------------------------------------------------------- 1 | /// Actor for the accessibility framework. 2 | @globalActor public actor AccessActor { 3 | /// Shared singleton. 4 | public static let shared = AccessActor() 5 | 6 | /// Singleton initializer. 7 | private init() {} 8 | 9 | /// Convenience method that schedules a function to run on this actor's dedicated thread. 10 | /// - Parameters: 11 | /// - resultType: Return type of the scheduled function. 12 | /// - run: Function to run. 13 | /// - Returns: Whatever the function returns. 14 | public static func run(resultType _: T.Type = T.self, body run: @AccessActor () throws -> T) async rethrows -> T { 15 | return try await run() 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Sources/Access/AccessEntity.swift: -------------------------------------------------------------------------------- 1 | import Element 2 | 3 | /// Convenience wrapper around an accessibility element. 4 | @AccessActor final class AccessEntity { 5 | /// Wrapped accessibility element. 6 | let element: Element 7 | 8 | /// Creates an accessibility entity wrapping the specified element. 9 | /// - Parameter element: Element to wrap. 10 | init(for element: Element) async throws { 11 | self.element = element 12 | } 13 | 14 | /// Retrieves the interesting parent of this entity. 15 | /// - Returns: Retrieved entity. 16 | func getParent() async throws -> AccessEntity? { 17 | guard let parent = try await Self.findParent(of: element) else { 18 | return nil 19 | } 20 | return try await AccessEntity(for: parent) 21 | } 22 | 23 | /// Retrieves the first interesting child of this entity. 24 | /// - Returns: Child of this entity. 25 | func getFirstChild() async throws -> AccessEntity? { 26 | guard let child = try await Self.findFirstChild(of: element, backwards: false) else { 27 | return nil 28 | } 29 | return try await AccessEntity(for: child) 30 | } 31 | 32 | /// Retrieves the next interesting sibling of this entity. 33 | /// - Parameter backwards: Whether to move backwards. 34 | /// - Returns: Sibling of this entity. 35 | func getNextSibling(backwards: Bool) async throws -> AccessEntity? { 36 | guard let element = try await Self.findNextSibling(of: element, backwards: backwards) else { 37 | return nil 38 | } 39 | return try await AccessEntity(for: element) 40 | } 41 | 42 | /// Attempts to set the keyboard focus to the wrapped element. 43 | func setKeyboardFocus() async throws { 44 | do { 45 | try await element.setAttribute(.isFocused, value: true) 46 | guard let role = try await element.getAttribute(.role) as? ElementRole else { 47 | return 48 | } 49 | switch role { 50 | case .button, .checkBox, .colorWell, .comboBox, 51 | .dateField, .incrementer, .link, .menuBarItem, 52 | .menuButton, .menuItem, .popUpButton, .radioButton, 53 | .slider, .textArea, .textField, .timeField: 54 | break 55 | default: 56 | return 57 | } 58 | if let isFocused = try await element.getAttribute(.isFocused) as? Bool, isFocused { 59 | return 60 | } 61 | if let focusableAncestor = try await element.getAttribute(.focusableAncestor) as? Element { 62 | try await focusableAncestor.setAttribute(.isFocused, value: true) 63 | } 64 | } catch ElementError.attributeUnsupported { 65 | return 66 | } catch { 67 | throw error 68 | } 69 | } 70 | 71 | /// Checks whether this entity is a focusable ancestor of the provided entity. 72 | /// - Parameter entity: Child entity. 73 | /// - Returns: Whether the provided entity is a child of this entity. 74 | func isInFocusGroup(of entity: AccessEntity) async throws -> Bool { 75 | guard let element = try await element.getAttribute(.focusableAncestor) as? Element else { 76 | return false 77 | } 78 | return element == entity.element 79 | } 80 | 81 | /// Looks for an interesting parent of the specified element. 82 | /// - Parameter element: Element whose parent is to be searched. 83 | /// - Returns: Interesting parent. 84 | private static func findParent(of element: Element) async throws -> Element? { 85 | guard let parent = try await element.getAttribute(.parentElement) as? Element, try await !isRoot(element: parent) else { 86 | return nil 87 | } 88 | guard try await isInteresting(element: parent) else { 89 | return try await findParent(of: parent) 90 | } 91 | return parent 92 | } 93 | 94 | /// Looks for the next interesting sibling of the specified element. 95 | /// - Parameters: 96 | /// - element: Element whose next sibling is to search. 97 | /// - backwards: Whehter to walk backwards. 98 | /// - Returns: Found sibling, if any. 99 | private static func findNextSibling(of element: Element, backwards: Bool) async throws -> Element? { 100 | guard let parent = try await element.getAttribute(.parentElement) as? Element else { 101 | return nil 102 | } 103 | let siblings: [Element]? = if let siblings = try await parent.getAttribute(.childElementsInNavigationOrder) as? [Any?] { 104 | siblings.compactMap({$0 as? Element}) 105 | } else if let siblings = try await element.getAttribute(.childElements) as? [Any?] { 106 | siblings.compactMap({$0 as? Element}) 107 | } else { 108 | nil 109 | } 110 | guard let siblings = siblings, !siblings.isEmpty else { 111 | return nil 112 | } 113 | var orderedSiblings = siblings 114 | if backwards { 115 | orderedSiblings.reverse() 116 | } 117 | for sibling in orderedSiblings.drop(while: {$0 != element}).dropFirst() { 118 | if try await isInteresting(element: sibling) { 119 | return sibling 120 | } 121 | if let child = try await findFirstChild(of: sibling, backwards: backwards) { 122 | return child 123 | } 124 | } 125 | guard try await !isRoot(element: parent), try await !isInteresting(element: parent) else { 126 | return nil 127 | } 128 | return try await findNextSibling(of: parent, backwards: backwards) 129 | } 130 | 131 | /// Looks for the first interesting child of the specified element. 132 | /// - Parameters: 133 | /// - element: Element to search. 134 | /// - backwards: Whether to walk backwards. 135 | /// - Returns: Suitable child element. 136 | private static func findFirstChild(of element: Element, backwards: Bool) async throws -> Element? { 137 | if try await isLeaf(element: element) { 138 | return nil 139 | } 140 | let children: [Element]? = if let children = try await element.getAttribute(.childElementsInNavigationOrder) as? [Any?] { 141 | children.compactMap({$0 as? Element}) 142 | } else if let children = try await element.getAttribute(.childElements) as? [Any?] { 143 | children.compactMap({$0 as? Element}) 144 | } else { 145 | nil 146 | } 147 | guard let children = children, !children.isEmpty else { 148 | return nil 149 | } 150 | var orderedChildren = children 151 | if backwards { 152 | orderedChildren.reverse() 153 | } 154 | for child in orderedChildren { 155 | if try await isInteresting(element: child) { 156 | return child 157 | } 158 | if try await isLeaf(element: child) { 159 | return nil 160 | } 161 | if let child = try await findFirstChild(of: child, backwards: backwards) { 162 | return child 163 | } 164 | } 165 | return nil 166 | } 167 | 168 | /// Checks whether the specified element has accessibility relevance. 169 | /// - Parameter element: Element to test. 170 | /// - Returns: Result of the test. 171 | private static func isInteresting(element: Element) async throws -> Bool { 172 | if let isFocused = try await element.getAttribute(.isFocused) as? Bool, isFocused { 173 | return true 174 | } 175 | if let title = try await element.getAttribute(.title) as? String, !title.isEmpty { 176 | return true 177 | } 178 | if let description = try await element.getAttribute(.description) as? String, !description.isEmpty { 179 | return true 180 | } 181 | guard let role = try await element.getAttribute(.role) as? ElementRole else { 182 | return false 183 | } 184 | switch role { 185 | case .browser, .busyIndicator, .button, .cell, 186 | .checkBox, .colorWell, .comboBox, .dateField, 187 | .disclosureTriangle, .dockItem, .drawer, .grid, 188 | .growArea, .handle, .heading, .image, 189 | .levelIndicator, .link, .list, .menuBarItem, 190 | .menuItem, .menuButton, .outline, .popUpButton, .popover, 191 | .progressIndicator, .radioButton, .relevanceIndicator, .sheet, 192 | .slider, .staticText, .tabGroup, .table, 193 | .textArea, .textField, .timeField, .toolbar, 194 | .valueIndicator, .webArea: 195 | let isLeaf = try await isLeaf(element: element) 196 | let hasWebAncestor = try await hasWebAncestor(element: element) 197 | return !hasWebAncestor || hasWebAncestor && isLeaf 198 | default: 199 | return false 200 | } 201 | } 202 | 203 | /// Checks whether the specified element is considered to not have parents. 204 | /// - Parameter element: Element to test. 205 | /// - Returns: Result of the test. 206 | private static func isRoot(element: Element) async throws -> Bool { 207 | guard let role = try await element.getAttribute(.role) as? ElementRole else { 208 | return false 209 | } 210 | switch role { 211 | case .menu, .menuBar, .window: 212 | return true 213 | default: 214 | return false 215 | } 216 | } 217 | 218 | /// Checks whether the specified element is considered to not have children. 219 | /// - Parameter element: Element to test. 220 | /// - Returns: Result of the test. 221 | private static func isLeaf(element: Element) async throws -> Bool { 222 | guard let role = try await element.getAttribute(.role) as? ElementRole else { 223 | return false 224 | } 225 | switch role { 226 | case .busyIndicator, .button, .checkBox, .colorWell, 227 | .comboBox, .dateField, .disclosureTriangle, .dockItem, 228 | .heading, .image, .incrementer, .levelIndicator, 229 | .link, .menuBarItem, .menuButton, .menuItem, 230 | .popUpButton, .progressIndicator, .radioButton, .relevanceIndicator, 231 | .scrollBar, .slider, .staticText, .textArea, 232 | .textField, .timeField, .valueIndicator: 233 | return true 234 | default: 235 | return false 236 | } 237 | } 238 | 239 | /// Checks whether the specified element has web area ancestry. 240 | /// - Parameter element: Element to verify. 241 | /// - Returns: Whether the element has a web ancestor. 242 | private static func hasWebAncestor(element: Element) async throws -> Bool { 243 | guard let parent = try await element.getAttribute(.parentElement) as? Element else { 244 | return false 245 | } 246 | guard let role = try await parent.getAttribute(.role) as? ElementRole else { 247 | return false 248 | } 249 | if role == .webArea { 250 | return true 251 | } 252 | return try await hasWebAncestor(element: parent) 253 | } 254 | } 255 | -------------------------------------------------------------------------------- /Sources/Access/AccessFocus.swift: -------------------------------------------------------------------------------- 1 | import Element 2 | import Output 3 | 4 | /// User focus state. 5 | @AccessActor struct AccessFocus { 6 | /// Focused entity. 7 | let entity: AccessEntity 8 | /// Reader for the focused entity. 9 | let reader: AccessReader 10 | 11 | /// Creates a new focus on the specified element. 12 | /// - Parameter element: Element to focus. 13 | init(on element: Element) async throws { 14 | let entity = try await AccessEntity(for: element) 15 | try await self.init(on: entity) 16 | } 17 | 18 | /// Creates a new focus on the specified entity. 19 | /// - Parameter entity: Entity to focus. 20 | init(on entity: AccessEntity) async throws { 21 | self.entity = entity 22 | reader = try await AccessReader(for: entity.element) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Sources/Access/AccessReader.swift: -------------------------------------------------------------------------------- 1 | import Element 2 | import Output 3 | 4 | /// Accessibility reader context. 5 | @AccessActor final class AccessReader { 6 | /// Specialized reader strategy. 7 | let strategy: AccessGenericReader 8 | 9 | /// Creates a new reader. 10 | /// - Parameter element: Element to wrap. 11 | init(for element: Element) async throws { 12 | if let role = try await element.getAttribute(.role) as? ElementRole { 13 | switch role { 14 | case .row, .column, .cell: 15 | strategy = try await AccessPassThroughReader(for: element) 16 | case .outline, .table: 17 | strategy = try await AccessContainerReader(for: element) 18 | default: 19 | strategy = try await AccessGenericReader(for: element) 20 | } 21 | } else { 22 | strategy = try await AccessGenericReader(for: element) 23 | } 24 | } 25 | 26 | /// Reads the accessibility content of the element. 27 | /// - Returns: Semantically described output content. 28 | func read() async throws -> [OutputSemantic] { 29 | return try await strategy.read() 30 | } 31 | 32 | /// Reads a short description of the element. 33 | /// - Returns: Semantically described output content. 34 | func readSummary() async throws -> [OutputSemantic] { 35 | return try await strategy.readSummary() 36 | } 37 | 38 | /// Reads the accessibility label of the element. 39 | /// - Returns: Semantically described output content. 40 | func readLabel() async throws -> [OutputSemantic] { 41 | return try await strategy.readLabel() 42 | } 43 | 44 | /// Reads the value of the element. 45 | /// - Returns: Semantically described output content. 46 | func readValue() async throws -> [OutputSemantic] { 47 | return try await strategy.readValue() 48 | } 49 | 50 | /// Reads the accessibility role of the element. 51 | /// - Returns: Semantically described output content. 52 | func readRole() async throws -> [OutputSemantic] { 53 | return try await strategy.readRole() 54 | } 55 | 56 | /// Reads the state of the element. 57 | /// - Returns: Semantically described output content. 58 | func readState() async throws -> [OutputSemantic] { 59 | return try await strategy.readState() 60 | } 61 | 62 | /// Reads the help information of the element. 63 | /// - Returns: Semantically described output content. 64 | func readHelp() async throws -> [OutputSemantic] { 65 | return try await strategy.readHelp() 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /Sources/Access/AccessReader/AccessContainerReader.swift: -------------------------------------------------------------------------------- 1 | import Element 2 | import Output 3 | 4 | /// Accessibility reader for containers like outlines and tables. 5 | @AccessActor class AccessContainerReader: AccessGenericReader { 6 | /// Specializes the reader to also read the selected children of the wrapped container element. 7 | /// - Returns: Semantic accessibility output. 8 | override func read() async throws -> [OutputSemantic] { 9 | var content = try await super.read() 10 | content.append(contentsOf: try await readSelectedChildren()) 11 | return content 12 | } 13 | 14 | /// Specializes the summary reader to also read the number of rows and columns when available for the wrapped container element. 15 | /// - Returns: Semantic accessibility output. 16 | override func readSummary() async throws -> [OutputSemantic] { 17 | var content = try await super.readSummary() 18 | if let rows = try await element.getAttribute(.rows) as? [Any?] { 19 | content.append(.rowCount(rows.count)) 20 | } 21 | if let columns = try await element.getAttribute(.columns) as? [Any?] { 22 | content.append(.columnCount(columns.count)) 23 | } 24 | return content 25 | } 26 | 27 | /// Reads the selected children of the wrapped container element. 28 | /// - Returns: Semantic accessibility output. 29 | private func readSelectedChildren() async throws -> [OutputSemantic] { 30 | let children = if let children = try await element.getAttribute(.selectedChildrenElements) as? [Any?], !children.isEmpty { 31 | children.compactMap({$0 as? Element}) 32 | } else if let children = try await element.getAttribute(.selectedCells) as? [Any?] { 33 | children.compactMap({$0 as? Element}) 34 | } else if let children = try await element.getAttribute(.selectedRows) as? [Any?] { 35 | children.compactMap({$0 as? Element}) 36 | } else if let children = try await element.getAttribute(.selectedColumns) as? [Any?] { 37 | children.compactMap({$0 as? Element}) 38 | } else { 39 | [Element]() 40 | } 41 | if children.count == 1, let child = children.first { 42 | let reader = try await AccessReader(for: child) 43 | return try await reader.readSummary() 44 | } 45 | return [.selectedChildrenCount(children.count)] 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /Sources/Access/AccessReader/AccessGenericReader.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import OSLog 3 | 4 | import Element 5 | import Output 6 | 7 | /// Unspecialized accessibility reader. 8 | @AccessActor class AccessGenericReader { 9 | /// Element to read. 10 | let element: Element 11 | /// System logging facility. 12 | private static let logger = Logger() 13 | 14 | /// Creates a generic accessibility reader. 15 | /// - Parameter element: Element to read. 16 | init(for element: Element) async throws { 17 | self.element = element 18 | } 19 | 20 | /// Reads the accessibility content of the wrapped element. 21 | /// - Returns: Semantically described output content. 22 | func read() async throws -> [OutputSemantic] { 23 | var content = try await readSummary() 24 | content.append(contentsOf: try await readRole()) 25 | content.append(contentsOf: try await readState()) 26 | content.append(contentsOf: try await readHelp()) 27 | return content 28 | } 29 | 30 | /// Reads a short description of the wrapped element. 31 | /// - Returns: Semantically described output content. 32 | func readSummary() async throws -> [OutputSemantic] { 33 | var content = [OutputSemantic]() 34 | content.append(contentsOf: try await readLabel()) 35 | content.append(contentsOf: try await readValue()) 36 | return content 37 | } 38 | 39 | /// Reads the accessibility label of the wrapped element. 40 | /// - Returns: Semantically described output content. 41 | func readLabel() async throws -> [OutputSemantic] { 42 | if let title = try await element.getAttribute(.title) as? String, !title.isEmpty { 43 | return [.label(title)] 44 | } 45 | if let element = try await element.getAttribute(.titleElement) as? Element, let title = try await element.getAttribute(.title) as? String, !title.isEmpty { 46 | return [.label(title)] 47 | } 48 | if let description = try await element.getAttribute(.description) as? String, !description.isEmpty { 49 | return [.label(description)] 50 | } 51 | return [] 52 | } 53 | 54 | /// Reads the value of the wrapped element. 55 | /// - Returns: Semantically described output content. 56 | func readValue() async throws -> [OutputSemantic] { 57 | var content = [OutputSemantic]() 58 | let value: Any? = if let value = try await element.getAttribute(.valueDescription) as? String, !value.isEmpty { 59 | value 60 | } else if let value = try await element.getAttribute(.value) { 61 | value 62 | } else { 63 | nil 64 | } 65 | guard let value = value else { 66 | return [] 67 | } 68 | switch value { 69 | case let bool as Bool: 70 | content.append(.boolValue(bool)) 71 | case let integer as Int64: 72 | content.append(.intValue(integer)) 73 | case let float as Double: 74 | content.append(.floatValue(float)) 75 | case let string as String: 76 | content.append(.stringValue(string)) 77 | if let selection = try await element.getAttribute(.selectedText) as? String, !selection.isEmpty { 78 | content.append(.selectedText(selection)) 79 | } 80 | case let attributedString as AttributedString: 81 | let string = String(attributedString.characters) 82 | content.append(.stringValue(string)) 83 | if let selection = try await element.getAttribute(.selectedText) as? String, !selection.isEmpty { 84 | content.append(.selectedText(selection)) 85 | } 86 | case let url as URL: 87 | content.append(.urlValue(url.absoluteString)) 88 | default: 89 | Self.logger.warning("Unexpected value type: \(type(of: value), privacy: .public)") 90 | } 91 | if let edited = try await element.getAttribute(.edited) as? Bool, edited { 92 | content.append(.edited) 93 | } 94 | if let placeholder = try await element.getAttribute(.placeholderValue) as? String, !placeholder.isEmpty { 95 | content.append(.placeholderValue(placeholder)) 96 | } 97 | return content 98 | } 99 | 100 | /// Reads the accessibility role of the wrapped element. 101 | /// - Returns: Semantically described output content. 102 | func readRole() async throws -> [OutputSemantic] { 103 | if let description = try await element.getAttribute(.description) as? String, !description.isEmpty { 104 | return [] 105 | } else if let role = try await element.getAttribute(.roleDescription) as? String, !role.isEmpty { 106 | return [.role(role)] 107 | } 108 | return [] 109 | } 110 | 111 | /// Reads the state of the wrapped element. 112 | /// - Returns: Semantically described output content. 113 | func readState() async throws -> [OutputSemantic] { 114 | var output = [OutputSemantic]() 115 | if let selected = try await element.getAttribute(.selected) as? Bool, selected { 116 | output.append(.selected) 117 | } 118 | if let enabled = try await element.getAttribute(.isEnabled) as? Bool, !enabled { 119 | output.append(.disabled) 120 | } 121 | return output 122 | } 123 | 124 | /// Reads the help information of the wrapped element. 125 | /// - Returns: Semantically described output content. 126 | func readHelp() async throws -> [OutputSemantic] { 127 | if let help = try await element.getAttribute(.help) as? String, !help.isEmpty { 128 | return [.help(help)] 129 | } 130 | return [] 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /Sources/Access/AccessReader/AccessPassThroughReader.swift: -------------------------------------------------------------------------------- 1 | import Element 2 | import Output 3 | 4 | /// Accessibility reader for elements that must be ignored and have all of their children's summaries read instead. 5 | @AccessActor class AccessPassThroughReader: AccessGenericReader { 6 | /// Reads all of this element's children summaries. 7 | /// - Returns: Semantic accessibility output. 8 | override func readSummary() async throws -> [OutputSemantic] { 9 | let children = if let children = try await element.getAttribute(.childElementsInNavigationOrder) as? [Any?] { 10 | children 11 | } else if let children = try await element.getAttribute(.childElements) as? [Any?] { 12 | children 13 | } else { 14 | [] 15 | } 16 | var content = [OutputSemantic]() 17 | for child in children.lazy.compactMap({$0 as? Element}) { 18 | let reader = try await AccessReader(for: child) 19 | content.append(contentsOf: try await reader.readSummary()) 20 | } 21 | return content 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Sources/Element/Element.swift: -------------------------------------------------------------------------------- 1 | import ApplicationServices 2 | 3 | /// Swift wrapper for a legacy ``AXUIElement``. 4 | @ElementActor public struct Element { 5 | /// Legacy value. 6 | let legacyValue: CFTypeRef 7 | 8 | /// Creates a system-wide element. 9 | public init() { 10 | legacyValue = AXUIElementCreateSystemWide() 11 | } 12 | 13 | /// Creates an application element for the specified PID. 14 | /// - Parameter processIdentifier: PID of the application. 15 | public init(processIdentifier: pid_t) { 16 | legacyValue = AXUIElementCreateApplication(processIdentifier) 17 | } 18 | 19 | /// Wraps a legacy ``AXUIElement``. 20 | /// - Parameter value: Legacy value to wrap. 21 | nonisolated init?(legacyValue value: CFTypeRef) { 22 | guard CFGetTypeID(value) == AXUIElementGetTypeID() else { 23 | return nil 24 | } 25 | legacyValue = unsafeBitCast(value, to: AXUIElement.self) 26 | } 27 | 28 | /// Creates the element corresponding to the application of the specified element. 29 | /// - Returns: Application element. 30 | public func getApplication() throws -> Element { 31 | let processIdentifier = try getProcessIdentifier() 32 | return Element(processIdentifier: processIdentifier) 33 | } 34 | 35 | /// Reads the process identifier of this element. 36 | /// - Returns: Process identifier. 37 | public func getProcessIdentifier() throws -> pid_t { 38 | let legacyValue = legacyValue as! AXUIElement 39 | var processIdentifier = pid_t(0) 40 | let result = AXUIElementGetPid(legacyValue, &processIdentifier) 41 | let error = ElementError(from: result) 42 | switch error { 43 | case .success: 44 | break 45 | case .apiDisabled, .invalidElement, .notImplemented, .timeout: 46 | throw error 47 | default: 48 | fatalError("Unexpected error reading an accessibility element's process identifier: \(error)") 49 | } 50 | return processIdentifier 51 | } 52 | 53 | /// Sets the timeout of requests made to this element. 54 | /// - Parameter seconds: Timeout in seconds. 55 | public func setTimeout(seconds: Float) throws { 56 | let legacyValue = legacyValue as! AXUIElement 57 | let result = AXUIElementSetMessagingTimeout(legacyValue, seconds) 58 | let error = ElementError(from: result) 59 | switch error { 60 | case .success: 61 | break 62 | case .apiDisabled, .invalidElement, .notImplemented, .timeout: 63 | throw error 64 | default: 65 | fatalError("Unexpected error setting an accessibility element's request timeout: \(error)") 66 | } 67 | } 68 | 69 | /// Dumps this element to a data structure suitable to be encoded and serialized. 70 | /// - Parameters: 71 | /// - recursiveParents: Whether to recursively dump this element's parents. 72 | /// - recursiveChildren: Whether to recursively dump this element's children. 73 | /// - Returns: Serializable element structure. 74 | public func dump(recursiveParents: Bool = true, recursiveChildren: Bool = true) async throws -> [String: Any]? { 75 | do { 76 | var root = [String: Any]() 77 | let attributes = try listAttributes() 78 | var attributeValues = [String: Any]() 79 | for attribute in attributes { 80 | guard let value = try getAttribute(attribute) else { 81 | continue 82 | } 83 | attributeValues[attribute] = encode(value: value) 84 | } 85 | root["attributes"] = attributeValues 86 | guard legacyValue as! AXUIElement != AXUIElementCreateSystemWide() else { 87 | return root 88 | } 89 | let parameterizedAttributes = try listParameterizedAttributes() 90 | root["parameterizedAttributes"] = parameterizedAttributes 91 | root["actions"] = try listActions() 92 | if recursiveParents, let parent = try getAttribute("AXParent") as? Element { 93 | root["parent"] = try await parent.dump(recursiveParents: true, recursiveChildren: false) 94 | } 95 | if recursiveChildren, let children = try getAttribute("AXChildren") as? [Any?] { 96 | var resultingChildren = [Any]() 97 | for child in children.lazy.compactMap({$0 as? Element}) { 98 | guard let child = try await child.dump(recursiveParents: false, recursiveChildren: true) else { 99 | continue 100 | } 101 | resultingChildren.append(child) 102 | } 103 | root["children"] = resultingChildren 104 | } 105 | return root 106 | } catch ElementError.invalidElement { 107 | return nil 108 | } catch { 109 | throw error 110 | } 111 | } 112 | 113 | /// Retrieves the set of attributes supported by this element. 114 | /// - Returns: Set of attributes. 115 | public func getAttributeSet() throws -> Set { 116 | let attributes = try listAttributes() 117 | return Set(attributes.lazy.compactMap({ElementAttribute(rawValue: $0)})) 118 | } 119 | 120 | /// Reads the value associated with a given attribute of this element. 121 | /// - Parameter attribute: Attribute whose value is to be read. 122 | /// - Returns: Value of the attribute, if any. 123 | public func getAttribute(_ attribute: ElementAttribute) throws -> Any? { 124 | let output = try getAttribute(attribute.rawValue) 125 | if attribute == .role, let output = output as? String { 126 | return ElementRole(rawValue: output) 127 | } 128 | if attribute == .subrole, let output = output as? String { 129 | return ElementSubrole(rawValue: output) 130 | } 131 | return output 132 | } 133 | 134 | /// Writes a value to the specified attribute of this element. 135 | /// - Parameters: 136 | /// - attribute: Attribute to be written. 137 | /// - value: Value to write. 138 | public func setAttribute(_ attribute: ElementAttribute, value: Any) throws { 139 | return try setAttribute(attribute.rawValue, value: value) 140 | } 141 | 142 | /// Retrieves the set of parameterized attributes supported by this element. 143 | /// - Returns: Set of parameterized attributes. 144 | public func getParameterizedAttributeSet() throws -> Set { 145 | let attributes = try listParameterizedAttributes() 146 | return Set(attributes.lazy.compactMap({ElementParameterizedAttribute(rawValue: $0)})) 147 | } 148 | 149 | /// Queries the specified parameterized attribute of this element. 150 | /// - Parameters: 151 | /// - attribute: Parameterized attribute to query. 152 | /// - input: Input value. 153 | /// - Returns: Output value. 154 | public func queryParameterizedAttribute(_ attribute: ElementParameterizedAttribute, input: Any) throws -> Any? { 155 | return try queryParameterizedAttribute(attribute.rawValue, input: input) 156 | } 157 | 158 | /// Creates a list of all the actions supported by this element. 159 | /// - Returns: List of actions. 160 | public func listActions() throws -> [String] { 161 | let legacyValue = legacyValue as! AXUIElement 162 | var actions: CFArray? 163 | let result = AXUIElementCopyActionNames(legacyValue, &actions) 164 | let error = ElementError(from: result) 165 | switch error { 166 | case .success: 167 | break 168 | case .systemFailure, .illegalArgument: 169 | return [] 170 | case .apiDisabled, .invalidElement, .notImplemented, .timeout: 171 | throw error 172 | default: 173 | fatalError("Unexpected error reading an accessibility elenet's action names: \(error)") 174 | } 175 | guard let actions = [Any?](legacyValue: actions as CFTypeRef) else { 176 | return [] 177 | } 178 | return actions.compactMap({$0 as? String}) 179 | } 180 | 181 | /// Queries for a localized description of the specified action. 182 | /// - Parameter action: Action to query. 183 | /// - Returns: Description of the action. 184 | public func describeAction(_ action: String) throws -> String? { 185 | let legacyValue = legacyValue as! AXUIElement 186 | var description: CFString? 187 | let result = AXUIElementCopyActionDescription(legacyValue, action as CFString, &description) 188 | let error = ElementError(from: result) 189 | switch error { 190 | case .success: 191 | break 192 | case .actionUnsupported, .illegalArgument, .systemFailure: 193 | return nil 194 | case .apiDisabled, .invalidElement, .notImplemented, .timeout: 195 | throw error 196 | default: 197 | fatalError("Unexpected error reading an accessibility element's description for action \(action)") 198 | } 199 | guard let description = description else { 200 | return nil 201 | } 202 | return description as String 203 | } 204 | 205 | /// Performs the specified action on this element. 206 | /// - Parameter action: Action to perform. 207 | public func performAction(_ action: String) throws { 208 | let legacyValue = legacyValue as! AXUIElement 209 | let result = AXUIElementPerformAction(legacyValue, action as CFString) 210 | let error = ElementError(from: result) 211 | switch error { 212 | case .success, .systemFailure, .illegalArgument: 213 | break 214 | case .actionUnsupported, .apiDisabled, .invalidElement, .notImplemented, .timeout: 215 | throw error 216 | default: 217 | fatalError("Unexpected error performing accessibility element action \(action): \(error.localizedDescription)") 218 | } 219 | } 220 | 221 | /// Creates a list of all the known attributes of this element. 222 | /// - Returns: List of attributes. 223 | private func listAttributes() throws -> [String] { 224 | let legacyValue = legacyValue as! AXUIElement 225 | var attributes: CFArray? 226 | let result = AXUIElementCopyAttributeNames(legacyValue, &attributes) 227 | let error = ElementError(from: result) 228 | switch error { 229 | case .success: 230 | break 231 | case .apiDisabled, .invalidElement, .notImplemented, .timeout: 232 | throw error 233 | default: 234 | fatalError("Unexpected error reading an accessibility element's attribute names: \(error)") 235 | } 236 | guard let attributes = [Any?](legacyValue: attributes as CFTypeRef) else { 237 | return [] 238 | } 239 | return attributes.compactMap({$0 as? String}) 240 | } 241 | 242 | /// Reads the value associated with a given attribute of this element. 243 | /// - Parameter attribute: Attribute whose value is to be read. 244 | /// - Returns: Value of the attribute, if any. 245 | private func getAttribute(_ attribute: String) throws -> Any? { 246 | let legacyValue = legacyValue as! AXUIElement 247 | var value: CFTypeRef? 248 | let result = AXUIElementCopyAttributeValue(legacyValue, attribute as CFString, &value) 249 | let error = ElementError(from: result) 250 | switch error { 251 | case .success: 252 | break 253 | case .attributeUnsupported, .noValue, .systemFailure, .illegalArgument: 254 | return nil 255 | case .apiDisabled, .invalidElement, .notImplemented, .timeout: 256 | throw error 257 | default: 258 | fatalError("Unexpected error getting value for accessibility element attribute \(attribute): \(error)") 259 | } 260 | guard let value = value else { 261 | return nil 262 | } 263 | return fromLegacy(value: value) 264 | } 265 | 266 | /// Writes a value to the specified attribute of this element. 267 | /// - Parameters: 268 | /// - attribute: Attribute to be written. 269 | /// - value: Value to write. 270 | private func setAttribute(_ attribute: String, value: Any) throws { 271 | let legacyValue = legacyValue as! AXUIElement 272 | guard let value = value as? any ElementLegacy else { 273 | throw ElementError.illegalArgument 274 | } 275 | let result = AXUIElementSetAttributeValue(legacyValue, attribute as CFString, value.legacyValue as CFTypeRef) 276 | let error = ElementError(from: result) 277 | switch error { 278 | case .success, .systemFailure, .attributeUnsupported, .illegalArgument: 279 | break 280 | case .apiDisabled, .invalidElement, .notEnoughPrecision, .notImplemented, .timeout: 281 | throw error 282 | default: 283 | fatalError("Unexpected error setting accessibility element attribute \(attribute): \(error)") 284 | } 285 | } 286 | 287 | /// Lists the parameterized attributes available to this element. 288 | /// - Returns: List of parameterized attributes. 289 | private func listParameterizedAttributes() throws -> [String] { 290 | let legacyValue = legacyValue as! AXUIElement 291 | var parameterizedAttributes: CFArray? 292 | let result = AXUIElementCopyParameterizedAttributeNames(legacyValue, ¶meterizedAttributes) 293 | let error = ElementError(from: result) 294 | switch error { 295 | case .success: 296 | break 297 | case .apiDisabled, .invalidElement, .notImplemented, .timeout: 298 | throw error 299 | default: 300 | fatalError("Unexpected error reading an accessibility element's parameterized attribute names: \(error)") 301 | } 302 | guard let parameterizedAttributes = [Any?](legacyValue: parameterizedAttributes as CFTypeRef) else { 303 | return [] 304 | } 305 | return parameterizedAttributes.compactMap({$0 as? String}) 306 | } 307 | 308 | /// Queries the specified parameterized attribute of this element. 309 | /// - Parameters: 310 | /// - attribute: Parameterized attribute to query. 311 | /// - input: Input value. 312 | /// - Returns: Output value. 313 | private func queryParameterizedAttribute(_ attribute: String, input: Any) throws -> Any? { 314 | let legacyValue = legacyValue as! AXUIElement 315 | guard let input = input as? any ElementLegacy else { 316 | throw ElementError.illegalArgument 317 | } 318 | var output: CFTypeRef? 319 | let result = AXUIElementCopyParameterizedAttributeValue(legacyValue, attribute as CFString, input.legacyValue as CFTypeRef, &output) 320 | let error = ElementError(from: result) 321 | switch error { 322 | case .success: 323 | break 324 | case .noValue, .parameterizedAttributeUnsupported, .systemFailure, .illegalArgument: 325 | return nil 326 | case .apiDisabled, .invalidElement, .notEnoughPrecision, .notImplemented, .timeout: 327 | throw error 328 | default: 329 | fatalError("Unrecognized error querying parameterized accessibility element attribute \(attribute): \(error)") 330 | } 331 | return fromLegacy(value: output) 332 | } 333 | 334 | /// Encodes a value into a format suitable to be serialized. 335 | /// - Parameter value: Value to encode. 336 | /// - Returns: Data structure suitable to be serialized. 337 | private func encode(value: Any) -> Any? { 338 | switch value { 339 | case is Bool, is Int64, is Double, is String: 340 | return value 341 | case let array as [Any?]: 342 | var resultArray = [Any]() 343 | resultArray.reserveCapacity(array.count) 344 | for element in array { 345 | guard let element = element, let element = encode(value: element) else { 346 | continue 347 | } 348 | resultArray.append(element) 349 | } 350 | return resultArray 351 | case let dictionary as [String: Any]: 352 | var resultDictionary = [String: Any]() 353 | resultDictionary.reserveCapacity(dictionary.count) 354 | for pair in dictionary { 355 | guard let value = encode(value: pair.value) else { 356 | continue 357 | } 358 | resultDictionary[pair.key] = value 359 | } 360 | return resultDictionary 361 | case let url as URL: 362 | return url.absoluteString 363 | case let attributedString as AttributedString: 364 | return String(attributedString.characters) 365 | case let point as CGPoint: 366 | return ["x": point.x, "y": point.y] 367 | case let size as CGSize: 368 | return ["width": size.width, "height": size.height] 369 | case let rect as CGRect: 370 | return ["x": rect.origin.x, "y": rect.origin.y, "width": rect.size.width, "height": rect.size.height] 371 | case let element as Element: 372 | return String(describing: element.legacyValue) 373 | case let error as ElementError: 374 | return "Error: \(error.localizedDescription)" 375 | default: 376 | return nil 377 | } 378 | } 379 | 380 | /// Checks whether this process is trusted and prompts the user to grant it accessibility privileges if it isn't. 381 | /// - Returns: Whether this process has accessibility privileges. 382 | @MainActor public static func confirmProcessTrustedStatus() -> Bool { 383 | return AXIsProcessTrustedWithOptions([kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String: true] as CFDictionary) 384 | } 385 | } 386 | 387 | extension Element: Hashable { 388 | public nonisolated func hash(into hasher: inout Hasher) { 389 | hasher.combine(legacyValue as! AXUIElement) 390 | } 391 | 392 | public static nonisolated func ==(_ lhs: Element, _ rhs: Element) -> Bool { 393 | let lhs = lhs.legacyValue as! AXUIElement 394 | let rhs = rhs.legacyValue as! AXUIElement 395 | return lhs == rhs 396 | } 397 | } 398 | -------------------------------------------------------------------------------- /Sources/Element/ElementActor.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// Dedicated thread to ensure that all interactions with the accessibility client interface run free of race conditions. 4 | @globalActor public actor ElementActor { 5 | /// Shared singleton. 6 | public static let shared = ElementActor() 7 | /// Executor used by this actor. 8 | public static let sharedUnownedExecutor = Executor.shared.asUnownedSerialExecutor() 9 | 10 | /// Singleton initializer. 11 | private init() {} 12 | 13 | /// Convenience method that schedules a function to run on this actor's dedicated thread. 14 | /// - Parameters: 15 | /// - resultType: Return type of the scheduled function. 16 | /// - run: Function to run. 17 | /// - Returns: Whatever the function returns. 18 | public static func run(resultType _: T.Type = T.self, body run: @ElementActor () throws -> T) async rethrows -> T { 19 | return try await run() 20 | } 21 | 22 | /// Custom executor supporting ``ElementActor``. 23 | public final class Executor: SerialExecutor, @unchecked Sendable { 24 | /// Dedicated thread on which this executor will schedule jobs. 25 | // This object is not Sendable, but it's also never dereferenced from a different thread after being constructed. 26 | private var thread: Thread! 27 | /// Run loop that provides the actual scheduling. 28 | // Run loops are generally not thread-safe, but their perform method is. 29 | private var runLoop: RunLoop! 30 | /// Singleton of this executor. 31 | public static let shared = Executor() 32 | 33 | /// Singleton initializer. 34 | private init() { 35 | // Use an NSConditionLock as a poor man's barrier to prevent the initializer from returning before the thread starts and a run loop is assigned. 36 | let lock = NSConditionLock(condition: 0) 37 | thread = Thread() {[self] in 38 | lock.lock(whenCondition: 0) 39 | runLoop = RunLoop.current 40 | lock.unlock(withCondition: 1) 41 | runLoop.run() 42 | } 43 | lock.lock(whenCondition: 1) 44 | thread.name = "Element" 45 | lock.unlock(withCondition: 0) 46 | } 47 | 48 | /// Schedules a job to be perform by this executor. 49 | /// - Parameter job: Job to be scheduled. 50 | public func enqueue(_ job: consuming ExecutorJob) { 51 | // I don't think this code is sound, but it is suggested in the custom actor executors Swift Evolution proposal. 52 | let job = UnownedJob(job) 53 | runLoop.perform({[unowned self] in job.runSynchronously(on: asUnownedSerialExecutor())}) 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /Sources/Element/ElementAttribute.swift: -------------------------------------------------------------------------------- 1 | /// Non-comprehensive list of element attributes. 2 | public enum ElementAttribute: String { 3 | // Informational attributes. 4 | case role = "AXRole" 5 | case subrole = "AXSubrole" 6 | case roleDescription = "AXRoleDescription" 7 | case title = "AXTitle" 8 | case description = "AXDescription" 9 | case help = "AXHelp" 10 | 11 | // Hierarchical relationship attributes. 12 | case parentElement = "AXParent" 13 | case childElements = "AXChildren" 14 | case childElementsInNavigationOrder = "AXChildrenInNavigationOrder" 15 | case selectedChildrenElements = "AXSelectedChildren" 16 | case visibleChildrenElements = "AXVisibleChildren" 17 | case windowElement = "AXWindow" 18 | case topLevelElement = "AXTopLevelUIElement" 19 | case titleElement = "AXTitleUIElement" 20 | case servesAsTitleForElement = "AXServesAsTitleForUIElement" 21 | case linkedElements = "AXLinkedUIElements" 22 | case sharedFocusElements = "AXSharedFocusElements" 23 | case focusableAncestor = "AXFocusableAncestor" 24 | 25 | // Visual state attributes. 26 | case isEnabled = "AXEnabled" 27 | case isFocused = "AXFocused" 28 | case position = "AXPosition" 29 | case size = "AXSize" 30 | 31 | // Value attributes. 32 | case value = "AXValue" 33 | case valueDescription = "AXValueDescription" 34 | case minValue = "AXMinValue" 35 | case maxValue = "AXMaxValue" 36 | case valueIncrement = "AXValueIncrement" 37 | case valueWraps = "AXValueWraps" 38 | case allowedValues = "AXAllowedValues" 39 | case placeholderValue = "AXPlaceholderValue" 40 | 41 | // Text-specific attributes. 42 | case selectedText = "AXSelectedText" 43 | case selectedTextRange = "AXSelectedTextRange" 44 | case selectedTextRanges = "AXSelectedTextRanges" 45 | case visibleTextRange = "AXVisibleTextRange" 46 | case numberOfCharacters = "AXNumberOfCharacters" 47 | case sharedTextElements = "AXSharedTextUIElements" 48 | case sharedCharacteRange = "AXSharedCharacterRange" 49 | case insertionPointLineNumber = "AXInsertionPointLineNumber" 50 | 51 | // Top-level element attributes. 52 | case isMain = "AXMain" 53 | case isMinimized = "AXMinimized" 54 | case closeButton = "AXCloseButton" 55 | case zoomButton = "AXZoomButton" 56 | case minimizeButton = "AXMinimizeButton" 57 | case toolbar = "AXToolbarButton" 58 | case fullScreenButton = "AXFullScreenButton" 59 | case proxy = "AXProxy" 60 | case growArea = "AXGrowArea" 61 | case isModal = "AXModal" 62 | case defaultButton = "AXDefaultButton" 63 | case cancelButton = "AXCancelButton" 64 | 65 | // Menu-specific attributes. 66 | case menuItemCmdChar = "AXMenuItemCmdChar" 67 | case menuItemCmdVirtualKey = "AXMenuItemCmdVirtualKey" 68 | case menuItemCmdGlyph = "AXMenuItemCmdGlyph" 69 | case menuItemCmdModifiers = "AXMenuItemCmdModifiers" 70 | case menuItemMarkChar = "AXMenuItemMarkChar" 71 | case menuItemPrimaryElement = "AXMenuItemPrimaryUIElement" 72 | 73 | // Attributes specific to application elements. 74 | case menuBar = "AXMenuBar" 75 | case windows = "AXWindows" 76 | case frontmostWindow = "AXFrontmost" 77 | case hidden = "AXHidden" 78 | case mainWindow = "AXMainWindow" 79 | case focusedWindow = "AXFocusedWindow" 80 | case focusedElement = "AXFocusedUIElement" 81 | case extrasMenuBar = "AXExtrasMenuBar" 82 | 83 | // Table attributes. 84 | case rows = "AXRows" 85 | case visibleRows = "AXVisibleRows" 86 | case selectedRows = "AXSelectedRows" 87 | case columns = "AXColumns" 88 | case visibleColumns = "AXVisibleColumns" 89 | case selectedColumns = "AXSelectedColumns" 90 | case selectedCells = "AXSelectedCells" 91 | case sortDirection = "AXSortDirection" 92 | case columnHeaderElements = "AXColumnHeaderUIElements" 93 | case index = "AXIndex" 94 | case disclosing = "AXDisclosing" 95 | case disclosedRows = "AXDisclosedRows" 96 | case disclosedByRow = "AXDisclosedByRow" 97 | 98 | // Role-specific attributes. 99 | case horizontalScrollBar = "AXHorizontalScrollBar" 100 | case verticalScrollBar = "AXVerticalScrollBar" 101 | case orientation = "AXOrientation" 102 | case header = "AXHeader" 103 | case edited = "AXEdited" 104 | case tabs = "AXTabs" 105 | case overflowButton = "AXOverflowButton" 106 | case fileName = "AXFilenameAttribute" 107 | case expanded = "AXExpanded" 108 | case selected = "AXSelected" 109 | case splitters = "AXSplitters" 110 | case contents = "AXContents" 111 | case nextContents = "AXNextContents" 112 | case previousContents = "AXPreviousContents" 113 | case document = "AXDocument" 114 | case incrementer = "AXIncrementer" 115 | case decrementButton = "AXDecrementButton" 116 | case incrementButton = "AXIncrementButton" 117 | case columnTitle = "AXColumnTitle" 118 | case url = "AXURL" 119 | case labelValue = "AXLabelValue" 120 | case shownMenuElement = "AXShownMenuUIElement" 121 | case focusedApplication = "AXFocusedApplication" 122 | case elementBusy = "AXElementBusy" 123 | case alternateUIVisible = "AXAlternateUIVisible" 124 | case isApplicationRunning = "AXIsApplicationRunning" 125 | case searchButton = "AXSearchButton" 126 | case clearButton = "AXClearButton" 127 | 128 | // Level indicator attributes. 129 | case warningValue = "AXWarningValue" 130 | case criticalValue = "AXCriticalValue" 131 | } 132 | -------------------------------------------------------------------------------- /Sources/Element/ElementError.swift: -------------------------------------------------------------------------------- 1 | import ApplicationServices 2 | 3 | /// Translator of legacy ``AXError`` values to a Swift type. 4 | public enum ElementError: Error, CustomStringConvertible { 5 | case success 6 | case systemFailure 7 | case illegalArgument 8 | case invalidElement 9 | case invalidObserver 10 | case timeout 11 | case attributeUnsupported 12 | case actionUnsupported 13 | case notificationUnsupported 14 | case notImplemented 15 | case notificationAlreadyRegistered 16 | case notificationNotRegistered 17 | case apiDisabled 18 | case noValue 19 | case parameterizedAttributeUnsupported 20 | case notEnoughPrecision 21 | 22 | public var description: String { 23 | switch self { 24 | case .success: 25 | return "Success" 26 | case .systemFailure: 27 | return "System failure" 28 | case .illegalArgument: 29 | return "Illegal argument" 30 | case .invalidElement: 31 | return "Invalid element" 32 | case .invalidObserver: 33 | return "Invalid observer" 34 | case .timeout: 35 | return "Request timed out" 36 | case .attributeUnsupported: 37 | return "Attribute unsupported" 38 | case .actionUnsupported: 39 | return "Action unsupported" 40 | case .notificationUnsupported: 41 | return "Notification unsupported" 42 | case .parameterizedAttributeUnsupported: 43 | return "Parameterized attribute unsupported" 44 | case .notImplemented: 45 | return "Accessibility not supported" 46 | case .notificationAlreadyRegistered: 47 | return "Notification already registered" 48 | case .notificationNotRegistered: 49 | return "Notification not registered" 50 | case .apiDisabled: 51 | return "Accessibility API disabled" 52 | case .noValue: 53 | return "No value" 54 | case .notEnoughPrecision: 55 | return "Not enough precision" 56 | } 57 | } 58 | 59 | /// Creates a new accessibility error from a legacy error value. 60 | /// - Parameter error: Legacy error value. 61 | init(from error: AXError) { 62 | switch error { 63 | case .success: 64 | self = .success 65 | case .failure: 66 | self = .systemFailure 67 | case .illegalArgument: 68 | self = .illegalArgument 69 | case .invalidUIElement: 70 | self = .invalidElement 71 | case .invalidUIElementObserver: 72 | self = .invalidObserver 73 | case .cannotComplete: 74 | self = .timeout 75 | case .attributeUnsupported: 76 | self = .attributeUnsupported 77 | case .actionUnsupported: 78 | self = .actionUnsupported 79 | case .notificationUnsupported: 80 | self = .notificationUnsupported 81 | case .parameterizedAttributeUnsupported: 82 | self = .parameterizedAttributeUnsupported 83 | case .notImplemented: 84 | self = .notImplemented 85 | case .notificationAlreadyRegistered: 86 | self = .notificationAlreadyRegistered 87 | case .notificationNotRegistered: 88 | self = .notificationNotRegistered 89 | case .apiDisabled: 90 | self = .apiDisabled 91 | case .noValue: 92 | self = .noValue 93 | case .notEnoughPrecision: 94 | self = .notEnoughPrecision 95 | @unknown default: 96 | fatalError("Unrecognized AXError case") 97 | } 98 | } 99 | 100 | /// Converts this error to a legacy error value. 101 | func toAXError() -> AXError { 102 | switch self { 103 | case .success: 104 | return .success 105 | case .systemFailure: 106 | return .failure 107 | case .illegalArgument: 108 | return .illegalArgument 109 | case .invalidElement: 110 | return .invalidUIElement 111 | case .invalidObserver: 112 | return .invalidUIElementObserver 113 | case .timeout: 114 | return .cannotComplete 115 | case .attributeUnsupported: 116 | return .attributeUnsupported 117 | case .actionUnsupported: 118 | return .actionUnsupported 119 | case .notificationUnsupported: 120 | return .notificationUnsupported 121 | case .parameterizedAttributeUnsupported: 122 | return .parameterizedAttributeUnsupported 123 | case .notImplemented: 124 | return .notImplemented 125 | case .notificationAlreadyRegistered: 126 | return .notificationAlreadyRegistered 127 | case .notificationNotRegistered: 128 | return .notificationNotRegistered 129 | case .apiDisabled: 130 | return .apiDisabled 131 | case .noValue: 132 | return .noValue 133 | case .notEnoughPrecision: 134 | return .notEnoughPrecision 135 | } 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /Sources/Element/ElementEvent.swift: -------------------------------------------------------------------------------- 1 | /// Wrapper around an event produced by the legacy consumer accessibility API. 2 | public struct ElementEvent { 3 | /// Event notification. 4 | public let notification: ElementNotification 5 | /// Element generating this event. 6 | public let subject: Element 7 | /// Event payload. 8 | public let payload: [PayloadKey: Any]? 9 | 10 | /// Creates an event for the specified notification, related to the specified subject, and with the specified payload. 11 | /// - Parameters: 12 | /// - notification: Notification that triggered this event. 13 | /// - subject: Element to which this notification belongs. 14 | /// - payload: Additional data sent with the notification. 15 | init?(notification: String, subject: Element, payload: [String: Any]) { 16 | guard let notification = ElementNotification(rawValue: notification) else { 17 | return nil 18 | } 19 | let payload = payload.reduce([PayloadKey: Any]()) {(previous, value) in 20 | guard let key = PayloadKey(rawValue: value.key) else { 21 | return previous 22 | } 23 | var next = previous 24 | next[key] = value.value 25 | return previous 26 | } 27 | self.notification = notification 28 | self.subject = subject 29 | self.payload = payload 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Sources/Element/ElementLegacy.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import ApplicationServices 3 | 4 | /// Declares the required functionality for any Swift type that can be converted to and from a CoreFoundation type. 5 | protocol ElementLegacy { 6 | /// Initializes a new Swift type by converting from a legacy CoreFoundation type. 7 | init?(legacyValue value: CFTypeRef) 8 | /// Converts this Swift type to a legacy CoreFoundation type. 9 | var legacyValue: CFTypeRef {get} 10 | } 11 | 12 | extension Optional where Wrapped: ElementLegacy { 13 | init?(legacyValue value: CFTypeRef) { 14 | guard CFGetTypeID(value) != CFNullGetTypeID() else { 15 | return nil 16 | } 17 | self = Wrapped(legacyValue: value) 18 | } 19 | 20 | var legacyValue: CFTypeRef { 21 | switch self { 22 | case .some(let value): 23 | return value.legacyValue as CFTypeRef 24 | case .none: 25 | return kCFNull 26 | } 27 | } 28 | } 29 | 30 | extension Bool: ElementLegacy { 31 | init?(legacyValue value: CFTypeRef) { 32 | guard CFGetTypeID(value) == CFBooleanGetTypeID() else { 33 | return nil 34 | } 35 | let boolean = unsafeBitCast(value, to: CFBoolean.self) 36 | self = CFBooleanGetValue(boolean) 37 | } 38 | 39 | var legacyValue: CFTypeRef { 40 | return self ? kCFBooleanTrue : kCFBooleanFalse 41 | } 42 | } 43 | 44 | extension Int64: ElementLegacy { 45 | init?(legacyValue value: CFTypeRef) { 46 | guard CFGetTypeID(value) == CFNumberGetTypeID() else { 47 | return nil 48 | } 49 | let number = unsafeBitCast(value, to: CFNumber.self) 50 | var integer = Int64(0) 51 | guard CFNumberGetValue(number, .sInt64Type, &integer) else { 52 | return nil 53 | } 54 | guard let integer = Self(exactly: integer) else { 55 | return nil 56 | } 57 | self = integer 58 | } 59 | 60 | var legacyValue: CFTypeRef { 61 | var integer = self 62 | return CFNumberCreate(nil, .sInt64Type, &integer) 63 | } 64 | } 65 | 66 | extension Double: ElementLegacy { 67 | init?(legacyValue value: CFTypeRef) { 68 | guard CFGetTypeID(value) == CFNumberGetTypeID() else { 69 | return nil 70 | } 71 | let number = unsafeBitCast(value, to: CFNumber.self) 72 | var float = Double(0.0) 73 | guard CFNumberGetValue(number, .doubleType, &float) else { 74 | return nil 75 | } 76 | guard let float = Self(exactly: float) else { 77 | return nil 78 | } 79 | self = float 80 | } 81 | 82 | var legacyValue: CFTypeRef { 83 | var float = self 84 | return CFNumberCreate(nil, .doubleType, &float) 85 | } 86 | } 87 | 88 | extension String: ElementLegacy { 89 | init?(legacyValue value: CFTypeRef) { 90 | guard CFGetTypeID(value) == CFStringGetTypeID() else { 91 | return nil 92 | } 93 | self = unsafeBitCast(value, to: CFString.self) as String 94 | } 95 | 96 | var legacyValue: CFTypeRef { 97 | return self as CFString 98 | } 99 | } 100 | 101 | extension [Any?]: ElementLegacy { 102 | init?(legacyValue value: CFTypeRef) { 103 | guard CFGetTypeID(value) == CFArrayGetTypeID() else { 104 | return nil 105 | } 106 | let array = unsafeBitCast(value, to: CFArray.self) as! Array 107 | self = Self() 108 | self.reserveCapacity(array.count) 109 | for element in array { 110 | self.append(fromLegacy(value: element as CFTypeRef)) 111 | } 112 | } 113 | 114 | var legacyValue: CFTypeRef { 115 | return self as CFArray 116 | } 117 | } 118 | 119 | extension [String: Any]: ElementLegacy { 120 | init?(legacyValue value: CFTypeRef) { 121 | guard CFGetTypeID(value) == CFDictionaryGetTypeID() else { 122 | return nil 123 | } 124 | let dictionary = unsafeBitCast(value, to: CFDictionary.self) as! Self 125 | self = Self() 126 | self.reserveCapacity(dictionary.count) 127 | for pair in dictionary { 128 | guard let key = fromLegacy(value: pair.key as CFTypeRef) as? String, let value = fromLegacy(value: pair.value as CFTypeRef) else { 129 | continue 130 | } 131 | self[key] = value 132 | } 133 | } 134 | 135 | var legacyValue: CFTypeRef { 136 | return self as CFDictionary 137 | } 138 | } 139 | 140 | extension URL: ElementLegacy { 141 | init?(legacyValue value: CFTypeRef) { 142 | guard CFGetTypeID(value) == CFURLGetTypeID() else { 143 | return nil 144 | } 145 | let url = unsafeBitCast(value, to: CFURL.self) 146 | self = url as URL 147 | } 148 | 149 | var legacyValue: CFTypeRef { 150 | return self as CFURL 151 | } 152 | } 153 | 154 | extension AttributedString: ElementLegacy { 155 | init?(legacyValue value: CFTypeRef) { 156 | guard CFGetTypeID(value) == CFAttributedStringGetTypeID() else { 157 | return nil 158 | } 159 | let attributedString = unsafeBitCast(value, to: CFAttributedString.self) as NSAttributedString 160 | self = AttributedString(attributedString as NSAttributedString) 161 | } 162 | 163 | var legacyValue: CFTypeRef { 164 | return NSAttributedString(self) as CFAttributedString 165 | } 166 | } 167 | 168 | extension Range: ElementLegacy where Bound == Int { 169 | init?(legacyValue value: CFTypeRef) { 170 | guard CFGetTypeID(value) == AXValueGetTypeID() else { 171 | return nil 172 | } 173 | let value = unsafeBitCast(value, to: AXValue.self) 174 | var range = CFRangeMake(0, 0) 175 | guard AXValueGetValue(value, .cfRange, &range) else { 176 | return nil 177 | } 178 | self = Int(range.location) ..< Int(range.location + range.length) 179 | } 180 | 181 | var legacyValue: CFTypeRef { 182 | var range = CFRangeMake(self.lowerBound, self.upperBound - self.lowerBound) 183 | return AXValueCreate(.cfRange, &range)! 184 | } 185 | } 186 | 187 | extension CGPoint: ElementLegacy { 188 | init?(legacyValue value: CFTypeRef) { 189 | guard CFGetTypeID(value) == AXValueGetTypeID() else { 190 | return nil 191 | } 192 | let value = unsafeBitCast(value, to: AXValue.self) 193 | var point = CGPoint.zero 194 | guard AXValueGetValue(value, .cgPoint, &point) else { 195 | return nil 196 | } 197 | self = point 198 | } 199 | 200 | var legacyValue: CFTypeRef { 201 | var point = self 202 | return AXValueCreate(.cgPoint, &point)! 203 | } 204 | } 205 | 206 | extension CGSize: ElementLegacy { 207 | init?(legacyValue value: CFTypeRef) { 208 | guard CFGetTypeID(value) == AXValueGetTypeID() else { 209 | return nil 210 | } 211 | let value = unsafeBitCast(value, to: AXValue.self) 212 | var size = CGSize.zero 213 | guard AXValueGetValue(value, .cgSize, &size) else { 214 | return nil 215 | } 216 | self = size 217 | } 218 | 219 | var legacyValue: CFTypeRef { 220 | var size = self 221 | return AXValueCreate(.cgSize, &size)! 222 | } 223 | } 224 | 225 | extension CGRect: ElementLegacy { 226 | init?(legacyValue value: CFTypeRef) { 227 | guard CFGetTypeID(value) == AXValueGetTypeID() else { 228 | return nil 229 | } 230 | let value = unsafeBitCast(value, to: AXValue.self) 231 | var rect = CGRect.zero 232 | guard AXValueGetValue(value, .cgRect, &rect) else { 233 | return nil 234 | } 235 | self = rect 236 | } 237 | 238 | var legacyValue: CFTypeRef { 239 | var rect = self 240 | return AXValueCreate(.cgRect, &rect)! 241 | } 242 | } 243 | 244 | extension ElementError: ElementLegacy { 245 | init?(legacyValue value: CFTypeRef) { 246 | guard CFGetTypeID(value) == AXValueGetTypeID() else { 247 | return nil 248 | } 249 | let value = unsafeBitCast(value, to: AXValue.self) 250 | var error = AXError.success 251 | guard AXValueGetValue(value, .axError, &error) else { 252 | return nil 253 | } 254 | self = ElementError(from: error) 255 | } 256 | 257 | var legacyValue: CFTypeRef { 258 | var error = self.toAXError() 259 | return AXValueCreate(.axError, &error)! 260 | } 261 | } 262 | 263 | extension Element: ElementLegacy {} 264 | 265 | /// Converts a value from any known legacy type to a Swift type. 266 | /// - Parameter value: Value to convert. 267 | /// - Returns: Converted Swift value. 268 | func fromLegacy(value: CFTypeRef?) -> Any? { 269 | guard let value = value else { 270 | return nil 271 | } 272 | guard CFGetTypeID(value) != CFNullGetTypeID() else { 273 | return nil 274 | } 275 | if let boolean = Bool(legacyValue: value) { 276 | return boolean 277 | } 278 | if let integer = Int64(legacyValue: value) { 279 | return integer 280 | } 281 | if let float = Double(legacyValue: value) { 282 | return float 283 | } 284 | if let string = String(legacyValue: value) { 285 | return string 286 | } 287 | if let array = [Any?](legacyValue: value) { 288 | return array 289 | } 290 | if let dictionary = [String: Any](legacyValue: value) { 291 | return dictionary 292 | } 293 | if let url = URL(legacyValue: value) { 294 | return url 295 | } 296 | if let attributedString = AttributedString(legacyValue: value) { 297 | return attributedString 298 | } 299 | if let range = Range(legacyValue: value) { 300 | return range 301 | } 302 | if let point = CGPoint(legacyValue: value) { 303 | return point 304 | } 305 | if let size = CGSize(legacyValue: value) { 306 | return size 307 | } 308 | if let rect = CGRect(legacyValue: value) { 309 | return rect 310 | } 311 | if let error = ElementError(legacyValue: value) { 312 | return error 313 | } 314 | if let element = Element(legacyValue: value) { 315 | return element 316 | } 317 | return nil 318 | } 319 | -------------------------------------------------------------------------------- /Sources/Element/ElementNotification.swift: -------------------------------------------------------------------------------- 1 | /// Event notifications. 2 | public enum ElementNotification: String { 3 | // Keyboard focus events. 4 | case windowDidGetFocus = "AXFocusedWindowChanged" 5 | case elementDidGetFocus = "AXFocusedUIElementChanged" 6 | 7 | // Application events. 8 | case applicationDidBecomeActive = "AXApplicationActivated" 9 | case applicationDidBecomeInactive = "AXApplicationDeactivated" 10 | case applicationDidHide = "AXApplicationHidden" 11 | case applicationDidShow = "AXApplicationShown" 12 | 13 | // Top-level element events. 14 | case windowDidAppear = "AXWindowCreated" 15 | case windowDidMove = "AXWindowMoved" 16 | case windowDidResize = "AXWindowResized" 17 | case windowDidMinimize = "AXWindowMiniaturized" 18 | case windowDidRestore = "AXWindowDeminiaturized" 19 | case drawerDidSpawn = "AXDrawerCreated" 20 | case sheetDidSpawn = "AXSheetCreated" 21 | case helpTagDidSpawn = "AXHelpTagCreated" 22 | 23 | // Menu events. 24 | case menuDidOpen = "AXMenuOpened" 25 | case menuDidClose = "AXMenuClosed" 26 | case menuDidSelectItem = "AXMenuItemSelected" 27 | 28 | // Table and outline events. 29 | case rowCountDidUpdate = "AXRowCountChanged" 30 | case rowDidExpand = "AXRowExpanded" 31 | case rowDidCollapse = "AXRowCollapsed" 32 | case cellSelectionDidUpdate = "AXSelectedCellsChanged" 33 | case rowSelectionDidUpdate = "AXSelectedRowsChanged" 34 | case columnSelectionDidUpdate = "AXSelectedColumnsChanged" 35 | 36 | // Generic element and hierarchy events. 37 | case elementDidAppear = "AXCreated" 38 | case elementDidDisappear = "AXUIElementDestroyed" 39 | case elementBusyStatusDidUpdate = "AXElementBusyChanged" 40 | case elementDidResize = "AXResized" 41 | case elementDidMove = "AXMoved" 42 | case selectedChildrenDidMove = "AXSelectedChildrenMoved" 43 | case childrenSelectionDidUpdate = "AXSelectedChildrenChanged" 44 | case textSelectionDidUpdate = "AXSelectedTextChanged" 45 | case titleDidUpdate = "AXTitleChanged" 46 | case valueDidUpdate = "AXValueChanged" 47 | 48 | // Layout events. 49 | case unitsDidUpdate = "AXUnitsChanged" 50 | case layoutDidChange = "AXLayoutChanged" 51 | 52 | // Announcement events. 53 | case applicationDidAnnounce = "AXAnnouncementRequested" 54 | } 55 | 56 | /// Non-comprehensive list of payload keys. 57 | public enum PayloadKey: String { 58 | case announcement = "AXAnnouncement" 59 | } 60 | -------------------------------------------------------------------------------- /Sources/Element/ElementObserver.swift: -------------------------------------------------------------------------------- 1 | import ApplicationServices 2 | 3 | /// Observes an application's accessibility element for events, passing them down to their respective subscribers. 4 | @ElementActor public final class ElementObserver { 5 | /// Event stream. 6 | public let eventStream: AsyncStream 7 | /// Legacy accessibility observer. 8 | private let observer: AXObserver 9 | /// Legacy accessibility element. 10 | private let element: AXUIElement 11 | /// Event stream continuation. 12 | private let eventContinuation: AsyncStream.Continuation 13 | 14 | /// Creates a new accessibility observer for the specified element. 15 | /// - Parameter element: Element to observe. 16 | public init(element: Element) async throws { 17 | self.element = element.legacyValue as! AXUIElement 18 | let processIdentifier = try element.getProcessIdentifier() 19 | var observer: AXObserver? 20 | let callBack: AXObserverCallbackWithInfo = {(_, element, notification, info, this) in 21 | let this = Unmanaged.fromOpaque(this!).takeUnretainedValue() 22 | let notification = notification as String 23 | let subject = Element(legacyValue: element)! 24 | // The following hack is necessary since info is optional but isn't marked as such. 25 | let payload = unsafeBitCast(info, to: Int.self) != 0 ? [String: Any](legacyValue: info) : nil 26 | let event = ElementEvent(notification: notification, subject: subject, payload: payload ?? [:])! 27 | this.eventContinuation.yield(event) 28 | } 29 | let result = AXObserverCreateWithInfoCallback(processIdentifier, callBack, &observer) 30 | let error = ElementError(from: result) 31 | guard error == .success, let observer = observer else { 32 | switch error { 33 | case .apiDisabled, .notImplemented, .timeout: 34 | throw error 35 | default: 36 | fatalError("Unexpected error creating an accessibility element observer: \(error)") 37 | } 38 | } 39 | self.observer = observer 40 | (eventStream, eventContinuation) = AsyncStream.makeStream() 41 | await MainActor.run() { 42 | let runLoopSource = AXObserverGetRunLoopSource(observer) 43 | CFRunLoopAddSource(CFRunLoopGetCurrent(), runLoopSource, .defaultMode) 44 | } 45 | } 46 | 47 | /// Subscribes to be notified of specific state changes of the observed element. 48 | /// - Parameter notification: Notification to subscribe. 49 | public func subscribe(to notification: ElementNotification) throws { 50 | let result = AXObserverAddNotification(observer, element, notification.rawValue as CFString, Unmanaged.passUnretained(self).toOpaque()) 51 | let error = ElementError(from: result) 52 | switch error { 53 | case .success, .notificationAlreadyRegistered: 54 | break 55 | case .apiDisabled, .invalidElement, .notificationUnsupported, .timeout: 56 | throw error 57 | default: 58 | fatalError("Unexpected error registering accessibility element notification \(notification.rawValue): \(error)") 59 | } 60 | } 61 | 62 | /// Unsubscribes from the specified notification of state changes to the observed element. 63 | /// - Parameter notification: Notification to unsubscribe. 64 | public func unsubscribe(from notification: ElementNotification) throws { 65 | let result = AXObserverRemoveNotification(observer, element, notification.rawValue as CFString) 66 | let error = ElementError(from: result) 67 | switch error { 68 | case .success, .notificationNotRegistered: 69 | break 70 | case .apiDisabled, .invalidElement, .notificationUnsupported, .timeout: 71 | throw error 72 | default: 73 | fatalError("Unexpected error unregistering accessibility element notification \(notification.rawValue): \(error)") 74 | } 75 | } 76 | 77 | deinit { 78 | eventContinuation.finish() 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /Sources/Element/ElementParameterizedAttribute.swift: -------------------------------------------------------------------------------- 1 | /// Parameterized attribute queries. 2 | public enum ElementParameterizedAttribute: String { 3 | // Text attributes. 4 | case lineForIndex = "AXLineForIndex" 5 | case rangeForLine = "AXRangeForLine" 6 | case stringForRange = "AXStringForRange" 7 | case rangeForPosition = "AXRangeForPosition" 8 | case rangeForIndex = "AXRangeForIndex" 9 | case boundsForRange = "AXBoundsForRange" 10 | case rtfForRange = "AXRTFForRange" 11 | case attributedStringForRange = "AXAttributedStringForRange" 12 | case styleRangeForIndex = "AXStyleRangeForIndex" 13 | 14 | // Table cell attributes. 15 | case cellForColumnAndRow = "AXCellForColumnAndRow" 16 | 17 | // Layout attributes. 18 | case layoutPointForScreenPoint = "AXLayoutPointForScreenPoint" 19 | case layoutSizeForScreenSize = "AXLayoutSizeForScreenSize" 20 | case screenPointForLayoutPoint = "AXScreenPointForLayoutPoint" 21 | case screenSizeForLayoutSize = "AXScreenSizeForLayoutSize" 22 | } 23 | -------------------------------------------------------------------------------- /Sources/Element/ElementRole.swift: -------------------------------------------------------------------------------- 1 | /// Non-comprehensive role list. 2 | public enum ElementRole: String { 3 | case application = "AXApplication" 4 | case systemWide = "AXSystemWide" 5 | case window = "AXWindow" 6 | case sheet = "AXSheet" 7 | case drawer = "AXDrawer" 8 | case growArea = "AXGrowArea" 9 | case image = "AXImage" 10 | case unknown = "AXUnknown" 11 | case button = "AXButton" 12 | case radioButton = "AXRadioButton" 13 | case checkBox = "AXCheckBox" 14 | case popUpButton = "AXPopUpButton" 15 | case menuButton = "AXMenuButton" 16 | case tabGroup = "AXTabGroup" 17 | case table = "AXTable" 18 | case column = "AXColumn" 19 | case row = "AXRow" 20 | case outline = "AXOutline" 21 | case browser = "AXBrowser" 22 | case scrollArea = "AXScrollArea" 23 | case scrollBar = "AXScrollBar" 24 | case radioGroup = "AXRadioGroup" 25 | case list = "AXList" 26 | case group = "AXGroup" 27 | case valueIndicator = "AXValueIndicator" 28 | case comboBox = "AXComboBox" 29 | case slider = "AXSlider" 30 | case incrementer = "AXIncrementor" 31 | case busyIndicator = "AXBusyIndicator" 32 | case progressIndicator = "AXProgressIndicator" 33 | case relevanceIndicator = "AXRelevanceIndicator" 34 | case toolbar = "AXToolbar" 35 | case disclosureTriangle = "AXDisclosureTriangle" 36 | case textField = "AXTextField" 37 | case textArea = "AXTextArea" 38 | case staticText = "AXStaticText" 39 | case heading = "AXHeading" 40 | case menuBar = "AXMenuBar" 41 | case menuBarItem = "AXMenuBarItem" 42 | case menu = "AXMenu" 43 | case menuItem = "AXMenuItem" 44 | case splitGroup = "AXSplitGroup" 45 | case splitter = "AXSplitter" 46 | case colorWell = "AXColorWell" 47 | case timeField = "AXTimeField" 48 | case dateField = "AXDateField" 49 | case helpTag = "AXHelpTag" 50 | case matte = "AXMatte" 51 | case dockItem = "AXDockItem" 52 | case ruler = "AXRuler" 53 | case grid = "AXGrid" 54 | case levelIndicator = "AXLevelIndicator" 55 | case cell = "AXCell" 56 | case layoutArea = "AXLayoutArea" 57 | case layoutItem = "AXLayoutItem" 58 | case handle = "AXHandle" 59 | case popover = "AXPopover" 60 | case link = "AXLink" 61 | case webArea = "AXWebArea" 62 | } 63 | -------------------------------------------------------------------------------- /Sources/Element/ElementSubrole.swift: -------------------------------------------------------------------------------- 1 | /// Non-comprehensive subrole list. 2 | public enum ElementSubrole: String { 3 | case closeButton = "AXCloseButton" 4 | case minimizeButton = "AXMinimizeButton" 5 | case zoomButton = "AXZoomButton" 6 | case toolBarButton = "AXToolbarButton" 7 | case fullScreenButton = "AXFullScreenButton" 8 | case secureTextField = "AXSecureTextField" 9 | case tableRow = "AXTableRow" 10 | case outlineRow = "AXOutlineRow" 11 | case unknown = "AXUnknown" 12 | case standardWindow = "AXStandardWindow" 13 | case dialogWindow = "AXDialog" 14 | case systemDialogWindow = "AXSystemDialog" 15 | case floatingWindow = "AXFloatingWindow" 16 | case systemFloatingWindow = "AXSystemFloatingWindow" 17 | case decorative = "AXDecorative" 18 | case incrementArrow = "AXIncrementArrow" 19 | case decrementArrow = "AXDecrementArrow" 20 | case incrementPage = "AXIncrementPage" 21 | case decrementPage = "AXDecrementPage" 22 | case sortButton = "AXSortButton" 23 | case searchField = "AXSearchField" 24 | case timeline = "AXTimeline" 25 | case ratingIndicator = "AXRatingIndicator" 26 | case contentList = "AXContentList" 27 | case descriptionList = "AXDescriptionList" 28 | case toggle = "AXToggle" 29 | case selector = "AXSwitch" 30 | case applicationDockItem = "AXApplicationDockItem" 31 | case documentDockItem = "AXDocumentDockItem" 32 | case folderDockItem = "AXFolderDockItem" 33 | case minimizedWindowDockItem = "AXMinimizedWindowDockItem" 34 | case urlDockItem = "AXURLDockItem" 35 | case extraDockItem = "AXDockExtraDockItem" 36 | case trashDockItem = "AXTrashDockItem" 37 | case separatorDockItem = "AXSeparatorDockItem" 38 | case processSwitcherList = "AXProcessSwitcherList" 39 | } 40 | -------------------------------------------------------------------------------- /Sources/Input/Input.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import CoreGraphics 3 | import IOKit 4 | 5 | import Output 6 | 7 | /// Input handler. 8 | @MainActor public final class Input { 9 | /// Keys currently being pressed. 10 | public private(set) var regularKeys = Set() 11 | /// Modifiers currently being pressed. 12 | public private(set) var modifierKeys = Set() 13 | /// Input state. 14 | private let state = State() 15 | /// CapsLock stream event continuation. 16 | private let capsLockContinuation: AsyncStream<(timestamp: UInt64, isDown: Bool)>.Continuation 17 | /// Modifier stream event continuation. 18 | private var modifierContinuation: AsyncStream<(key: InputModifierKeyCode, isDown: Bool)>.Continuation 19 | /// Keyboard tap event stream continuation. 20 | private let keyboardTapContinuation: AsyncStream.Continuation 21 | /// Legacy Human Interface Device manager instance. 22 | private let hidManager: IOHIDManager 23 | /// CapsLock event service handle. 24 | private var connect = io_connect_t(0) 25 | /// Tap into the windoe server's input events. 26 | private var eventTap: CFMachPort! 27 | /// Task handling CapsLock events. 28 | private var capsLockTask: Task! 29 | /// Task handling modifier key events. 30 | private var modifierTask: Task! 31 | /// Task handling keyboard window server tap events. 32 | private var keyboardTapTask: Task! 33 | /// Shared singleton. 34 | public static let shared = Input() 35 | 36 | /// Browse mode state. 37 | public var browseModeEnabled: Bool {get {state.browseModeEnabled} set {state.browseModeEnabled = newValue}} 38 | 39 | /// Creates a new input handler. 40 | private init() { 41 | let (capsLockStream, capsLockContinuation) = AsyncStream<(timestamp: UInt64, isDown: Bool)>.makeStream() 42 | let (modifierStream, modifierContinuation) = AsyncStream<(key: InputModifierKeyCode, isDown: Bool)>.makeStream() 43 | let (keyboardTapStream, keyboardTapContinuation) = AsyncStream.makeStream() 44 | self.capsLockContinuation = capsLockContinuation 45 | self.modifierContinuation = modifierContinuation 46 | self.keyboardTapContinuation = keyboardTapContinuation 47 | hidManager = IOHIDManagerCreate(kCFAllocatorDefault, IOOptionBits(kIOHIDOptionsTypeNone)) 48 | let matches = [[kIOHIDDeviceUsagePageKey: kHIDPage_GenericDesktop, kIOHIDDeviceUsageKey: kHIDUsage_GD_Keyboard], [kIOHIDDeviceUsagePageKey: kHIDPage_GenericDesktop, kIOHIDDeviceUsageKey: kHIDUsage_GD_Keypad]] 49 | IOHIDManagerSetDeviceMatchingMultiple(hidManager, matches as CFArray) 50 | let capsLockCallback: IOHIDValueCallback = {(this, _, _, value) in 51 | let this = Unmanaged.fromOpaque(this!).takeUnretainedValue() 52 | let isDown = IOHIDValueGetIntegerValue(value) != 0 53 | let timestamp = IOHIDValueGetTimeStamp(value) 54 | let element = IOHIDValueGetElement(value) 55 | let scanCode = IOHIDElementGetUsage(element) 56 | guard let modifierKeyCode = InputModifierKeyCode(rawValue: scanCode) else { 57 | return 58 | } 59 | if modifierKeyCode == .capsLock { 60 | this.capsLockContinuation.yield((timestamp: timestamp, isDown: isDown)) 61 | } 62 | this.modifierContinuation.yield((key: modifierKeyCode, isDown: isDown)) 63 | } 64 | IOHIDManagerRegisterInputValueCallback(hidManager, capsLockCallback, Unmanaged.passUnretained(self).toOpaque()) 65 | IOHIDManagerScheduleWithRunLoop(hidManager, CFRunLoopGetMain(), CFRunLoopMode.defaultMode.rawValue) 66 | IOHIDManagerOpen(hidManager, IOOptionBits(kIOHIDOptionsTypeNone)) 67 | let service = IOServiceGetMatchingService(kIOMainPortDefault, IOServiceMatching(kIOHIDSystemClass)) 68 | IOServiceOpen(service, mach_task_self_, UInt32(kIOHIDParamConnectType), &connect) 69 | IOHIDGetModifierLockState(connect, Int32(kIOHIDCapsLockState), &state.capsLockEnabled) 70 | let keyboardTapCallback: CGEventTapCallBack = {(_, _, event, this) in 71 | let this = Unmanaged.fromOpaque(this!).takeUnretainedValue() 72 | guard event.type != CGEventType.tapDisabledByTimeout else { 73 | CGEvent.tapEnable(tap: this.eventTap, enable: true) 74 | return nil 75 | } 76 | this.keyboardTapContinuation.yield(event) 77 | guard this.state.capsLockPressed || this.state.browseModeEnabled else { 78 | return Unmanaged.passUnretained(event) 79 | } 80 | return nil 81 | } 82 | guard let eventTap = CGEvent.tapCreate(tap: .cghidEventTap, place: .tailAppendEventTap, options: .defaultTap, eventsOfInterest: 1 << CGEventType.keyDown.rawValue | 1 << CGEventType.keyUp.rawValue | 1 << CGEventType.flagsChanged.rawValue, callback: keyboardTapCallback, userInfo: Unmanaged.passUnretained(self).toOpaque()) else { 83 | fatalError("Failed to create a keyboard event tap") 84 | } 85 | let eventRunLoopSource = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, eventTap, 0) 86 | CFRunLoopAddSource(CFRunLoopGetMain(), eventRunLoopSource, CFRunLoopMode.defaultMode) 87 | capsLockTask = Task(operation: {[unowned self] in await handleCapsLockStream(capsLockStream)}) 88 | modifierTask = Task(operation: {[unowned self] in await handleModifierStream(modifierStream)}) 89 | keyboardTapTask = Task(operation: {[unowned self] in await handleKeyboardTapStream(keyboardTapStream)}) 90 | } 91 | 92 | deinit { 93 | capsLockTask.cancel() 94 | modifierTask.cancel() 95 | keyboardTapTask.cancel() 96 | IOServiceClose(connect) 97 | } 98 | 99 | /// Binds a key to an action with optional modifiers. 100 | /// - Parameters: 101 | /// - browseMode: Requires browse mode. 102 | /// - controlModifier: Requires the Control modifier key to be pressed. 103 | /// - optionModifier: Requires the Option modifier key to be pressed. 104 | /// - commandModifier: Requires the Command modifier key to be pressed. 105 | /// - shiftModifier: Requires the Shift modifier key to be pressed. 106 | /// - key: Key to bind. 107 | /// - action: Action to perform when the key combination is pressed. 108 | public func bindKey(browseMode: Bool = false, controlModifier: Bool = false, optionModifier: Bool = false, commandModifier: Bool = false, shiftModifier: Bool = false, key: InputKeyCode, action: @escaping () async -> Void) { 109 | let keyBinding = KeyBinding(browseMode: browseMode, controlModifier: controlModifier, optionModifier: optionModifier, commandModifier: commandModifier, shiftModifier: shiftModifier, key: key) 110 | guard state.keyBindings.updateValue(action, forKey: keyBinding) == nil else { 111 | fatalError("Attempted to bind the same key combination twice") 112 | } 113 | } 114 | 115 | /// Handles the stream of CapsLock events. 116 | /// - Parameter capsLockStream: Stream of CapsLock events. 117 | private func handleCapsLockStream(_ capsLockStream: AsyncStream<(timestamp: UInt64, isDown: Bool)>) async { 118 | for await (timestamp: timestamp, isDown: isDown) in capsLockStream { 119 | state.capsLockPressed = isDown 120 | var timeBase = mach_timebase_info(numer: 0, denom: 0) 121 | mach_timebase_info(&timeBase) 122 | let timestamp = timestamp / UInt64(timeBase.denom) * UInt64(timeBase.numer) 123 | if state.lastCapsLockEvent + 250000000 > timestamp && isDown { 124 | state.lastCapsLockEvent = 0 125 | state.capsLockEnabled.toggle() 126 | IOHIDSetModifierLockState(connect, Int32(kIOHIDCapsLockState), state.capsLockEnabled) 127 | let event = CGEvent(keyboardEventSource: nil, virtualKey: 0x39, keyDown: state.capsLockEnabled) 128 | event?.post(tap: .cghidEventTap) 129 | Output.shared.convey([OutputSemantic.capsLockStatusChanged(state.capsLockEnabled)]) 130 | continue 131 | } 132 | IOHIDSetModifierLockState(connect, Int32(kIOHIDCapsLockState), state.capsLockEnabled) 133 | if isDown { 134 | state.lastCapsLockEvent = timestamp 135 | } 136 | } 137 | } 138 | 139 | /// Handles the stream of modifier key events. 140 | /// - Parameter modifierStream: Stream of modifier key events. 141 | private func handleModifierStream(_ modifierStream: AsyncStream<(key: InputModifierKeyCode, isDown: Bool)>) async { 142 | for await event in modifierStream { 143 | if event.isDown { 144 | state.shouldInterrupt = regularKeys.isEmpty && modifierKeys.isEmpty && (event.key == .leftControl || event.key == .rightControl) 145 | modifierKeys.insert(event.key) 146 | continue 147 | } 148 | modifierKeys.remove(event.key) 149 | if state.shouldInterrupt { 150 | Output.shared.interrupt() 151 | state.shouldInterrupt = false 152 | } 153 | } 154 | } 155 | 156 | /// Handles the stream of keyboard tap events. 157 | /// - Parameter keyboardTapStream: Stream of keyboard tap events. 158 | private func handleKeyboardTapStream(_ keyboardTapStream: AsyncStream) async { 159 | for await event in keyboardTapStream { 160 | let keyCode = event.getIntegerValueField(.keyboardEventKeycode) 161 | guard let keyCode = InputKeyCode(rawValue: keyCode) else { 162 | continue 163 | } 164 | state.shouldInterrupt = false 165 | guard event.type == .keyDown else { 166 | regularKeys.remove(keyCode) 167 | continue 168 | } 169 | regularKeys.insert(keyCode) 170 | guard state.capsLockPressed || state.browseModeEnabled else { 171 | continue 172 | } 173 | let browseMode = state.browseModeEnabled && !state.capsLockPressed 174 | let controlModifier = event.flags.contains(.maskControl) 175 | let optionModifier = event.flags.contains(.maskAlternate) 176 | let commandModifier = event.flags.contains(.maskCommand) 177 | let shiftModifier = event.flags.contains(.maskShift) 178 | let keyBinding = KeyBinding(browseMode: browseMode, controlModifier: controlModifier, optionModifier: optionModifier, commandModifier: commandModifier, shiftModifier: shiftModifier, key: keyCode) 179 | guard let action = state.keyBindings[keyBinding] else { 180 | continue 181 | } 182 | await action() 183 | } 184 | } 185 | 186 | /// Input state. 187 | private final class State { 188 | /// Whether browse mode is enabled. 189 | var browseModeEnabled = false 190 | /// Mach timestamp of the last CapsLock key press event. 191 | var lastCapsLockEvent = UInt64(0) 192 | /// Whether CapsLock is enabled. 193 | var capsLockEnabled = false 194 | /// Whether CapsLock is being pressed. 195 | var capsLockPressed = false 196 | /// Map of key bindings to their respective actions. 197 | var keyBindings = [KeyBinding: () async -> Void]() 198 | /// Whether the user wants to interrupt speech. 199 | var shouldInterrupt = false 200 | } 201 | 202 | /// Key to the key bindings map. 203 | private struct KeyBinding: Hashable { 204 | /// Whether browse mode is required. 205 | let browseMode: Bool 206 | /// Whether the Control key modifier is required. 207 | let controlModifier: Bool 208 | /// Whether the Option key modifier is required. 209 | let optionModifier: Bool 210 | /// Whether the Command key modifier is required. 211 | let commandModifier: Bool 212 | /// Whether the Shift key modifier is required. 213 | let shiftModifier: Bool 214 | /// Bound key. 215 | let key: InputKeyCode 216 | } 217 | } 218 | -------------------------------------------------------------------------------- /Sources/Input/InputKeyCode.swift: -------------------------------------------------------------------------------- 1 | /// Window server tap event key codes for US ANSI keyboards. 2 | public enum InputKeyCode: Int64 { 3 | case keyboardA = 0x0 4 | case keyboardB = 0xb 5 | case keyboardC = 0x8 6 | case keyboardD = 0x2 7 | case keyboardE = 0xe 8 | case keyboardF = 0x3 9 | case keyboardG = 0x5 10 | case keyboardH = 0x4 11 | case keyboardI = 0x22 12 | case keyboardJ = 0x26 13 | case keyboardK = 0x28 14 | case keyboardL = 0x25 15 | case keyboardM = 0x2e 16 | case keyboardN = 0x2d 17 | case keyboardO = 0x1f 18 | case keyboardP = 0x23 19 | case keyboardQ = 0xc 20 | case keyboardR = 0xf 21 | case keyboardS = 0x1 22 | case keyboardT = 0x11 23 | case keyboardU = 0x20 24 | case keyboardV = 0x9 25 | case keyboardW = 0xd 26 | case keyboardX = 0x7 27 | case keyboardY = 0x10 28 | case keyboardZ = 0x6 29 | case keyboard1AndExclamation = 0x12 30 | case keyboard2AndAtt = 0x13 31 | case keyboard3AndHash = 0x14 32 | case keyboard4AndDollar = 0x15 33 | case keyboard5AndPercent = 0x17 34 | case keyboard6AndCaret = 0x16 35 | case keyboard7AndAmp = 0x1a 36 | case keyboard8AndStar = 0x1c 37 | case keyboard9AndLeftParen = 0x19 38 | case keyboard0AndRightParen = 0x1d 39 | case keyboardReturn = 0x24 40 | case keyboardEscape = 0x35 41 | case keyboardBackDelete = 0x33 42 | case keyboardTab = 0x30 43 | case keyboardSpace = 0x31 44 | case keyboardMinusAndUnderscore = 0x1b 45 | case keyboardEqualsAndPlus = 0x18 46 | case keyboardLeftBracketAndBrace = 0x21 47 | case keyboardRightBracketAndBrace = 0x1e 48 | case keyboardBackSlashAndVertical = 0x2a 49 | case keyboardSemiColonAndColon = 0x29 50 | case keyboardApostropheAndQuote = 0x27 51 | case keyboardGraveAccentAndTilde = 0x32 52 | case keyboardCommaAndLeftAngle = 0x2b 53 | case keyboardPeriodAndRightAngle = 0x2f 54 | case keyboardSlashAndQuestion = 0x2c 55 | case keyboardF1 = 0x7a 56 | case keyboardF2 = 0x78 57 | case keyboardF3 = 0x63 58 | case keyboardF4 = 0x76 59 | case keyboardF5 = 0x60 60 | case keyboardF6 = 0x61 61 | case keyboardF7 = 0x62 62 | case keyboardF8 = 0x64 63 | case keyboardF9 = 0x65 64 | case keyboardF10 = 0x6d 65 | case keyboardF11 = 0x67 66 | case keyboardF12 = 0x6f 67 | case keyboardHome = 0x73 68 | case keyboardPageUp = 0x74 69 | case keyboardDelete = 0x75 70 | case keyboardEnd = 0x77 71 | case keyboardPageDown = 0x79 72 | case keyboardLeftArrow = 0x7b 73 | case keyboardRightArrow = 0x7c 74 | case keyboardDownArrow = 0x7d 75 | case keyboardUpArrow = 0x7e 76 | case keypadNumLock = 0x47 77 | case keypadDivide = 0x4b 78 | case keypadMultiply = 0x43 79 | case keypadSubtract = 0x4e 80 | case keypadAdd = 0x45 81 | case keypadEnter = 0x4c 82 | case keypad1AndEnd = 0x53 83 | case keypad2AndDownArrow = 0x54 84 | case keypad3AndPageDown = 0x55 85 | case keypad4AndLeftArrow = 0x56 86 | case keypad5 = 0x57 87 | case keypad6AndRightArrow = 0x58 88 | case keypad7AndHome = 0x59 89 | case keypad8AndUpArrow = 0x5b 90 | case keypad9AndPageUp = 0x5c 91 | case keypad0 = 0x52 92 | case keypadDecimalAndDelete = 0x41 93 | case keypadEquals = 0x51 94 | case keyboardF13 = 0x69 95 | case keyboardF14 = 0x6b 96 | case keyboardF15 = 0x71 97 | case keyboardF16 = 0x6a 98 | case keyboardF17 = 0x40 99 | case keyboardF18 = 0x4f 100 | case keyboardF19 = 0x50 101 | case keyboardF20 = 0x5a 102 | case keyboardVolumeUp = 0x48 103 | case keyboardVolumeDown = 0x49 104 | case keyboardVolumeMute = 0x4a 105 | case keyboardHelp = 0x72 106 | } 107 | -------------------------------------------------------------------------------- /Sources/Input/InputModifierKeyCode.swift: -------------------------------------------------------------------------------- 1 | /// Low level system key codes for modifier keys. 2 | public enum InputModifierKeyCode: UInt32 { 3 | case capsLock = 0x39 4 | case leftShift = 0xe1 5 | case leftControl = 0xe0 6 | case leftOption = 0xe2 7 | case leftCommand = 0xe3 8 | case rightShift = 0xe5 9 | case rightControl = 0xe4 10 | case rightOption = 0xe6 11 | case rightCommand = 0xe7 12 | case function = 0x3 13 | } 14 | -------------------------------------------------------------------------------- /Sources/Output/Output.swift: -------------------------------------------------------------------------------- 1 | import AVFoundation 2 | import ApplicationServices 3 | 4 | /// Output conveyer. 5 | @MainActor public final class Output: NSObject { 6 | /// Speech synthesizer. 7 | private let synthesizer = AVSpeechSynthesizer() 8 | /// Queued output. 9 | private var queued = [OutputSemantic]() 10 | /// Whether the synthesizer is currently announcing something. 11 | private var isAnnouncing = false 12 | /// Shared singleton. 13 | public static let shared = Output() 14 | 15 | /// Creates a new output. 16 | private override init() { 17 | super.init() 18 | synthesizer.delegate = self 19 | } 20 | 21 | /// Announces a high priority event. 22 | /// - Parameter announcement: Event to announce. 23 | public func announce(_ announcement: String) { 24 | let announcement = AVSpeechUtterance(string: announcement) 25 | synthesizer.stopSpeaking(at: .immediate) 26 | isAnnouncing = true 27 | synthesizer.speak(announcement) 28 | } 29 | 30 | /// Conveys the semantic accessibility output to the user. 31 | /// - Parameter content: Content to output. 32 | public func convey(_ content: [OutputSemantic]) { 33 | if isAnnouncing { 34 | queued = content 35 | return 36 | } 37 | queued = [] 38 | synthesizer.stopSpeaking(at: .immediate) 39 | for expression in content { 40 | switch expression { 41 | case .apiDisabled: 42 | let utterance = AVSpeechUtterance(string: "Accessibility interface disabled") 43 | synthesizer.speak(utterance) 44 | case let .application(label): 45 | let utterance = AVSpeechUtterance(string: label) 46 | synthesizer.speak(utterance) 47 | case let .boolValue(bool): 48 | let utterance = AVSpeechUtterance(string: bool ? "On" : "Off") 49 | synthesizer.speak(utterance) 50 | case .boundary: 51 | continue 52 | case let .capsLockStatusChanged(status): 53 | let utterance = AVSpeechUtterance(string: "CapsLock \(status ? "On" : "Off")") 54 | synthesizer.speak(utterance) 55 | case let .columnCount(count): 56 | let utterance = AVSpeechUtterance(string: "\(count) columns") 57 | synthesizer.speak(utterance) 58 | case .disabled: 59 | let utterance = AVSpeechUtterance(string: "Disabled") 60 | synthesizer.speak(utterance) 61 | case .edited: 62 | let utterance = AVSpeechUtterance(string: "Edited") 63 | synthesizer.speak(utterance) 64 | case .entering: 65 | let utterance = AVSpeechUtterance(string: "Entering") 66 | synthesizer.speak(utterance) 67 | case .exiting: 68 | let utterance = AVSpeechUtterance(string: "Exiting") 69 | synthesizer.speak(utterance) 70 | case let .floatValue(float): 71 | let utterance = AVSpeechUtterance(string: String(format: "%.01.02f", arguments: [float])) 72 | synthesizer.speak(utterance) 73 | case let .help(help): 74 | let utterance = AVSpeechUtterance(string: help) 75 | synthesizer.speak(utterance) 76 | case let .insertedText(text): 77 | let utterance = AVSpeechUtterance(string: text) 78 | synthesizer.speak(utterance) 79 | case let .intValue(int): 80 | let utterance = AVSpeechUtterance(string: String(int)) 81 | synthesizer.speak(utterance) 82 | case let .label(label): 83 | let utterance = AVSpeechUtterance(string: label) 84 | synthesizer.speak(utterance) 85 | case .next: 86 | continue 87 | case .noFocus: 88 | let utterance = AVSpeechUtterance(string: "Nothing in focus") 89 | synthesizer.speak(utterance) 90 | case .notAccessible: 91 | let utterance = AVSpeechUtterance(string: "Application not accessible") 92 | synthesizer.speak(utterance) 93 | case let .placeholderValue(value): 94 | let utterance = AVSpeechUtterance(string: value) 95 | synthesizer.speak(utterance) 96 | case .previous: 97 | continue 98 | case let .removedText(text): 99 | let utterance = AVSpeechUtterance(string: text) 100 | synthesizer.speak(utterance) 101 | case let .role(role): 102 | let utterance = AVSpeechUtterance(string: role) 103 | synthesizer.speak(utterance) 104 | case let .rowCount(count): 105 | let utterance = AVSpeechUtterance(string: "\(count) rows") 106 | synthesizer.speak(utterance) 107 | case .selected: 108 | let utterance = AVSpeechUtterance(string: "Selected") 109 | synthesizer.speak(utterance) 110 | case let .selectedChildrenCount(count): 111 | let utterance = AVSpeechUtterance(string: "\(count) selected \(count == 1 ? "child" : "children")") 112 | synthesizer.speak(utterance) 113 | case let .selectedText(text): 114 | let utterance = AVSpeechUtterance(string: text) 115 | synthesizer.speak(utterance) 116 | case let .selectedTextGrew(text): 117 | let utterance = AVSpeechUtterance(string: text) 118 | synthesizer.speak(utterance) 119 | case let .selectedTextShrank(text): 120 | let utterance = AVSpeechUtterance(string: text) 121 | synthesizer.speak(utterance) 122 | case let .stringValue(string): 123 | let utterance = AVSpeechUtterance(string: string) 124 | synthesizer.speak(utterance) 125 | case .timeout: 126 | let utterance = AVSpeechUtterance(string: "Application is not responding") 127 | synthesizer.speak(utterance) 128 | case let .updatedLabel(label): 129 | let utterance = AVSpeechUtterance(string: label) 130 | synthesizer.speak(utterance) 131 | case let .urlValue(url): 132 | let utterance = AVSpeechUtterance(string: url) 133 | synthesizer.speak(utterance) 134 | case let .window(label): 135 | let utterance = AVSpeechUtterance(string: label) 136 | synthesizer.speak(utterance) 137 | } 138 | } 139 | } 140 | 141 | /// Interrupts speech. 142 | public func interrupt() { 143 | isAnnouncing = false 144 | queued = [] 145 | synthesizer.stopSpeaking(at: .immediate) 146 | } 147 | } 148 | 149 | extension Output: AVSpeechSynthesizerDelegate { 150 | public func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didFinish _: AVSpeechUtterance) { 151 | if isAnnouncing { 152 | isAnnouncing = false 153 | convey(queued) 154 | } 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /Sources/Output/OutputSemantic.swift: -------------------------------------------------------------------------------- 1 | /// Semantic Accessibility descriptions. 2 | public enum OutputSemantic { 3 | case application(String) 4 | case window(String) 5 | case boundary 6 | case selectedChildrenCount(Int) 7 | case rowCount(Int) 8 | case columnCount(Int) 9 | case label(String) 10 | case role(String) 11 | case boolValue(Bool) 12 | case intValue(Int64) 13 | case floatValue(Double) 14 | case stringValue(String) 15 | case urlValue(String) 16 | case placeholderValue(String) 17 | case selectedText(String) 18 | case selectedTextGrew(String) 19 | case selectedTextShrank(String) 20 | case insertedText(String) 21 | case removedText(String) 22 | case help(String) 23 | case updatedLabel(String) 24 | case edited 25 | case selected 26 | case disabled 27 | case entering 28 | case exiting 29 | case next 30 | case previous 31 | case noFocus 32 | case capsLockStatusChanged(Bool) 33 | case apiDisabled 34 | case notAccessible 35 | case timeout 36 | } 37 | -------------------------------------------------------------------------------- /Sources/Vosh/Vosh.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | /// Entry point and user interface. 4 | @main struct Vosh: App { 5 | @NSApplicationDelegateAdaptor private var appDelegate: AppDelegate 6 | 7 | var body: some Scene { 8 | MenuBarExtra("Vosh", systemImage: "eye") { 9 | Button(action: {NSApplication.shared.terminate(nil)}, label: {Text("Exit")}) 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /Sources/Vosh/VoshAgent.swift: -------------------------------------------------------------------------------- 1 | import Access 2 | import Input 3 | 4 | /// Interface between the user and the ``Access`` framework. 5 | @MainActor final class VoshAgent { 6 | /// ``Access`` framework handle. 7 | private let access: Access 8 | 9 | /// Creates the user agent. 10 | init?() async { 11 | guard let access = await Access() else { 12 | return nil 13 | } 14 | await access.setTimeout(seconds: 5.0) 15 | self.access = access 16 | Input.shared.bindKey(key: .keyboardTab, action: {[weak self] in await self?.access.readFocus()}) 17 | Input.shared.bindKey(key: .keyboardLeftArrow, action: {[weak self] in await self?.access.focusNextSibling(backwards: true)}) 18 | Input.shared.bindKey(key: .keyboardRightArrow, action: {[weak self] in await self?.access.focusNextSibling(backwards: false)}) 19 | Input.shared.bindKey(key: .keyboardDownArrow, action: {[weak self] in await self?.access.focusFirstChild()}) 20 | Input.shared.bindKey(key: .keyboardUpArrow, action: {[weak self] in await self?.access.focusParent()}) 21 | Input.shared.bindKey(key: .keyboardSlashAndQuestion, action: {[weak self] in await self?.access.dumpApplication()}) 22 | Input.shared.bindKey(key: .keyboardPeriodAndRightAngle, action: {[weak self] in await self?.access.dumpApplication()}) 23 | Input.shared.bindKey(key: .keyboardCommaAndLeftAngle, action: {[weak self] in await self?.access.dumpFocus()}) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Sources/Vosh/VoshAppDelegate.swift: -------------------------------------------------------------------------------- 1 | import AppKit 2 | 3 | import Output 4 | 5 | /// Handler for application lifecycle events. 6 | final class AppDelegate: NSObject, NSApplicationDelegate { 7 | /// Screen-reader instance. 8 | private var agent: VoshAgent? 9 | 10 | func applicationDidFinishLaunching(_ _notification: Notification) { 11 | Output.shared.announce("Starting Vosh") 12 | Task() {[self] in 13 | let agent = await VoshAgent() 14 | await MainActor.run() {[self] in 15 | self.agent = agent 16 | } 17 | if agent == nil { 18 | await Output.shared.announce("Vosh failed to start!") 19 | try? await Task.sleep(nanoseconds: 3_000_000_000) 20 | NSApplication.shared.terminate(nil) 21 | } 22 | } 23 | } 24 | 25 | func applicationShouldTerminate(_: NSApplication) -> NSApplication.TerminateReply { 26 | if agent != nil { 27 | agent = nil 28 | Output.shared.announce("Terminating Vosh") 29 | Task() { 30 | // Allow some time to announce termination before actually terminating. 31 | try! await Task.sleep(nanoseconds: 3_000_000_000) 32 | await MainActor.run(body: {NSApplication.shared.terminate(nil)}) 33 | } 34 | return .terminateCancel 35 | } 36 | return .terminateNow 37 | } 38 | } 39 | --------------------------------------------------------------------------------