Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added .DS_Store
Binary file not shown.
Binary file added MouseHook/.DS_Store
Binary file not shown.
34 changes: 23 additions & 11 deletions MouseHook/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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()
Expand All @@ -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

Expand All @@ -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) {
Expand All @@ -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)
Expand Down Expand Up @@ -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 {
Expand All @@ -158,4 +171,3 @@ extension UserDefaults {
set(enabledMonitors, forKey: "enabledMonitors")
}
}

Binary file added MouseHook/Assets.xcassets/.DS_Store
Binary file not shown.
99 changes: 29 additions & 70 deletions MouseHook/MouseViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
//

import Cocoa
import CommonCrypto

class MouseViewController: NSViewController {
private var cursorImageView: NSImageView!
Expand All @@ -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)
Expand All @@ -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()
Expand All @@ -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)
}


Expand All @@ -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
}
}
1 change: 1 addition & 0 deletions MouseHook/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down