``` title = "A Mac Menu Bar App to Toggle Natural Scrolling" tags = ["swift"] date = "2021-09-02 22:31:42 -0400" 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 pane[^1], 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. [^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. 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](https://developer.apple.com/documentation/foundation/nsglobaldomain), it should be—and indeed is—accessible to us directly through `UserDefaults`. ```swift 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: ```swift 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: ```txt [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](https://www.hopperapp.com/), 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`](https://developer.apple.com/documentation/foundation/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. ```swift extension Notification.Name { static let swipeScrollDirectionDidChangeNotification = Notification.Name(rawValue: "SwipeScrollDirectionDidChangeNotification") } ``` ```swift 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.