Skip to content

Commit

Permalink
✨ Ability to observe windows
Browse files Browse the repository at this point in the history
  • Loading branch information
MrKai77 committed Jun 22, 2024
1 parent b0c2c9c commit 7c2d907
Show file tree
Hide file tree
Showing 3 changed files with 278 additions and 0 deletions.
4 changes: 4 additions & 0 deletions Loop.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
A85CB5852ACFA5F700BF63E6 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = A85CB5842ACFA5F700BF63E6 /* AppDelegate.swift */; };
A85DDBDA2C1693D4008C103D /* WindowDirection+Snapping.swift in Sources */ = {isa = PBXBuildFile; fileRef = A85DDBD92C1693D4008C103D /* WindowDirection+Snapping.swift */; };
A864F4682AA660CD00579738 /* WindowDragManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A864F4672AA660CD00579738 /* WindowDragManager.swift */; };
A867C20E2C26522B005831BC /* Observer.swift in Sources */ = {isa = PBXBuildFile; fileRef = A867C20D2C26522B005831BC /* Observer.swift */; };
A86949862A8F2BB70051AAAF /* CGKeyCode+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = A86949852A8F2BB60051AAAF /* CGKeyCode+Extensions.swift */; };
A869C1A12B38C6E600AD1A84 /* StageManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A869C1A02B38C6E600AD1A84 /* StageManager.swift */; };
A86A75102C253BBC004AA154 /* Luminare in Frameworks */ = {isa = PBXBuildFile; productRef = A86A750F2C253BBC004AA154 /* Luminare */; };
Expand Down Expand Up @@ -136,6 +137,7 @@
A85CB5842ACFA5F700BF63E6 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
A85DDBD92C1693D4008C103D /* WindowDirection+Snapping.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WindowDirection+Snapping.swift"; sourceTree = "<group>"; };
A864F4672AA660CD00579738 /* WindowDragManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowDragManager.swift; sourceTree = "<group>"; };
A867C20D2C26522B005831BC /* Observer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Observer.swift; sourceTree = "<group>"; };
A86949852A8F2BB60051AAAF /* CGKeyCode+Extensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "CGKeyCode+Extensions.swift"; sourceTree = "<group>"; };
A869C1A02B38C6E600AD1A84 /* StageManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StageManager.swift; sourceTree = "<group>"; };
A86AFD7529888B29008F4892 /* README.md */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = "<group>"; };
Expand Down Expand Up @@ -220,6 +222,7 @@
A86B97AC2AB79E2500099D7F /* ShakeEffect.swift */,
A8D6D3002B6C894C0061B11F /* PaddingModel.swift */,
A8D6D3042B6C92F20061B11F /* WallpaperView.swift */,
A867C20D2C26522B005831BC /* Observer.swift */,
);
path = Utilities;
sourceTree = "<group>";
Expand Down Expand Up @@ -566,6 +569,7 @@
A8D4327B2C13ED3C007BE4F2 /* Icon.swift in Sources */,
A86B97AD2AB79E2500099D7F /* ShakeEffect.swift in Sources */,
A8D6D3032B6C8D750061B11F /* PaddingPreviewView.swift in Sources */,
A867C20E2C26522B005831BC /* Observer.swift in Sources */,
A82740982AB00FCE00B9BDC5 /* Color+Extensions.swift in Sources */,
A82B1AF62BD35C8500E2F3F9 /* BehaviorConfiguration.swift in Sources */,
A869C1A12B38C6E600AD1A84 /* StageManager.swift in Sources */,
Expand Down
246 changes: 246 additions & 0 deletions Loop/Utilities/Observer.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,246 @@
//
// Observer.swift
// Loop
//
// Created by Kai Azim on 2024-06-21.
//
// Mostly taken from https://github.com/tmandry/AXSwift/, thank you so much :)

import Cocoa
import Darwin
import Foundation

/// Observers watch for events on an application's UI elements.
///
/// Events are received as part of the application's default run loop.
class Observer {
typealias Callback = (
_ observer: Observer,
_ window: Window,
_ notification: AXNotification
) -> ()

typealias CallbackWithInfo = (
_ observer: Observer,
_ window: Window,
_ notification: AXNotification,
_ info: [String: AnyObject]?
) -> ()

let pid: pid_t
let axObserver: AXObserver!
let callback: Callback?
let callbackWithInfo: CallbackWithInfo?

// public fileprivate(set) lazy var application: Application = Application(forKnownProcessID: self.pid)!

/// Creates and starts an observer on the given `processID`.
public init(processID: pid_t, callback: @escaping Callback) throws {
var axObserver: AXObserver?
let error = AXObserverCreate(processID, internalCallback, &axObserver)

self.pid = processID
self.axObserver = axObserver
self.callback = callback
self.callbackWithInfo = nil

guard error == .success else {
throw error
}
assert(axObserver != nil)

start()
}

/// Creates and starts an observer on the given `processID`.
///
/// Use this initializer if you want the extra user info provided with notifications.
public init(processID: pid_t, callback: @escaping CallbackWithInfo) throws {
var axObserver: AXObserver?
let error = AXObserverCreateWithInfoCallback(processID, internalInfoCallback, &axObserver)

self.pid = processID
self.axObserver = axObserver
self.callback = nil
self.callbackWithInfo = callback

guard error == .success else {
throw error
}
assert(axObserver != nil)

start()
}

deinit {
stop()
}

/// Starts watching for events. You don't need to call this method unless you use `stop()`.
///
/// If the observer has already been started, this method does nothing.
public func start() {
CFRunLoopAddSource(
RunLoop.current.getCFRunLoop(),
AXObserverGetRunLoopSource(axObserver),
CFRunLoopMode.defaultMode
)
}

/// Stops sending events to your callback until the next call to `start`.
///
/// If the observer has already been started, this method does nothing.
///
/// - important: Events will still be queued in the target process until the Observer is started
/// again or destroyed. If you don't want them, create a new Observer.
public func stop() {
CFRunLoopRemoveSource(
RunLoop.current.getCFRunLoop(),
AXObserverGetRunLoopSource(axObserver),
CFRunLoopMode.defaultMode
)
}

/// Adds a notification for the observer to watch.
///
/// - parameter notification: The name of the notification to watch for.
/// - parameter forElement: The element to watch for the notification on. Must belong to the
/// application this observer was created on.
/// - note: The underlying API returns an error if the notification is already added, but that
/// error is not passed on for consistency with `start()` and `stop()`.
/// - throws: `Error.NotificationUnsupported`: The element does not support notifications (note
/// that the system-wide element does not support notifications).
public func addNotification(
_ notification: AXNotification,
forElement element: Window
) throws {
let selfPtr = UnsafeMutableRawPointer(Unmanaged.passUnretained(self).toOpaque())
let error = AXObserverAddNotification(
axObserver, element.axWindow, notification.rawValue as CFString, selfPtr
)
guard error == .success || error == .notificationAlreadyRegistered else {
throw error
}
}

/// Removes a notification from the observer.
///
/// - parameter notification: The name of the notification to stop watching.
/// - parameter forElement: The element to stop watching the notification on.
/// - note: The underlying API returns an error if the notification is not present, but that
/// error is not passed on for consistency with `start()` and `stop()`.
/// - throws: `Error.NotificationUnsupported`: The element does not support notifications (note
/// that the system-wide element does not support notifications).
public func removeNotification(
_ notification: AXNotification,
forElement element: Window
) throws {
let error = AXObserverRemoveNotification(
axObserver, element.axWindow, notification.rawValue as CFString
)
guard error == .success || error == .notificationNotRegistered else {
throw error
}
}
}

private func internalCallback(
_: AXObserver,
axElement: AXUIElement,
notification: CFString,
userData: UnsafeMutableRawPointer?
) {
guard let userData else { fatalError("userData should be an AXSwift.Observer") }
guard let element = try? Window(element: axElement) else { return }

let observer = Unmanaged<Observer>.fromOpaque(userData).takeUnretainedValue()
guard let notif = AXNotification(rawValue: notification as String) else {
NSLog("Unknown AX notification %s received", notification as String)
return
}
observer.callback!(observer, element, notif)
}

private func internalInfoCallback(
_: AXObserver,
axElement: AXUIElement,
notification: CFString,
cfInfo: CFDictionary,
userData: UnsafeMutableRawPointer?
) {
guard let userData else { fatalError("userData should be an AXSwift.Observer") }
guard let element = try? Window(element: axElement) else { return }

let observer = Unmanaged<Observer>.fromOpaque(userData).takeUnretainedValue()
let info = cfInfo as NSDictionary? as! [String: AnyObject]?
guard let notif = AXNotification(rawValue: notification as String) else {
NSLog("Unknown AX notification %s received", notification as String)
return
}
observer.callbackWithInfo!(observer, element, notif, info)
}

public enum AXNotification: String {
// Focus notifications
case mainWindowChanged = "AXMainWindowChanged"
case focusedWindowChanged = "AXFocusedWindowChanged"
case focusedUIElementChanged = "AXFocusedUIElementChanged"
case focusedTabChanged = "AXFocusedTabChanged"

// Application notifications
case applicationActivated = "AXApplicationActivated"
case applicationDeactivated = "AXApplicationDeactivated"
case applicationHidden = "AXApplicationHidden"
case applicationShown = "AXApplicationShown"

// Window notifications
case windowCreated = "AXWindowCreated"
case windowMoved = "AXWindowMoved"
case windowResized = "AXWindowResized"
case windowMiniaturized = "AXWindowMiniaturized"
case windowDeminiaturized = "AXWindowDeminiaturized"

// Drawer & sheet notifications
case drawerCreated = "AXDrawerCreated"
case sheetCreated = "AXSheetCreated"

// Element notifications
case uiElementDestroyed = "AXUIElementDestroyed"
case valueChanged = "AXValueChanged"
case titleChanged = "AXTitleChanged"
case resized = "AXResized"
case moved = "AXMoved"
case created = "AXCreated"

// Used when UI changes require the attention of assistive application. Pass along a user info
// dictionary with the key NSAccessibilityUIElementsKey and an array of elements that have been
// added or changed as a result of this layout change.
case layoutChanged = "AXLayoutChanged"

// Misc notifications
case helpTagCreated = "AXHelpTagCreated"
case selectedTextChanged = "AXSelectedTextChanged"
case rowCountChanged = "AXRowCountChanged"
case selectedChildrenChanged = "AXSelectedChildrenChanged"
case selectedRowsChanged = "AXSelectedRowsChanged"
case selectedColumnsChanged = "AXSelectedColumnsChanged"
case loadComplete = "AXLoadComplete"

case rowExpanded = "AXRowExpanded"
case rowCollapsed = "AXRowCollapsed"

// Cell-table notifications
case selectedCellsChanged = "AXSelectedCellsChanged"

// Layout area notifications
case unitsChanged = "AXUnitsChanged"
case selectedChildrenMoved = "AXSelectedChildrenMoved"

// This notification allows an application to request that an announcement be made to the user
// by an assistive application such as VoiceOver. The notification requires a user info
// dictionary with the key NSAccessibilityAnnouncementKey and the announcement as a localized
// string. In addition, the key NSAccessibilityAnnouncementPriorityKey should also be used to
// help an assistive application determine the importance of this announcement. This
// notification should be posted for the application element.
case announcementRequested = "AXAnnouncementRequested"
}
28 changes: 28 additions & 0 deletions Loop/Window Management/Window.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ class Window {
let cgWindowID: CGWindowID
let nsRunningApplication: NSRunningApplication?

var observer: Observer?

init(element: AXUIElement) throws {
self.axWindow = element

Expand Down Expand Up @@ -57,6 +59,12 @@ class Window {
try self.init(element: window)
}

deinit {
if let observer = self.observer {
observer.stop()
}
}

var role: NSAccessibility.Role? {
do {
guard let value: String = try self.axWindow.getValue(.role) else {
Expand Down Expand Up @@ -288,4 +296,24 @@ class Window {
self.enhancedUserInterface = true
}
}

public func createObserver(_ callback: @escaping Observer.Callback) -> Observer? {
do {
return try Observer(processID: try self.axWindow.getPID()!, callback: callback)

Check warning on line 302 in Loop/Window Management/Window.swift

View workflow job for this annotation

GitHub Actions / Lint

Move inline try keyword(s) to start of expression. (hoistTry)
} catch AXError.invalidUIElement {
return nil
} catch let error {

Check warning on line 305 in Loop/Window Management/Window.swift

View workflow job for this annotation

GitHub Actions / Lint

Remove redundant let error from catch clause. (redundantLetError)
fatalError("Caught unexpected error creating observer: \(error)")
}
}

public func createObserver(_ callback: @escaping Observer.CallbackWithInfo) -> Observer? {
do {
return try Observer(processID: try self.axWindow.getPID()!, callback: callback)

Check warning on line 312 in Loop/Window Management/Window.swift

View workflow job for this annotation

GitHub Actions / Lint

Move inline try keyword(s) to start of expression. (hoistTry)
} catch AXError.invalidUIElement {
return nil
} catch let error {

Check warning on line 315 in Loop/Window Management/Window.swift

View workflow job for this annotation

GitHub Actions / Lint

Remove redundant let error from catch clause. (redundantLetError)
fatalError("Caught unexpected error creating observer: \(error)")
}
}
}

0 comments on commit 7c2d907

Please sign in to comment.