[tl;dr;] The frame of the macOS system HUD is (origin = (x = 620, y = 140), size = (width = 200, height = 200))
... (at the time of writing)
In one of the reviews of CapsLocker a user disliked, that the HUDs in CapsLocker do not match the size and position of the HUDs that macOS shows (e.g. when changing the volume or display brightness).
Since I was anyway working on a new release, I said to myself "How hard can it be?" and decided to also adapt CapsLocker's HUD to match the system HUDs. I was already using a NSVisualEffectView
to get the blur effect the app also already automatically adapted to macOS's "dark mode". So it was only a matter of adjusting the size and position, right?
(Spoiler alert: I didn't know the rabbit hole that was awaiting me)
Where to start?
At first I thought "Maybe there are macOS docs around the HUDs". But some googling didn't really reveal anything useful. So that didn't seem to get me anywhere.
So I had to find out which process is responsible for showing the HUDs. I threw a few more keywords at Google and eventually found this repository, which contains an alternative HUD. Quickly scanning through the source code, the most interesting part was the section about a OSDUIHelper
. OSD and UI sounded promising. Doing a quick sudo find / -name "*OSDUIHelper*"
gave away its home: /System/Library/CoreServices/OSDUIHelper.app
The beginning of the adventure
But simply launching it didn't do much. Since it's just an .app
I had a look into the package. I figured it had to have some kind of resources which might already help me. And indeed it had resources. PDFs for various HUD contents (e.g. the volume icon). My hope was, that the HUD's size matched the size of the PDF resources. So I took the size of one of said PDFs and used it as my HUD size (170 x 170pt). But unfortunately it didn't really match the size of the system HUDs. I could have just took that as a start and make my way to the exact size using a trial and error approach. But where would be the fun in that? (Also, I wanted the corner radius and position to match as well).
Deeper down the rabbit hole
The next logic step for me was to take apart the binary. My knowledge of Assembly isn't the best, but I thought I give it a try nevertheless. So I started Hopper and disassembled it. Right in the EntryPoint code I found out how the app was communicating with the system (and that it was at least partially written in Swift 🙃):
r14 = objc_allocWithZone(@class(NSXPCListener));
r15 = (extension in Foundation):Swift.String._bridgeToObjectiveC() -> __ObjC.NSString("com.apple.OSDUIHelper", 0x15, 0x0);
rbx = [r14 initWithMachServiceName:r15];
[r15 release];
*qword = rbx;
[rbx setDelegate:*qword];
[rbx resume];
So the OSDUIHelper
is listening using a NSXPCListener
and the corresponding Mach service name is "com.apple.OSDUIHelper"
. One of the other results of find
was a LaunchAgent (/System/Library/LaunchAgents/com.apple.OSDUIHelper.plist
) whose contents confirmed that a XPC connection was used. With that insight, I thought maybe I could use the OSDUIHelper directly - but more on that later.
Looking through the other symbols, I found that there's a OSDRoundWindow
(whose mangled name is _TtC11OSDUIHelper14OSDRoundWindow
). That sounded pretty much like the window I was interested in.
Looking at its structure I spotted a method named CORNER_RADIUS
(at which point I also started wondering if Apple had any coding style guide since this method didn't follow any guidelines of Swift nor Objective-C).
Either way, the implementation was more or less just a constant:
double -[_TtC11OSDUIHelper14OSDRoundWindow CORNER_RADIUS](void * self, void * _cmd) {
xmm0 = intrinsic_movsd(xmm0, *qword); // qword_100011488
return xmm0;
}
-> qword_100011488:
dq 0x4032000000000000
Taking 0x4032000000000000
and applying how double
is stored according to IEEE754 (1 bit for the sign, 11 bits for the exponent and 52 bits for the fraction) results in 1.8e1
or simply 18.0
.
But apart from the corner radius and the structural insight the disassembled binary wasn't of much help. I wasn't able to extract the size and position of the window.
A binary is made for running (and debugging)
Hopper is able to run a binary with a debugger (LLDB) attached. So I decided to give this a try. Unfortunately, OSDUIHelper
is within the scope of System Integrity Protection (SIP) - don't ask me why a HUD is so important that it is part of the system's integrity, though.
So I rebooted into the recovery mode and disabled SIP and rebooted again into the normal system. Now things looked better. LLDB was able to run the binary and attach to it.
Knowing the mangled name of the window, I simply created an instance of that class and started poking around with it. But apart from getting the CORNER_RADIUS
constant confirmed to be 18.0
, there wasn't much useful information to get. The frame
was still zero. And I couldn't really get it to layout itself. And triggering a HUD by e.g. changing the volume didn't do anything to my instance of OSDUIHelper
.
Debugging the real thing
Since I knew the name of the process, it came to me that there should also be the "live process" which was actually showing the HUDs when I e.g. changed the volume. Running sudo ps aux | grep OSDUIHelper
confirmed this thought. So I went right at it and attached lldb to the live process.
Now where to set the breakpoints... CORNER_RADIUS
surely isn't of much help since the constant is likely read only once. And apart from that, there were only very few methods: _cornerMask
, _backdropBleedAmount
and two initializers. The two initializers likely aren't of much use either, because depending how the window is managed, only one is ever alive. And according to the disassembled binary the other two methods have similar semantics as CORNER_RADIUS
meaning they are also only called once.
But since OSDROundWindow
in the end is also just a NSWindow
subclass, there are many methods on NSWindow
I could use to set breakpoints. Looking at some runtime headers, I found -[NSWindow _setVisible:]
to be the most interesting one. A br set -n "[NSWindow _setVisible:]"
and a volume change later, the process actually paused right there.
With the System V x86-64 calling convention, the first six arguments are stored in the %RDI
, %RSI
, %RDX
, %RCX
, %R8
, %R9
registers. Since every method call in Objective-C passes self
as first argument, the pointer to the window had to be in %RDI
.
And once I had the pointer, things were pretty easy:
(lldb) po [(NSWindow *)0x00007faa13403300 frame]
(origin = (x = 620, y = 140), size = (width = 200, height = 200))
Finally I had what I wanted. The x
position of the frame was rather irrelevant since it's always centered (which the HUD of CapsLocker also always had been). So I took the other values and put them into my HUD in CapsLocker. Build and run and try it out. And hooray, CapsLocker's HUD had the exact same position and size as the system's HUD. Also, the y
position seemed to be constant across all display sizes.
With that knowledge I've finished version 1.2.1 and submitted it for review. A little more than a day later it went live on the AppStore.
Using the XPC connection
As previously mentioned, I had the thought of talking to OSDUIHelper
directly to get it to show the HUD. The weekend after, I sat down and tried to get this to work. I had to disassemble a few other frameworks that are involved in showing the HUDs for volume changes, brightness changes, etc. to figure out how the XPC connection had to be set up.
A few hours later I had it all figured out and was able to get the HUD to show with the following code:
@objc enum OSDImage: CLongLong {
case eject = 6
/* and more cases from 1 to 28 (except 18 and 24) */
}
@objc protocol OSDUIHelperProtocol {
@objc func showImage(_ img: OSDImage,
onDisplayID: CGDirectDisplayID,
priority: CUnsignedInt,
msecUntilFade: CUnsignedInt,
withText: String?)
/* and more methods from OSDUIHelper.OSDUIHelperProtocol */
}
let conn = NSXPCConnection(machServiceName: "com.apple.OSDUIHelper", options: [])
conn.remoteObjectInterface = NSXPCInterface(with: OSDUIHelperProtocol.self)
conn.interruptionHandler = { print("Interrupted!") }
conn.invalidationHandler = { print("Invalidated!") }
conn.resume()
let target = conn.remoteObjectProxyWithErrorHandler { print("Failed: \($0)") }
guard let helper = target as? OSDUIHelperProtocol else { fatalError("Wrong type: \(target)") }
helper.showImage(.eject, onDisplayID: CGMainDisplayID(), priority: 0x1f4, msecUntilFade: 2000, withText: "🙃 GOT YA HACKED 🙃")
The last call then resulted in the following HUD being shown:
There are other methods to e.g. show an image at a given path, but they only work with paths within the sandbox of OSDUIHelper
so I don't think they're of much use. Also, it's probably not a very good idea to use this as a HUD for CapsLocker since this goes into the direction of private API. And on top of that the OSDUIHelper has quite a few bugs when showing a HUD with text. It always draws the text over the last shown text. To get rid of the text, you first need to show a HUD without a text (there's another method without a withText
parameter - simply passing nil
doesn't do the trick).
But it would definitively be nice if Apple allowed using the HUD for such things by making this a public API.