diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..1680888 Binary files /dev/null and b/.DS_Store differ diff --git a/MouseHook/.DS_Store b/MouseHook/.DS_Store new file mode 100644 index 0000000..57f9bde Binary files /dev/null and b/MouseHook/.DS_Store differ diff --git a/MouseHook/AppDelegate.swift b/MouseHook/AppDelegate.swift index 06ce34c..6d4880e 100644 --- a/MouseHook/AppDelegate.swift +++ b/MouseHook/AppDelegate.swift @@ -18,6 +18,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { private var refreshObserver: NSKeyValueObservation! private var eventMonitor: Any! private var cancellationToken: AnyCancellable? + private let eventQueue = DispatchQueue(label: "com.mousehook.events", qos: .userInteractive) @objc private dynamic var fastFreqRefresh: Bool = false @@ -30,13 +31,14 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { } func applicationDidFinishLaunching(_ aNotification: Notification) { + NSApp.setActivationPolicy(.accessory) // hide Dock icon; status item only statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) if let button = statusItem.button { button.image = NSImage(named: NSImage.Name("mousehook-menubar")) } mouseController = MouseViewController() - fastEventThrottle = self.eventSubject.throttle(for: .seconds(1.0/60.0), scheduler: DispatchQueue.global(), latest: true) // 60Hz - slowEventThrottle = self.eventSubject.throttle(for: .seconds(1.0/5.0), scheduler: DispatchQueue.global(), latest: true) // 5Hz + fastEventThrottle = self.eventSubject.throttle(for: fastRefreshStride(), scheduler: eventQueue, latest: true) + slowEventThrottle = self.eventSubject.throttle(for: .seconds(1.0/5.0), scheduler: eventQueue, latest: true) // 5Hz setupMouseWindow() setupMenu() @@ -47,12 +49,12 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { let eventThrottle = change.newValue! ? self.fastEventThrottle : self.slowEventThrottle - self.cancellationToken = eventThrottle?.subscribe(on: DispatchQueue.global()).sink { event in - // Don't bother updating mouse position if it is currently hidden - DispatchQueue.main.async { - self.update(event) - } - } + self.cancellationToken = eventThrottle? + .receive(on: DispatchQueue.main) + .sink(receiveValue: { [weak self] event in + // Don't bother updating mouse position if it is currently hidden + self?.update(event) + }) }) fastFreqRefresh = true @@ -66,7 +68,8 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { let cursorVisible = mouseController.update(event) if cursorVisible { - mouseWindow.setFrameOrigin(mouseController.getFrameOrigin(event.locationInWindow)) + let mousePosition = NSEvent.mouseLocation + mouseWindow.setFrameOrigin(mouseController.getFrameOrigin(mousePosition)) } // Only use fast refresh rate if the cursor is actually visible if (fastFreqRefresh != cursorVisible) { @@ -78,7 +81,9 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { mouseWindow = NSWindow(contentViewController: mouseController) mouseWindow.styleMask = [.borderless] mouseWindow.ignoresMouseEvents = true - mouseWindow.setFrame(NSRect(x: 0, y: 0, width: 50, height: 50), display: false) + let initialSize = mouseController.currentCursorSize == .zero ? NSSize(width: 50, height: 50) : mouseController.currentCursorSize + mouseWindow.setFrame(NSRect(origin: .zero, size: initialSize), display: false) + mouseWindow.hasShadow = false mouseWindow.backgroundColor = .clear mouseWindow.level = .screenSaver mouseWindow.orderFront(nil) @@ -146,6 +151,14 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { userDefaults.setActiveMonitors(enabledMonitors) } + private func fastRefreshStride() -> DispatchQueue.SchedulerTimeType.Stride { + let targetFPS = NSScreen.screens + .compactMap { $0.maximumFramesPerSecond } + .filter { $0 > 0 } + .max() ?? 60 + return .seconds(1.0 / Double(targetFPS)) + } + } extension UserDefaults { @@ -158,4 +171,3 @@ extension UserDefaults { set(enabledMonitors, forKey: "enabledMonitors") } } - diff --git a/MouseHook/Assets.xcassets/.DS_Store b/MouseHook/Assets.xcassets/.DS_Store new file mode 100644 index 0000000..25c8c5f Binary files /dev/null and b/MouseHook/Assets.xcassets/.DS_Store differ diff --git a/MouseHook/MouseViewController.swift b/MouseHook/MouseViewController.swift index 4f92e4e..8bd373b 100644 --- a/MouseHook/MouseViewController.swift +++ b/MouseHook/MouseViewController.swift @@ -6,7 +6,6 @@ // import Cocoa -import CommonCrypto class MouseViewController: NSViewController { private var cursorImageView: NSImageView! @@ -17,10 +16,7 @@ class MouseViewController: NSViewController { @objc private dynamic var currentCursor: NSCursor = NSCursor.current - private let pointingHandHash: String? = NSCursor.pointingHand.image.tiffRepresentation?.sha256 - private let openHandHash: String? = NSCursor.openHand.image.tiffRepresentation?.sha256 - private let closedHandHash: String? = NSCursor.closedHand.image.tiffRepresentation?.sha256 - private let hideRefreshIntervalSecs = 1.5 + var currentCursorSize: NSSize { currentCursor.image.size } override init(nibName nibNameOrNil: NSNib.Name?, bundle nibBundleOrNil: Bundle?) { super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil) @@ -43,6 +39,7 @@ class MouseViewController: NSViewController { cursorImageView.image = currentCursor.image cursorImageView.imageScaling = .scaleNone cursorImageView.layer?.backgroundColor = NSColor.clear.cgColor + cursorImageView.frame = NSRect(origin: .zero, size: currentCursor.image.size) self.view = cursorImageView setupObservers() @@ -55,45 +52,13 @@ class MouseViewController: NSViewController { } }) cursorObserver = self.observe(\.currentCursor, options: [.initial, .new], changeHandler: { (this, change) in - self.cursorImageView.image = this.currentCursor.image + self.applyCurrentCursorImage() }) } - /** These magic numbers work for the default mouse cursor at the default size on MacOS Monterey. - I can't guarantee it will work for custom mouse cursors or different OS versions, but I think it'll be pretty close. **/ func getFrameOrigin(_ pt: NSPoint) -> NSPoint { - var offset: (Double, Double) - - // There is no simple way to check the exact image of a cursor, but size is cheap, easy, and works in most cases to determine the offset. - switch (currentCursor.image.size) { - case NSCursor.arrow.image.size: - offset = (21, 32) - case NSCursor.pointingHand.image.size: - // There are representations that match the size of pointingHand but not the offset, so check the hash of the image - switch (currentCursor.image.tiffRepresentation?.sha256) { - case pointingHandHash: - offset = (21.5, 32) - case openHandHash: - offset = (24.5, 24.5) - case closedHandHash: - offset = (24.5, 25) - default: - // It is likely a type of iBeam - offset = (24, 28) - } - case NSCursor.iBeam.image.size: - fallthrough - case NSCursor.resizeLeftRight.image.size: - offset = (24.5, 24.5) - case NSCursor.disappearingItem.image.size: - offset = (16.5, 38.5) - case NSCursor.dragLink.image.size: - offset = (28, 32) - default: - offset = (23.5, 25.5) - } - - return NSPoint(x: pt.x - offset.0, y: pt.y - offset.1) + let hotSpot = adjustedHotSpot() + return NSPoint(x: pt.x - hotSpot.x, y: pt.y - hotSpot.y) } @@ -113,44 +78,38 @@ class MouseViewController: NSViewController { } func update(_ event: NSEvent) -> Bool { - if (NSCursor.currentSystem != nil) { - currentCursor = NSCursor.currentSystem! + if let systemCursor = NSCursor.currentSystem, systemCursor !== currentCursor { + currentCursor = systemCursor } - let screenBounds = currentMonitor?.frame - // Apply a small transform to the y axis to avoid it going out of bounds along the top axis - let mousePosition = event.locationInWindow.applying(CGAffineTransform(translationX: 0.0, y: -0.00001)) - if (screenBounds?.contains(mousePosition) != true) { + let mousePosition = NSEvent.mouseLocation + if (currentMonitor?.frame.contains(mousePosition) != true) { resetCurrentMonitor(mousePosition) } return !cursorImageView.isHidden } -} - -extension Data { - public var sha256:String { - get { - return hexStringFromData(input: digest(input: self as NSData)) - } - } - - private func digest(input : NSData) -> NSData { - let digestLength = Int(CC_SHA256_DIGEST_LENGTH) - var hash = [UInt8](repeating: 0, count: digestLength) - CC_SHA256(input.bytes, UInt32(input.length), &hash) - return NSData(bytes: hash, length: digestLength) + + private func applyCurrentCursorImage() { + let size = currentCursor.image.size + cursorImageView.image = currentCursor.image + cursorImageView.frame = NSRect(origin: .zero, size: size) + view.window?.setContentSize(size) + cursorImageView.layer?.contentsScale = currentMonitor?.backingScaleFactor ?? NSScreen.main?.backingScaleFactor ?? 1.0 } - - private func hexStringFromData(input: NSData) -> String { - var bytes = [UInt8](repeating: 0, count: input.length) - input.getBytes(&bytes, length: input.length) - - var hexString = "" - for byte in bytes { - hexString += String(format:"%02x", UInt8(byte)) + + private func adjustedHotSpot() -> NSPoint { + var spot = currentCursor.hotSpot + if isArrowCursor() { + // Arrow hotspot appears to be measured from the top in recent macOS builds. + // Flip to a bottom-origin measurement so the overlay aligns with the system arrow tip. + spot = NSPoint(x: spot.x, y: currentCursor.image.size.height - spot.y) } - - return hexString + return spot + } + + private func isArrowCursor() -> Bool { + let arrow = NSCursor.arrow + return currentCursor.image.size == arrow.image.size && currentCursor.hotSpot == arrow.hotSpot } } diff --git a/MouseHook/main.swift b/MouseHook/main.swift index 32ca55a..5316f4f 100644 --- a/MouseHook/main.swift +++ b/MouseHook/main.swift @@ -8,6 +8,7 @@ import Cocoa let app = NSApplication.shared +app.setActivationPolicy(.accessory) // menu bar only; hide Dock icon let delegate = AppDelegate() app.delegate = delegate