Replicating macOS Dictation's glowing text cursor effect
As part of learning to reverse engineer private APIs, I wanted to replicate the glowing text cursor effect that shows when you press the dictation key on macOS. To spare your time I shall give you the code now, and you can decide if you want to read more about my process in discovering how to do this.
TLDR
// Enable glowing effect
let indicator = textView.perform(Selector(("_insertionIndicator"))).takeUnretainedValue()
let notification = NSNotification(name: .init("_NSTextInputContextDictationDidStartNotification"), object: nil)
_ = indicator.perform(Selector(("dictationStateDidChange:")), with: notification)
// Disable glowing effect
let indicator = textView.perform(Selector(("_insertionIndicator"))).takeUnretainedValue()
let notification = NSNotification(name: .init("_NSTextInputContextDictationDidEndNotification"), object: nil)
_ = indicator.perform(Selector(("dictationStateDidChange:")), with: notification)
Set up
To understand how Dictation triggers this effect on the text cursor, let’s create a SwiftUI project with a NSTextView
. You could use TextField
or TextEditor
but you will need to do some SwiftUI-foo to get the backing NSTextView/NSTextField
.
BetterTextField.swift
This simply wraps a NSScrollView
and NSTextView
in a SwiftUI component, and passes the NSTextView
to a configure
method from the parent.
import SwiftUI
struct BetterTextView: NSViewRepresentable {
@Binding var text: String
let configure: (_ textView: NSTextView) -> Void
func makeNSView(context: Context) -> NSScrollView {
let textView = NSTextView()
textView.isEditable = true
textView.isSelectable = true
textView.isRichText = false
textView.drawsBackground = false
textView.delegate = context.coordinator
textView.font = NSFont.systemFont(ofSize: 14)
textView.isVerticallyResizable = true
textView.isHorizontallyResizable = true
textView.autoresizingMask = [.width, .height]
textView.textContainer?.widthTracksTextView = true
textView.textContainer?.heightTracksTextView = false
textView.textContainerInset = NSSize(width: 8, height: 8)
textView.textContainer?.lineFragmentPadding = 4
let scrollView = NSScrollView()
scrollView.hasVerticalScroller = true
scrollView.hasHorizontalScroller = false
scrollView.borderType = .noBorder
scrollView.autohidesScrollers = true
scrollView.translatesAutoresizingMaskIntoConstraints = false
scrollView.documentView = textView
textView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
textView.leadingAnchor.constraint(equalTo: scrollView.contentView.leadingAnchor),
textView.trailingAnchor.constraint(equalTo: scrollView.contentView.trailingAnchor),
textView.topAnchor.constraint(equalTo: scrollView.contentView.topAnchor),
textView.bottomAnchor.constraint(equalTo: scrollView.contentView.bottomAnchor)
])
configure(textView)
return scrollView
}
func updateNSView(_ nsView: NSScrollView, context: Context) {
if let textView = nsView.documentView as? NSTextView, textView.string != text {
textView.string = text
}
}
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
class Coordinator: NSObject, NSTextViewDelegate {
var parent: BetterTextView
init(_ parent: BetterTextView) {
self.parent = parent
}
func textDidChange(_ notification: Notification) {
if let textView = notification.object as? NSTextView {
parent.text = textView.string
}
}
}
}
ContentView.swift
import SwiftUI
struct ContentView: View {
@State var text = ""
@State var _textView: NSTextView?
var body: some View {
ZStack(alignment: .bottomTrailing) {
BetterTextView(text: $text) { nsTextView in
DispatchQueue.main.async {
self._textView = nsTextView
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
}
Inspecting AppKit.framework with Hopper
Let’s look for sus symbols in AppKit:
- Open Hopper
- Go to
File -> Read from DYLD Cache…
- Open
AppKit.framework
- Do a search for “glow”
Immediately you should see a few methods that might be relevant. What caught my eye was -[NSTextInsertionIndicator setShowsGlow:]
. The Apple docs revealed that NSTextInsertionIndicator
is indeed what NSTextView
and NSTextField
use to display the text cursor. Searching on Hopper also reveals that you can get a NSTextView
’s indicator with -[NSTextView _insertionIndicator]
. Let’s try to make our insertion indicator glow:
struct ContentView: View {
@State var text = ""
@State var _textView: NSTextView?
var body: some View {
ZStack(alignment: .bottomTrailing) {
BetterTextView(text: $text) { nsTextView in
DispatchQueue.main.async {
self._textView = nsTextView
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
Button("Enable Glow") {
enableGlow()
}
.padding()
}
}
func enableGlow() {
guard let textView = _textView else { return }
let indicator = textView.perform(Selector(("_insertionIndicator"))).takeUnretainedValue()
_ = indicator.perform(Selector(("setShowsGlow:")), with: true)
}
}
Build and launch the app and click on Enable Glow and you’ll realise it doesn’t work at all! What’s going on? I suspect it’s one of the following:
- We passed the wrong argument to
-[NSTextInsertionIndicator setShowsGlow:]
- It is never called when Dictation is activated.
- Calling it is a necessary but not a sufficient condition.
Rather than spending more time in Hopper reading incomprehensible ARM assembly and speculating what the issue is, let’s inspect the runtime behaviour using the LLDB debugger. Add a symbolic breakpoint for the method:
Then, activate Dictation with our NSTextView
focused. Our breakpoint is triggered! We can cross off (2) knowing it is definitely called.
Let’s inspect our registers with a few print commands:
(lldb) po $x0
<NSTextInsertionIndicator: 0x12f762210>
(lldb) po (SEL) $x1
"setShowsGlow:"
(lldb) po $x2
1
As you can see Dictation calls the method and passes 1
as argument, which is what true
in Swift will also do (you can verify this). We can cross off (1). If you continue execution, the debugger will be paused again because setShowsGlow:
is called with <nil>
when Dictation is deactivated.
This leaves us with the fact that maybe we need to do more than calling setShowsGlow:
. Run bt
to inspect the backtrace:
(lldb) bt
* thread #1, queue = 'com.apple.main-thread', stop reason = breakpoint 1.1
* frame #0: 0x000000019ef345ec AppKit`-[NSTextInsertionIndicator setShowsGlow:]
frame #1: 0x000000019ef32f70 AppKit`-[NSTextInsertionIndicator updateSubstate] + 580
frame #2: 0x000000019ef328d4 AppKit`-[NSTextInsertionIndicator dictationStateDidChange:] + 308
frame #3: 0x000000019aa31370 CoreFoundation`__CFNOTIFICATIONCENTER_IS_CALLING_OUT_TO_AN_OBSERVER__ + 148
frame #4: 0x000000019aac220c CoreFoundation`___CFXRegistrationPost_block_invoke + 88
frame #5: 0x000000019aac2154 CoreFoundation`_CFXRegistrationPost + 436
frame #6: 0x000000019a9fffac CoreFoundation`_CFXNotificationPost + 732
frame #7: 0x000000019bbba6b8 Foundation`-[NSNotificationCenter postNotificationName:object:userInfo:] + 88
frame #8: 0x000000019f3b7724 AppKit`-[NSDictationManager updateDictationState:] + 260
frame #9: 0x00000001a60f8c30 HIToolbox`__68-[IMKInputSession_Modern imkxpc_setApplicationProperty:value:reply:]_block_invoke + 124
frame #10: 0x00000001a60d7d10 HIToolbox`__57+[IMKInputSession_Modern IMKXPCPerformBlockOnMainThread:]_block_invoke + 36
frame #11: 0x00000001a13d8460 HIServices`invocation function for block in wrapBlockWithVoucher(void () block_pointer) + 56
frame #12: 0x00000001a13d7fe8 HIServices`_ZL24deferredBlockOpportunity_block_invoke_2 + 496
frame #13: 0x000000019aa3c430 CoreFoundation`__CFRUNLOOP_IS_CALLING_OUT_TO_A_BLOCK__ + 28
frame #14: 0x000000019aa3c340 CoreFoundation`__CFRunLoopDoBlocks + 356
frame #15: 0x000000019aa3b770 CoreFoundation`__CFRunLoopRun + 2432
frame #16: 0x000000019aa3a734 CoreFoundation`CFRunLoopRunSpecific + 588
frame #17: 0x00000001a5fa9530 HIToolbox`RunCurrentEventLoopInMode + 292
frame #18: 0x00000001a5faf348 HIToolbox`ReceiveNextEventCommon + 676
frame #19: 0x00000001a5faf508 HIToolbox`_BlockUntilNextEventMatchingListInModeWithFilter + 76
frame #20: 0x000000019e5b2848 AppKit`_DPSNextEvent + 660
frame #21: 0x000000019ef18c24 AppKit`-[NSApplication(NSEventRouting) _nextEventMatchingEventMask:untilDate:inMode:dequeue:] + 688
frame #22: 0x000000019e5a5874 AppKit`-[NSApplication run] + 480
frame #23: 0x000000019e57c068 AppKit`NSApplicationMain + 888
frame #24: 0x00000001c96da70c SwiftUI`merged generic specialization <SwiftUI.TestingAppDelegate> of function signature specialization <Arg[0] = Existential To Protocol Constrained Generic> of SwiftUI.runApp(__C.NSResponder & __C.NSApplicationDelegate) -> Swift.Never + 160
frame #25: 0x00000001c9b509a0 SwiftUI`SwiftUI.runApp<τ_0_0 where τ_0_0: SwiftUI.App>(τ_0_0) -> Swift.Never + 140
frame #26: 0x00000001c9ecce68 SwiftUI`static SwiftUI.App.main() -> () + 224
frame #27: 0x0000000104a5c4bc GlowingCursor.debug.dylib`static GlowingCursorApp.$main() at <compiler-generated>:0
frame #28: 0x0000000104a5c56c GlowingCursor.debug.dylib`main at GlowingCursorApp.swift:4:8
frame #29: 0x000000019a5d4274 dyld`start + 2840
Looking at the assembly for -[NSTextInsertionIndicator updateSubstate]:
reveals that after setShowsGlow:
is called with 1
, there also calls to methods like setCurrentAnimation:
, setOpacity:
, addTrailingGlow
, and animateIn
. I think these are the actual methods that are responsible for the glow effect, and setShowsGlow:
does very little more than setting a boolean flag. However, -[NSTextInsertionIndicator updateSubstate]:
does not have any parameters. This leaves us to either:
- Manually call the subroutines that
updateSubstate
calls to set the effect - Call the parent method
-[NSTextInsertionIndicator dictationStateDidChange:]
While (1) is going to be more powerful and gives us control over how exactly we want the glow effect to look, it is also going to take more time compared to (2), which seems like a very simple API.
Let’s set a breakpoint for -[NSTextInsertionIndicator dictationStateDidChange:]
and run po $x2
, you should see NSConcreteNotification 0x600003421980 {name = _NSTextInputContextDictationDidStartNotification}
and NSConcreteNotification 0x60000345d400 {name = _NSTextInputContextDictationDidEndNotification}
being passed when Dictation is activated and deactivated respectively.
A few LLDB commands reveal that NSConcreteNotification
belongs to Foundation.framework
:
(lldb) po $x2
NSConcreteNotification 0x60000345d400 {name = _NSTextInputContextDictationDidEndNotification}
(lldb) expr -- (const char *)object_getClassName((id)0x60000345d400)
(const char *) $0 = 0x000000019c9813dc "NSConcreteNotification"
(lldb) expr -- (void *)object_getClass((id)0x60000345d400)
(void *) $1 = 0x00000002042e25a0
(lldb) image lookup -a 0x00000002042e25a0
Address: Foundation[0x00000001e9dda5a0] (Foundation.__DATA_DIRTY.__objc_data + 4480)
Summary: (void *)0x00000002042e25c8: NSConcreteNotification
Exporting Foundation.framework
in Hopper (File -> Export Objective-C Header File…
) reveals NSConcreteNotification
is a subclass of NSNotification
:
@interface NSConcreteNotification : NSNotification {
NSString * name;
id object;
NSDictionary * userInfo;
}
- (void)dealloc;
- (id)name;
- (id)object;
- (id)userInfo;
- (id)initWithName:(id)v1 object:(id)v2 userInfo:(id)v3;
@end
This looks awfully just like the interface of NSNotification
in Swift, so lets try that:
struct ContentView: View {
@State var text = ""
@State var isGlowEnabled = false
@State var _textView: NSTextView?
var body: some View {
ZStack(alignment: .bottomTrailing) {
BetterTextView(text: $text) { nsTextView in
DispatchQueue.main.async {
self._textView = nsTextView
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
Button(isGlowEnabled ? "Disable Glow" : "Enable Glow") {
if isGlowEnabled {
disableGlow()
} else {
enableGlow()
}
}
.padding()
}
}
func enableGlow() {
guard let textView = _textView else { return }
isGlowEnabled = true
let indicator = textView.perform(Selector(("_insertionIndicator"))).takeUnretainedValue()
let notification = NSNotification(name: .init("_NSTextInputContextDictationDidStartNotification"), object: nil)
_ = indicator.perform(Selector(("dictationStateDidChange:")), with: notification)
}
func disableGlow() {
guard let textView = _textView else { return }
isGlowEnabled = false
let indicator = textView.perform(Selector(("_insertionIndicator"))).takeUnretainedValue()
let notification = NSNotification(name: .init("_NSTextInputContextDictationDidEndNotification"), object: nil)
_ = indicator.perform(Selector(("dictationStateDidChange:")), with: notification)
}
}
It works!
Conclusion
I hope you learned a thing or two about reverse engineeering private APIs! You can find the code on GitHub.