macOS harvest cursor from any app šŸ˜

July 27, 2023

As a pet project I was building a screenshot app, and I wanted its cursors to match the ones of macOS screenshot utility: Crosshair and Camera.

This was harder than expected. Iā€™ll tell you the whole story because I find it fun and interesting, but feel free to jump straight to the solution.

Default system cursors in NSCursor

In a Mac app, the NSCursor class exposes a number of default cursors, like the arrow Arrow, I-beam I-beam, pointing hand Pointing hand, various resize cursors, and even a cute ā€œdisappearing itemā€ cursor Disappearing item (that I kinda want to name ā€œpoofā€ for some reason).

There is also a crosshair cursor Crosshair, however itā€™s not the same that the system screenshot utility uses. And the camera cursor is nowhere to be found.

So our last resort is to set a custom cursor from an image, e.g. for a cursor thatā€™s 32x32 pixels where we want the ā€œhot spotā€ to be in the middle:

let image = NSImage(named: "cursor.png")
let hotSpot = NSPoint(x: 16, y: 16)
let cursor = NSCursor(image: image, hotSpot: hotSpot)

But what image do we use here?

macOS default cursors source location?

By doing a bit of digging in the /System directory, we find the following path:

/System/Library/Frameworks/ApplicationServices.framework/Versions/A/Frameworks/HIServices.framework/Versions/A/Resources/cursors

This seems to contain all the system cursors, one directory for each, containing a cursor.pdf and info.plist!

Here, we effectively have screenshotselection that matches the screen capture utilityā€™s crosshair, and screenshotwindow that matches the camera cursor shown during window selection. Neat.

Parsing the info.plist, we find the hot spot coordinates:

$ plutil -p /System/Library/Frameworks/ApplicationServices.framework/Versions/A/Frameworks/HIServices.framework/Versions/A/Resources/cursors/screenshotselection/info.plist
{
  "hotx" => 15
  "hotx-scaled" => 15
  "hoty" => 15
  "hoty-scaled" => 15
}

We can now load those programmatically:

func loadCursor(_ name: String) -> NSCursor? {
  let root =
    "/System/Library/Frameworks/ApplicationServices.framework/Versions/A/Frameworks/HIServices.framework/Versions/A/Resources/cursors"

  guard let data = FileManager.default.contents(atPath: "\(root)/\(name)/info.plist")
  else {
    return nil
  }

  guard
    let plist = try? PropertyListSerialization.propertyList(from: data, options: [], format: nil)
      as? [String: Any]
  else {
    return nil
  }

  guard let pdfData = try? Data(contentsOf: URL(fileURLWithPath: "\(root)/\(name)/cursor.pdf"))
  else {
    return nil
  }

  guard let cursorImage = NSImage(data: pdfData) else {
    return nil
  }

  let hotSpot = NSPoint(
    x: plist["hotx"] as! Int? ?? Int(cursorImage.size.width) / 2,
    y: plist["hoty"] as! Int? ?? Int(cursorImage.size.height) / 2
  )

  return NSCursor(image: cursorImage, hotSpot: hotSpot)
}

Letā€™s use this function in a basic example to demonstrate it:

import Cocoa

let app = NSApplication.shared

if let cursor = loadCursor("screenshotselection") {
  DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
    cursor.set()
  }
}

app.setActivationPolicy(.regular)
app.activate(ignoringOtherApps: true)
app.run()

Note: here we call cursor.set() after a delay because it doesnā€™t always work when called right away for reasons that are not familiar to me.

In a real app, you probably want to subclass NSView, override resetCursorRects, and call addCursorRect in it.

This actually looks good for the camera! But for the crosshair, it doesnā€™t seem to match the original one.

The original crosshair size appears to be 50x50 pixels, while this one is 46x46. More importantly, the original one has some kind of light outline that makes it visible on darker backgrounds, that is completely missing from that cursor PDF we just found. You can see the difference easily:

Original Custom
Original crosshair over grey background Custom crosshair over grey background
Original crosshair over dark background Custom crosshair over dark background

So the screen capture utility doesnā€™t seem to be using this cursor from HIServices.framework.

I tried exploring the contents of the screen capture app in /System/Library/CoreServices/screencaptureui.app, especially the Contents/Resources/Assets.car file, exploring it using Asset Catalog Tinkerer, but it didnā€™t contain anything useful.

Harvesting the cursor programmatically

The next idea I tried was to see if I could somehow access the cursor data from other apps from my Swift app.

It turns out NSCursor exposes a currentSystem property, containing current system cursor (as opposed to NSCursor.current that contains your own applicationā€™s current cursor).

This way we can easily access the image data of the currentSystem cursor, as well as its hotSpot to be used later in our own custom cursor.

import Cocoa

let cursor = NSCursor.currentSystem!

print(cursor.hotSpot)

let image = cursor.image.cgImage(forProposedRect: nil, context: nil, hints: nil)!
let bitmap = NSBitmapImageRep(cgImage: image)
let data = bitmap.representation(using: .png, properties: [:])!
try! data.write(to: URL(fileURLWithPath: "cursor.png"))

We can put this code in a file test.swift, and run it with sleep 5 && swift test.swift. This gives us 5 seconds to do whatever is needed to show the cursor we want to harvest, before our script actually runs and saves the current system cursor to a PNG file.

In the case of the screen capture utility crosshair, Iā€™ve got this (pictured over transparent, grey and dark background to show how well it reacts to those):

Harvested crosshair Harvested crosshair over grey background Harvested crosshair over dark background

Perfect. šŸ‘Œ

I didnā€™t want to get into adding support for showing the dynamic coordinates as part of the cursor, so as far as Iā€™m concerned, I got rid of those and used just the crosshair in my app.

Wrapping up

I hope you found this post useful! Now if you want to get the cursor data from any app, in its original transparent quality, you can use the simple script above to do so. Enjoy!

Want to leave a comment?

Join the discussion on Twitter or send me an email! šŸ’Œ
This post helped you? Buy me a coffee! šŸ»