shadowfacts.net/site/posts/2021-09-02-scrollswitcher.md

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:

Console.app showing a message from disnoted reading register name: SwipeScrollDirectionDidChangeNotification

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.

filled and outlined scroll icons in the menubar
Scrolls, get it?

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:

Hopper showing the disassembly for -[MouseController awakeFromNib]

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.

Hopper showing the disassembly for -[MouseController _swipeScrollDirectionDidChangeNotification:]

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.

<%- video(metadata, "sync", {style: "max-width: 100%; margin: 0 auto; display: block;", title: "Screen recording of menu bar item changing highlighted state in sync with the natural scrolling checkbox in System Preferences."}) %>

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.

Hopper showing the assembly for 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 showing the disassembly for _setSwipeScrollDirection in PreferencePanesSupport

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?


  1. 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. ↩︎