18 KiB
metadata.title = "A Mac Menu Bar App to Toggle Natural Scrolling"
metadata.tags = ["swift"]
metadata.date = "2021-09-02 22:31:42 -0400"
metadata.shortDesc = ""
metadata.slug = "scrollswitcher"
There are two ways you can configure the scroll direction on a computer: normal or inverted (what Apple calls "natural"). If you, like most, use a mouse with a wheel, the normal scrolling scheme is probably what you use. Under it, moving the mouse wheel down (i.e., so that a point on the top of the wheel moves down/closer to you) causes the content to move up and the viewport (the window into the content) to move down. Similarly on a multitouch trackpad, under normal scrolling, as your two fingers move down, the content moves up and the viewport down.
The other option—natural scrolling—flips this, so as your fingers move down on the trackpad, the content moves down and viewport moves up, and similarly for rotating the mouse wheel. When using a mouse, this feels to most people obviously backwards. But this setting doesn't exist without reason. It's transposing the paradigm of touchscreens on to the trackpad, where your finger remains pinned to the point in the content where you first touched. You move the content directly, rather than moving the viewport.
This generally isn't a big deal; most people just find the mode they prefer, change the setting, and then never touch it again. But what if you prefer both?
Why might you want both options for this preference? Well, I use my laptop both docked and undocked. When it's at my desk and connected to a monitor, I also use a keyboard and mouse, for which I want normal scrolling. But when it's undocked and I'm using the trackpad, I vastly prefer natural scrolling.
Unfortunately, macOS only has one setting shared between both mice and trackpads. Yes, despite appearing in two different places in System Preferences (under both Trackpad and Mouse), with the implication that they're two different settings, both checkboxes are backed by the same underlying value. So, we need some workaround.
Since the preference can't be different for mouse and trackpad, I at least want some way of toggling it quickly, so that when I dock/undock my laptop, I can correct it quickly. A menu bar app seemed like the perfect fit, as I don't want something cluttering up my dock and there's almost no UI (but I do want more than just a global keyboard shortcut).
A bit of cursory googling reveals that the actual preference is stored in a global user default, com.apple.swipescrolldirection
. It's a boolean value, and true means natural scrolling is enabled. Reading it is easy, but unfortunately setting it is a bit more complicated. Just using defaults write -g com.apple.swipescrolldirection -bool YES
on the command line does not change the actual value—although when you open the Mouse preferences pane1, you can see the checkbox state has indeed opened. Places on the internet mention this and say you need to log out and back in to correct the input behavior. But, that isn't necessary when you change in System Preferences, so clearly something more is going on.
To try and figure out what else System Preferences was doing, I launched Console.app and started streaming log messages. I toggled the scroll direction checkbox a bunch of times in both the Mouse and Trackpad pref panes before stopping log streaming. Searching the logs for "mouse" and "trackpad" revealed nothing useful, but searching for "scroll" turned up one beautiful nugget of information:
SwipeScrollDirectionDidChangeNotification
. Sure sounds like the name of a notification that would be fired to inform other parts of the OS about the change.
With at least an inkling of the direction I needed to go in, I started building the app with the initial goal of displaying the current scrolling mode. I created a new Mac app in Xcode and set the LSUIElement
key in the Info.plist
to YES
, hiding the app from the dock. I also removed the Xcode-created storyboard and then created a system status bar item with a dummy icon.
Fun Problem #1: Storyboards, or the lack thereof
Here is where I encountered the first (small) problem: the menu item never appeared. My code that created the menu item in applicationDidFinishLaunching
was being hit, as evidenced by a breakpoint, and the menu item was being created, but it just didn't show up. I recalled noticing that the docs of NSStatusBar.statusItem(withLength:)
mentioned that the NSStatusItem
had to be retained by the caller, otherwise it would be removed when deallocated. But that shouldn't have been the problem, as I was storing it in a property on my app delegate. Unless...
It turns out using @main
on your app delegate does not strongly retain it unless there's a storyboard, which I had deleted. To fix it, I had to replace the @main
with a custom main.swift
which creates the NSApplication
instance and strongly retains the delegate.
Reading the Scroll Direction
With the menu item now displaying, it was time to read the current scroll direction. The most direct mapping from what I did on the command line would be to use Foundation's Process
to actually run the defaults
command and then examine the output. But, because the value we're after is stored in the global domain, it should be—and indeed is—accessible to us directly through UserDefaults
.
let defaultsKey = "com.apple.swipescrolldirection"
enum Direction: Int, Equatable {
case normal, natural
static var current: Direction {
let naturalEnabled = UserDefaults.standard.bool(forKey: defaultssKey)
return naturalEnabled ? .natural : .normal
}
}
Then, using the current direction, I could set the menu bar item's icon. SF Symbols' scroll.filled
for natural scrolling and scroll
for normal.
Setting the Scroll Direction
Setting the scroll direction via the command line isn't too bad. I just construct a Process
, configure it to run defaults
with the right arguments, and then launch it:
private func setDirection(_ new: Direction) {
let proc = Process()
proc.launchPath = "/usr/bin/defaults"
let newVal = new == .normal ? "NO" : "YES"
proc.arguments = ["write", "-g", "com.apple.swipescrolldirection", "-bool", newVal]
proc.launch()
proc.waitUntilExit()
if proc.terminationStatus != 0 {
fatalError("uh oh, exit code: \(proc.terminationStatus)")
}
}
With that wired up to run when the menu item was clicked, I tried toggling the scroll direction. Unfortunately, it failed, because defaults
didn't run successfully. But, it had at least printed an error message:
[User Defaults] Couldn't write values for keys (
"com.apple.swipescrolldirection"
) in CFPrefsPlistSource<0x6000017a1200> (Domain: kCFPreferencesAnyApplication, User: kCFPreferencesCurrentUser, ByHost: No, Container: (null), Contents Need Refresh: Yes): setting preferences outside an application's container requires user-preference-write or file-write-data sandbox access
Everyone's favorite sandbox, snatching defeat from the jaws of victory.
Fun Problem #2: Sandboxing
Unfortunately, both of the mentioned entitlements seem to be private (the only mentions of them I can find on the internet are from people running into some Catalyst error). So, I needed to disable sandboxing for this app altogether.
Disabling sandboxing turned out to be annoyingly confusing. Most of the documentation you can find on the internet is outdated and says you can simple flip the "App Sandbox" switch in the Capabilities section of the Xcode project. Slight problem: as of Xcode 13 (if not earlier), that switch no longer exists. And flat out removing the App Sandbox entry from Signing & Capabilities did not disable it, the same error occurred. Setting App Sandbox to NO
in the entitlements file was similarly ineffective.
After banging my head against this for a while (it seems like this has not been discussed on the internet recently enough to be of any help), I looked in target's Build Settings. Where I found the "Enable App Sandbox" flag—and it was set to Yes. Setting it to No finally fixed the issue, and the default was actually getting set.
The updated value could successfully be read from the app itself, as well as from outside. And that left me where I got stuck before on the command line: the preference was being updated, but nothing else on the system was aware of the change.
Into the Caves
I knew the name of the notification I needed to fire, but not what to do with it—the normal NotificationCenter
only works in-process, doesn't it? I decided the best course of action was to go spelunking through the System Preferences binary to try and figure out what it was doing. But not actually the System Preferences binary: there's a separate binary for each preference pane. A little bit of poking around the filesystem led me to the /System/Library/PreferencePanes/
directory where all the builtin ones live. Mouse.prefPane
looked exactly like what I wanted. Opening it in Hopper, I could search the strings for the notification name. Following the references to the string back through the CFString led me to the -[MouseController awakeFromNib]
method.
Looking at the disassembly, we can see exactly what it's doing:
It's adding an observer to NSDistributedNotificationCenter
's defaultCenter
for the SwipeScrollDirectionDidChangeNotification
—the notification name I saw earlier in the Console. The other place it's referenced from ([MTMouseScrollGesture initWithDictionary:andReadPreferences:]
) is doing the same thing: adding an observer. So, it looks like this notification isn't what triggers the actual change to the actual scroll input handling deep in the guts of the system. If that were the case, I'd expect to see the preference pane send the notification, not just receive it.
But, it still may be useful. Looking at the implementation of the _swipeScrollDirectionDidChangeNotification:
method it's setting as the callback for the action, we can see that it's probably updating the checkbox value. That setState:
call sure seems like it's on the NSButton
used for the natural scrolling checkbox.
NSDistributedNotificationCenter
is described as being like the regular notification center but for inter-process communication, which sounds like what we want. It has pretty much the same API as the regular one, so we can just send that notification when we change the scroll mode.
extension Notification.Name {
static let swipeScrollDirectionDidChangeNotification = Notification.Name(rawValue: "SwipeScrollDirectionDidChangeNotification")
}
private func setDirection(_ new: Direction) {
let proc = Process()
// omitted
DistributedNotificationCenter.default().postNotificationName(.swipeScrollDirectionDidChangeNotification, object: nil, userInfo: nil, deliverImmediately: false)
}
With that in place, clicking the menu bar item both sets the default and causes the System Preferences UI to update to match. Going the other way is similarly easy, since, although I couldn't find it in Mouse.prefPane
, something is emitting that notification when the value changes. I just call addObserver
and register myself for the notification and update the icon when it's received.
Back Into the Caves
That's all well and good, but clicking the menu item still doesn't actually change what happens when you move two fingers on the trackpad. It clearly works when clicking the checkbox in System Preferences, so there must be something else it's doing that we're not. Internally, this feature seems to be consistently referred to as the "swipe scroll direction" (even though it affects non-swipe scrolling), so, back in Hopper, we can search for procedures named like that. There's one that immediately looks promising setSwipeScrollDirection
, that just delegates to an externally implemented _setSwipeScrollDirection
.
Looking at the references to the function, I saw it was called by the -[MouseController scrollingBehavior:]
setter. That seems like the function that I wanted, but since it was implemented elsewhere, I had no idea what parameters it took. So, where's it implemented?
I used otool -L
to print all the frameworks the prefpane was linked against, and then started guessing.
$ otool -L /System/Library/PreferencePanes/Mouse.prefPane/Contents/MacOS/Mouse
/System/Library/PreferencePanes/Mouse.prefPane/Contents/MacOS/Mouse:
/System/Library/Frameworks/PreferencePanes.framework/Versions/A/PreferencePanes (compatibility version 1.0.0, current version 1.0.0)
# rest omitted
Actually getting the framework binaries is a bit tricky, since, starting with macOS Big Sur, the binaries are only present in the dyld
shared cache. The process is a little bit annoying, but not terribly complicated. This article by Jeff Johnson explains how to build dyld_shared_cache_util
, which you can use to transform the shared cache back into a directory with all the framework binaries.
$ dyld_shared_cache_util -extract ~/Desktop/Libraries/ /System/Library/dyld/dyld_shared_cache_x86_64
It took a couple guesses, but I found that the _setSwipeScrollingDirection
function is defined in PreferencePanesSupport.framework
.
Hopper thinks it takes an int, but we can clearly see the parameter's being used as a bool. rcx
is initialized to kCFBooleanFalse
and set to kCFBooleanTrue
if the parameter is true, and that's the value being passed to CFPreferencesSetValue
. Perfect.
Now—finally—that should be everything we need.
Back in the Xcode project, I added a bridging header that defines the externally implemented function and lets me call it from Swift.
#import <stdbool.h>
extern void setSwipeScrollDirection(bool direction);
Then, from Swift, we can simply call the function.
private func setDirection(_ new: Direction) {
let proc = Process()
// omitted
DistributedNotificationCenter.default().postNotificationName(.swipeScrollDirectionDidChangeNotification, object: nil, userInfo: nil, deliverImmediately: false)
setSwipeScrollDirection(new == .natural)
}
Lastly, in order to make the extern function actually go to the right place, the app needs to be linked against /System/Library/PrivateFrameworks/PreferencePanesSupport.framework
. And with that, clicking the menu item toggles the preference and immediately updates the user input behavior.
I can't really take a screen recording of that, so you'll have to take my word that it works.
If you're interested in the complete code, it can be found here. It's not currently packaged for distribution, but you can build and run it yourself. Because it needs the sandbox disabled, it won't ever been in the App Store, but at some point I might slap an app icon on it and published a notarized, built version. So, if anyone's interested, let me know.
As it currently exists, the app—which I'm calling ScrollSwitcher—covers 90% of my needs. I don't generally dock/undock more than a one or twice a day, so just being able to click a menu bar item is plenty fast. That said, I may still extend it for "fun". One obvious improvement would be automatically changing the state when an external mouse is connected/disconnected. That shouldn't be too hard, right? Right?
-
Or, if the Mouse prefs pane was already opened in the current session, relaunch the System Preferences app. Preference panes are not reloaded when you close and re-enter them, so manually writing the default causes the UI to desync. ↩︎