Files
upstream-aw-watcher-window/aw_watcher_window/macos.swift
upstream-mirror 8e9cceaeae
Some checks failed
Build / test on ubuntu-latest (py-3.9) (push) Failing after 2m36s
Build / typecheck (push) Successful in 1m38s
Build / test on macos-latest (py-3.9) (push) Has been cancelled
Build / test on windows-latest (py-3.9) (push) Has been cancelled
CodeQL / Analyze (python) (push) Failing after 1s
snapshot of https://github.com/ActivityWatch/aw-watcher-window.git (master, 2026-09-16; blobs > 131072B omitted)
2026-09-16 00:21:37 +05:00

752 lines
27 KiB
Swift

import Cocoa
import ScriptingBridge
@objc protocol ChromeTab {
@objc optional var URL: String { get }
@objc optional var title: String { get }
}
@objc protocol ChromeWindow {
@objc optional var activeTab: ChromeTab { get }
@objc optional var mode: String { get }
}
extension SBObject: ChromeWindow, ChromeTab {}
@objc protocol ChromeProtocol {
@objc optional func windows() -> [ChromeWindow]
}
extension SBApplication: ChromeProtocol {}
// https://github.com/tingraldi/SwiftScripting/blob/4346eba0f47e806943601f5fb2fe978e2066b310/Frameworks/SafariScripting/SafariScripting/Safari.swift#L37
@objc public protocol SafariDocument {
@objc optional var name: String { get } // Its name.
@objc optional var modified: Bool { get } // Has it been modified since the last save?
@objc optional var file: URL { get } // Its location on disk, if it has one.
@objc optional var source: String { get } // The HTML source of the web page currently loaded in the document.
@objc optional var URL: String { get } // The current URL of the document.
@objc optional var text: String { get } // The text of the web page currently loaded in the document. Modifications to text aren't reflected on the web page.
@objc optional func setURL(_ URL: String!) // The current URL of the document.
}
@objc public protocol SafariTab {
@objc optional var source: String { get } // The HTML source of the web page currently loaded in the tab.
@objc optional var URL: String { get } // The current URL of the tab.
@objc optional var index: NSNumber { get } // The index of the tab, ordered left to right.
@objc optional var text: String { get } // The text of the web page currently loaded in the tab. Modifications to text aren't reflected on the web page.
@objc optional var visible: Bool { get } // Whether the tab is currently visible.
@objc optional var name: String { get } // The name of the tab.
@objc optional func setURL(_ URL: String!) // The current URL of the tab.
}
@objc public protocol SafariWindow {
@objc optional var name: String { get } // The title of the window.
@objc optional func id() -> Int // The unique identifier of the window.
@objc optional var index: Int { get } // The index of the window, ordered front to back.
@objc optional var document: SafariDocument { get } // The document whose contents are displayed in the window.
@objc optional func tabs() -> SBElementArray
@objc optional var currentTab: SafariTab { get } // The current tab.
}
extension SBObject: SafariWindow {}
@objc public protocol SafariApplication {
@objc optional func documents() -> SBElementArray
@objc optional func windows() -> [SafariWindow]
@objc optional var name: String { get } // The name of the application.
@objc optional var frontmost: Bool { get } // Is this the active application?
}
extension SBApplication: SafariApplication {}
// AW-specific structs
struct NetworkMessage: Codable, Equatable {
var app: String
var title: String?
var url: String?
}
struct Heartbeat: Codable {
var timestamp: Date
var data: NetworkMessage
}
enum HeartbeatError: Error {
case error(msg: String)
}
struct Bucket: Codable {
var client: String
var type: String
var hostname: String
}
// there's no builtin logging library on macos which has levels & hits stdout, so we build our own simple one
// there a complex open source one, but it makes it harder to compile this simple one-file swift application
let dateFormatter = DateFormatter();
func logTimestamp() -> String {
let now = Date()
dateFormatter.timeZone = TimeZone.current
dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
return dateFormatter.string(from: now)
}
// generate log prefix based on level
func logPrefix(_ level: String) -> String {
return "\(logTimestamp()) [aw-watcher-window-macos] [\(level)]"
}
let logLevel = ProcessInfo.processInfo.environment["LOG_LEVEL"]?.uppercased() ?? "INFO"
func debug(_ msg: String) {
if (logLevel == "DEBUG") {
print("\(logPrefix("DEBUG")) \(msg)")
fflush(stdout)
}
}
func log(_ msg: String) {
print("\(logPrefix("INFO")) \(msg)")
fflush(stdout)
}
func error(_ msg: String) {
print("\(logPrefix("ERROR")) \(msg)")
fflush(stdout)
}
// Placeholder values, set in start() from CLI arguments
var baseurl = "http://localhost:5600"
// NOTE: this differs from the hostname we get from Python, here we get `.local`, but in Python we get `.localdomain`
var clientHostname = ProcessInfo.processInfo.hostName
var clientName = "aw-watcher-window"
var bucketName = "\(clientName)_\(clientHostname)"
var excludeTitle = false
var excludeTitlePatterns: [NSRegularExpression] = []
var researchEnabled = false
var researchCategoryMap: [(pattern: String, category: String)] = []
var researchAppCategoryMap: [(app: String, category: String)] = []
let researchBrowserApps = Set([
"chrome",
"google chrome",
"google chrome canary",
"google-chrome",
"google-chrome-beta",
"google-chrome-unstable",
"chromium",
"chromium-browser",
"chromium.exe",
"brave browser",
"brave",
"brave-browser",
"arc",
"arc browser",
"firefox",
"firefox developer edition",
"firefox-esr",
"safari",
"edge",
"microsoft edge",
"microsoft-edge",
"microsoft-edge-beta",
"microsoft-edge-dev",
"opera",
"vivaldi",
"vivaldi.exe",
"chrome.exe",
"brave.exe",
"firefox.exe",
"msedge.exe",
"opera.exe",
])
let main = MainThing()
var oldHeartbeat: Heartbeat?
let encoder = JSONEncoder()
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
encoder.dateEncodingStrategy = .custom({ date, encoder in
var container = encoder.singleValueContainer()
let dateString = formatter.string(from: date)
try container.encode(dateString)
})
start()
RunLoop.main.run()
func compileExcludeTitlePattern(_ pattern: String) -> NSRegularExpression {
do {
return try NSRegularExpression(pattern: pattern, options: [.caseInsensitive])
} catch let regexError {
error("Invalid regex pattern: \(pattern) — \(regexError.localizedDescription)")
exit(1)
}
}
func parseOptionalArguments(_ arguments: ArraySlice<String>) {
var index = arguments.startIndex
while index < arguments.endIndex {
let argument = arguments[index]
if argument == "--exclude-title" {
excludeTitle = true
index = arguments.index(after: index)
continue
}
if argument == "--exclude-titles" {
let nextIndex = arguments.index(after: index)
guard nextIndex < arguments.endIndex else {
error("Missing value for --exclude-titles")
exit(1)
}
excludeTitlePatterns.append(compileExcludeTitlePattern(arguments[nextIndex]))
index = arguments.index(after: nextIndex)
continue
}
if argument == "--research" {
researchEnabled = true
index = arguments.index(after: index)
continue
}
if argument == "--research-category" {
let patternIndex = arguments.index(after: index)
let categoryIndex = patternIndex < arguments.endIndex ? arguments.index(after: patternIndex) : arguments.endIndex
guard patternIndex < arguments.endIndex, categoryIndex < arguments.endIndex else {
error("Missing pattern/category values for --research-category")
exit(1)
}
researchCategoryMap.append((pattern: arguments[patternIndex], category: arguments[categoryIndex]))
index = arguments.index(after: categoryIndex)
continue
}
if argument == "--research-app-category" {
let appIndex = arguments.index(after: index)
let categoryIndex = appIndex < arguments.endIndex ? arguments.index(after: appIndex) : arguments.endIndex
guard appIndex < arguments.endIndex, categoryIndex < arguments.endIndex else {
error("Missing app/category values for --research-app-category")
exit(1)
}
researchAppCategoryMap.append((app: arguments[appIndex], category: arguments[categoryIndex]))
index = arguments.index(after: categoryIndex)
continue
}
error("Unknown argument: \(argument)")
exit(1)
}
}
func titleShouldBeExcluded(_ title: String) -> Bool {
let range = NSRange(title.startIndex..<title.endIndex, in: title)
return excludeTitlePatterns.contains { pattern in
pattern.firstMatch(in: title, options: [], range: range) != nil
}
}
func isResearchBrowser(_ app: String) -> Bool {
return researchBrowserApps.contains(app.trimmingCharacters(in: .whitespacesAndNewlines).lowercased())
}
// Return the category of the *longest* pattern contained in `haystack`.
// Longest-match beats map order because study maps routinely contain both a
// generic and a specific form of the same host ("google.com" vs
// "docs.google.com"); first-match-wins would file every Google Docs/Drive/
// Calendar/Meet URL under the generic entry. Ties resolve to map order.
// Twin of `_longest_match` in research_filter.py — keep the two in sync.
func longestResearchMatch(_ haystack: String) -> String? {
var bestCategory: String?
var bestLength = -1
for item in researchCategoryMap {
if item.pattern.isEmpty { continue }
// Compare by Unicode scalar count, not Character count: Swift's `count`
// measures grapheme clusters while Python's `len()` measures code points,
// which would let the two twins pick different winners for non-ASCII
// patterns of equal visual length.
let patternLength = item.pattern.unicodeScalars.count
if haystack.range(of: item.pattern, options: [.caseInsensitive]) != nil,
patternLength > bestLength
{
bestCategory = item.category
bestLength = patternLength
}
}
return bestCategory
}
func classifyResearch(_ title: String, url: String?) -> String {
// Try URL first — more reliable than page title, which can change mid-load
if let url = url, !url.isEmpty {
if let category = longestResearchMatch(url) {
return category
}
}
if let category = longestResearchMatch(title) {
return category
}
return "excluded"
}
func classifyApp(_ app: String) -> String {
// Case-insensitive exact lookup of app name in the app category map.
// Returns the mapped category, or "Excluded" when the app is not in the map.
// Both sides are trimmed and lowercased so a configured key carrying stray
// whitespace matches identically here and in the Python path
// (research_filter.classify_app). Without trimming the configured key, a map
// entry like " Microsoft Outlook" would classify on Linux/Windows but fall
// through to "Excluded" on the macOS Swift path for the same config.
let appLower = app.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
for item in researchAppCategoryMap {
if item.app.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == appLower {
return item.category
}
}
return "Excluded"
}
func applyResearchFilter(_ data: NetworkMessage) -> NetworkMessage {
if !researchEnabled {
return data
}
if isResearchBrowser(data.app) {
return NetworkMessage(app: data.app, title: classifyResearch(data.title ?? "", url: data.url), url: nil)
}
// Non-browser: map app to study category when a map is provided;
// otherwise keep app name and drop title (legacy behaviour).
if researchAppCategoryMap.isEmpty {
return NetworkMessage(app: data.app, title: nil, url: nil)
}
// Replace the raw app identity with its category — the app name is the
// sensitive identifier for non-browser apps, so it must not be retained.
return NetworkMessage(app: classifyApp(data.app), title: nil, url: nil)
}
func start() {
// Arguments should be:
// - url + port
// - bucket_id
// - hostname
// - client_id
let arguments = CommandLine.arguments
// Check that we get the 4 required arguments plus any optional flags
if arguments.count < 5 {
print("Usage: aw-watcher-window <url> <bucket> <hostname> <client> [--exclude-title] [--exclude-titles <pattern> ...] [--research] [--research-category <pattern> <category> ...] [--research-app-category <app_name> <category> ...]")
exit(1)
}
baseurl = arguments[1]
bucketName = arguments[2]
clientHostname = arguments[3]
clientName = arguments[4]
parseOptionalArguments(arguments.dropFirst(5))
guard checkAccess() else {
DispatchQueue.main.asyncAfter(deadline: .now() + 10) {
start()
}
return
}
createBucket()
// listen for changes in focused application
NSWorkspace.shared.notificationCenter.addObserver(
main,
selector: #selector(main.focusedAppChanged),
name: NSWorkspace.didActivateApplicationNotification,
object: nil
)
main.focusedAppChanged()
// Start the polling timer
main.pollingTimer = Timer.scheduledTimer(timeInterval: 10.0, target: main, selector: #selector(main.pollActiveWindow), userInfo: nil, repeats: true)
}
// TODO might be better to have the python wrapper create this before launching the swift application
func createBucket() {
let payload = try! encoder.encode(
Bucket(client: clientName, type: "currentwindow", hostname: clientHostname))
let url = URL(string: "\(baseurl)/api/0/buckets/\(bucketName)")!
Task {
var urlRequest = URLRequest(url: url)
urlRequest.httpMethod = "POST"
urlRequest.addValue("application/json", forHTTPHeaderField: "Content-Type")
let (_, response) = try await URLSession.shared.upload(for: urlRequest, from: payload)
guard (200...299).contains((response as! HTTPURLResponse).statusCode) else {
log("Failed to create bucket")
return
}
}
}
func sendHeartbeat(_ heartbeat: Heartbeat) {
let oldPayloadDifferent = oldHeartbeat != nil && oldHeartbeat!.data != heartbeat.data
let timeSinceLastHeartbeat = oldHeartbeat != nil ? heartbeat.timestamp.timeIntervalSince(oldHeartbeat!.timestamp) : -1.0
// if you resize a window a ton of events (subsecond) will be fired
// we enforce a 1s minimum gap between events to avoid this
if timeSinceLastHeartbeat != -1.0 && timeSinceLastHeartbeat <= 0.5 {
debug("skipping heartbeat, last heartbeat was sent 1s ago")
return
}
// TODO running these async could cause weird state issues since the observer stuff can send a log of heartbeats
// in a short time under certain circumstances, and we don't want to send them all
Task {
if oldPayloadDifferent {
debug("sending old heartbeat for merging")
do {
// unlike the python aw-client library, we do not enforce a `commit_interval` and instead send the old event (which is not invalid)
// at the current time with a pulse value equal to the time since this event was originally sent. The aw-server will then merge
// this new event with the original event, extending the recorded time spent on this particular window/application.
let refreshedOldHeartbeat = Heartbeat(
// it is important to refresh the hearbeat using the timestamp where the user stopped working on the previous application
// more info: https://github.com/ActivityWatch/aw-watcher-window/pull/69
// we don't *think* this millisecond subtraction is necessary, but it may be:
// https://github.com/ActivityWatch/aw-watcher-window/pull/69#discussion_r987064282
timestamp: heartbeat.timestamp - 0.001,
data: oldHeartbeat!.data
)
try await sendHeartbeatSingle(refreshedOldHeartbeat, pulsetime: timeSinceLastHeartbeat + 1)
} catch {
log("Failed to send old heartbeat: \(error)")
return
}
}
do {
let since_last_seconds = oldHeartbeat != nil ? heartbeat.timestamp.timeIntervalSince(oldHeartbeat!.timestamp) : 0
try await sendHeartbeatSingle(heartbeat, pulsetime: since_last_seconds + 1)
} catch {
log("Failed to send heartbeat: \(error)")
return
}
oldHeartbeat = heartbeat
}
}
func sendHeartbeatSingle(_ heartbeat: Heartbeat, pulsetime: Double) async throws {
let url = URL(string: "\(baseurl)/api/0/buckets/\(bucketName)/heartbeat?pulsetime=\(pulsetime)")!
var urlRequest = URLRequest(url: url)
urlRequest.httpMethod = "POST"
urlRequest.addValue("application/json", forHTTPHeaderField: "Content-Type")
let payload = try! encoder.encode(heartbeat)
let (_, response) = try await URLSession.shared.upload(for: urlRequest, from: payload)
guard (200...299).contains((response as! HTTPURLResponse).statusCode) else {
throw HeartbeatError.error(msg: "Failed to send heartbeat: \(response)")
}
debug("[heartbeat] bucket: \(bucketName), timestamp: \(heartbeat.timestamp), pulsetime: \(round(pulsetime * 10) / 10), app: \(heartbeat.data.app), title: \(heartbeat.data.title ?? ""), url: \(heartbeat.data.url ?? "")")
}
class MainThing {
var observer: AXObserver?
var oldWindow: AXUIElement?
var pollingTimer: Timer?
// list of chrome equivalent browsers
let CHROME_BROWSERS = [
"Google Chrome",
"Google Chrome Canary",
"Chromium",
"Brave Browser",
]
// Gecko-based browsers have no scripting interface for tabs, but expose the
// current page URL on their accessibility tree's AXWebArea node
let FIREFOX_BROWSERS = [
"Firefox",
"Firefox Developer Edition",
"Firefox Nightly",
"Zen",
"Zen Browser",
"LibreWolf",
"Waterfox",
"Floorp",
]
// upper bound on accessibility elements examined per lookup, so a
// pathological tree can't stall the watcher (the web area is typically
// found within a few dozen elements)
let AX_TRAVERSAL_LIMIT = 384
// Search the window's accessibility tree breadth-first for an AXWebArea node
// and return its AXURL, the URL of the loaded page. The top-level document's
// web area sits shallow in Gecko's tree and is reached before any web areas
// of nested iframes, so the first hit is the current page.
// (there is no kAXWebAreaRole constant in HIServices; the role string is
// defined by the browsers themselves)
func geckoURL(window: AXUIElement) -> String? {
var queue: [AXUIElement] = [window]
var index = 0
while index < queue.count && index < AX_TRAVERSAL_LIMIT {
let element = queue[index]
index += 1
var roleRef: AnyObject?
AXUIElementCopyAttributeValue(element, kAXRoleAttribute as CFString, &roleRef)
if roleRef as? String == "AXWebArea" {
var urlRef: AnyObject?
AXUIElementCopyAttributeValue(element, kAXURLAttribute as CFString, &urlRef)
if let url = urlRef as? NSURL {
return url.absoluteString
}
// no URL on the web area (e.g. page still loading); stop rather than
// keep searching, since a deeper hit would be an iframe's web area
return urlRef as? String
}
var childrenRef: AnyObject?
AXUIElementCopyAttributeValue(element, kAXChildrenAttribute as CFString, &childrenRef)
if let children = childrenRef as? [AXUIElement] {
queue.append(contentsOf: children)
}
}
return nil
}
@objc func pollActiveWindow() {
debug("Polling active window")
guard let frontmost = NSWorkspace.shared.frontmostApplication else {
log("Failed to get frontmost application from polling")
return
}
let pid = frontmost.processIdentifier
let focusedApp = AXUIElementCreateApplication(pid)
var focusedWindow: AnyObject?
AXUIElementCopyAttributeValue(focusedApp, kAXFocusedWindowAttribute as CFString, &focusedWindow)
if focusedWindow != nil {
focusedWindowChanged(observer!, window: focusedWindow as! AXUIElement)
}
}
deinit {
pollingTimer?.invalidate()
}
func windowTitleChanged(
_ axObserver: AXObserver,
axElement: AXUIElement,
notification: CFString
) {
guard let frontmost = NSWorkspace.shared.frontmostApplication else {
log("Failed to get frontmost application from window title notification")
return
}
// calculate now before executing any scripting since that can take some time
let nowTime = Date.now
var windowTitle: AnyObject?
AXUIElementCopyAttributeValue(axElement, kAXTitleAttribute as CFString, &windowTitle)
let applicationName = frontmost.localizedName ?? frontmost.bundleIdentifier ?? ""
var data = NetworkMessage(app: applicationName, title: windowTitle as? String ?? "")
if CHROME_BROWSERS.contains(applicationName) {
debug("Chrome browser detected, extracting URL and title")
guard let bundleIdentifier = frontmost.bundleIdentifier else {
log("Failed to get bundle identifier from frontmost application, which was recognized to be Chrome")
return
}
let chromeObject: ChromeProtocol = SBApplication.init(bundleIdentifier: bundleIdentifier)!
guard let windows = chromeObject.windows,
let frontWindow = windows().first else {
log("Failed to get chrome front window")
return
}
guard let activeTab = frontWindow.activeTab else {
log("Failed to get chrome active tab")
return
}
if frontWindow.mode == "incognito" {
data = NetworkMessage(app: "", title: "")
} else {
data.url = activeTab.URL
// the tab title is more accurate and often different than the window title
// however, in some cases the binary does not have the right permissions to read
// the title properly and will return a blank string
if let tabTitle = activeTab.title {
if(tabTitle != "" && data.title != tabTitle) {
error("tab title diff: \(tabTitle), window title: \(data.title)")
data.title = tabTitle
}
}
}
} else if frontmost.localizedName == "Safari" {
debug("Safari browser detected, extracting URL and title")
guard let bundleIdentifier = frontmost.bundleIdentifier else {
log("Failed to get bundle identifier from frontmost application, which was recognized to be Safari")
return
}
let safariObject: SafariApplication = SBApplication.init(bundleIdentifier: bundleIdentifier)!
guard let windows = safariObject.windows,
let frontWindow = windows().first else {
log("Failed to get safari front window")
return
}
guard let activeTab = frontWindow.currentTab else {
log("Failed to get safari active tab")
return
}
// Safari doesn't allow incognito mode to be inspected, so we do not know if we should hide the url
data.url = activeTab.URL
// comment above applies here as well
if let tabTitle = activeTab.name {
if tabTitle != "" && data.title != tabTitle {
error("tab title diff: \(tabTitle), window title: \(data.title)")
data.title = tabTitle
}
}
} else if FIREFOX_BROWSERS.contains(applicationName) {
debug("Firefox-based browser detected, extracting URL from accessibility tree")
// note: private windows are not hidden here (unlike the Chrome incognito
// branch) — Gecko does not mark them in the accessibility tree, and their
// window titles carry a "Private Browsing" suffix for rules to match
data.url = geckoURL(window: axElement)
if data.url == nil {
// Newer Gecko builds instantiate their accessibility engine lazily and
// no longer treat plain tree walks as an assistive client, leaving the
// window's AX tree without any web content. Requesting
// AXEnhancedUserInterface (as VoiceOver does) turns the engine on; the
// call may report an error while the engine spins up, but the tree is
// populated for subsequent polls and stays on for the browser session.
let axApp = AXUIElementCreateApplication(frontmost.processIdentifier)
AXUIElementSetAttributeValue(axApp, "AXEnhancedUserInterface" as CFString, kCFBooleanTrue)
}
}
if researchEnabled {
data = applyResearchFilter(data)
} else if excludeTitle || titleShouldBeExcluded(data.title ?? "") {
data.title = "excluded"
// the URL identifies the page at least as precisely as the title does,
// so an excluded window must not report it either
data.url = nil
}
let heartbeat = Heartbeat(timestamp: nowTime, data: data)
sendHeartbeat(heartbeat)
}
@objc func focusedWindowChanged(_ observer: AXObserver, window: AXUIElement) {
debug("Focused window changed")
if oldWindow != nil {
AXObserverRemoveNotification(observer, oldWindow!, kAXFocusedWindowChangedNotification as CFString)
}
let selfPtr = UnsafeMutableRawPointer(Unmanaged.passUnretained(self).toOpaque())
AXObserverAddNotification(observer, window, kAXTitleChangedNotification as CFString, selfPtr)
windowTitleChanged(
observer, axElement: window, notification: kAXTitleChangedNotification as CFString)
oldWindow = window
}
@objc func focusedAppChanged() {
debug("Focused app changed")
if observer != nil {
CFRunLoopRemoveSource(
RunLoop.current.getCFRunLoop(),
AXObserverGetRunLoopSource(observer!),
CFRunLoopMode.defaultMode
)
}
guard let frontmost = NSWorkspace.shared.frontmostApplication else {
log("Failed to get frontmost application from app change notification")
return
}
let pid = frontmost.processIdentifier
let focusedApp = AXUIElementCreateApplication(pid)
AXObserverCreate(
pid,
{
(
_ axObserver: AXObserver,
axElement: AXUIElement,
notification: CFString,
userData: UnsafeMutableRawPointer?
) -> Void in
guard let userData = userData else {
log("Missing userData")
return
}
let application = Unmanaged<MainThing>.fromOpaque(userData).takeUnretainedValue()
if notification == kAXFocusedWindowChangedNotification as CFString {
application.focusedWindowChanged(axObserver, window: axElement)
} else {
application.windowTitleChanged(
axObserver,
axElement: axElement,
notification: notification
)
}
}, &observer)
let selfPtr = UnsafeMutableRawPointer(Unmanaged.passUnretained(self).toOpaque())
AXObserverAddNotification(observer!, focusedApp, kAXFocusedWindowChangedNotification as CFString, selfPtr)
CFRunLoopAddSource(
RunLoop.current.getCFRunLoop(),
AXObserverGetRunLoopSource(observer!),
CFRunLoopMode.defaultMode
)
var focusedWindow: AnyObject?
AXUIElementCopyAttributeValue(focusedApp, kAXFocusedWindowAttribute as CFString, &focusedWindow)
if focusedWindow != nil {
focusedWindowChanged(observer!, window: focusedWindow as! AXUIElement)
}
}
}
// TODO I believe this is handled by the python wrapper so it isn't needed here
func checkAccess() -> Bool {
let checkOptPrompt = kAXTrustedCheckOptionPrompt.takeUnretainedValue() as NSString
let options = [checkOptPrompt: true]
let accessEnabled = AXIsProcessTrustedWithOptions(options as CFDictionary?)
return accessEnabled
}