Add ScrollSwitcher

This commit is contained in:
Shadowfacts 2021-09-02 22:47:04 -04:00
parent 9599725e04
commit c9f6998e89
Signed by: shadowfacts
GPG Key ID: 94A5AB95422746E5
9 changed files with 222 additions and 0 deletions

View File

@ -0,0 +1,219 @@
```
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?
<!-- excerpt-end -->
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.
<aside>
Yes, I recognize that I'm probably in the tiny minority of people who prefer this. Could I try myself to accept the wrong way of scrolling on either the trackpad or mouse and then just leave the setting alone? [Probably](https://www.youtube.com/watch?v=MFzDaBzBlL0). But I don't really want to.
</aside>
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:
<img src="<%= metadata.permalink %>/console.png" alt="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](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.
<figure>
<img src="<%= metadata.permalink %>/scrolls.png" alt="filled and outlined scroll icons in the menubar">
<figcaption>Scrolls, get it?</figcaption>
</figure>
## Setting the Scroll Direction
<aside class="inline">
I briefly toyed with using `UserDefaults` to also set the scroll direction directly, without having to shell out to the `defaults` command. The obvious thing to do would be to construct a `UserDefaults` for the global suite. But sadly, no luck:
```txt
Using NSGlobalDomain as an NSUserDefaults suite name does not make sense and will not work.
```
My next idea was calling `setPersistentDomain(_:forName:)` on the standard `UserDefaults` with the a dictionary containing the new scroll direction for the global domain. This was, not to overstate things, a colossal failure. The docs for this method say:
> Calling this method is equivalent to initializing a user defaults object with `init(suiteName:)` passing `domainName`, and calling the `set(_:forKey:)` method for each key-value pair in domain.
What actually happened was calling with `UserDefaults.globalDomain` reset a whole bunch of global defaults (though seemingly not all) across my entire computer. This included (but may not have been limited to): a number of General system prefs (including what first tipped me off that something went wrong, the accent color), and the Xcode 13 setting for showing file extensions.
Pro tip: do not call `setPersistentDomain` with `NSGlobalDomain`.
</aside>
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:
<div class="article-content-wide"><img src="<%= metadata.permalink %>/awakefromnib.png" alt="Hopper showing the disassembly for -[MouseController awakeFromNib]"></div>
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.
<div class="article-content-wide"><img src="<%= metadata.permalink %>/notificationhandler.png" alt="Hopper showing the disassembly for -[MouseController _swipeScrollDirectionDidChangeNotification:]"></div>
[`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.
<div>
<%- 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."}) %>
</div>
## 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`.
<div class="article-content-wide"><img src="<%= metadata.permalink %>/setswipescrolldirection.png" alt="Hopper showing the assembly for setSwipeScrollDirection"></div>
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.
```sh
$ 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](https://lapcatsoftware.com/articles/bigsur.html) 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.
```sh
$ 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`.
<div class="article-content-wide"><img src="<%= metadata.permalink %>/preferencepanessupport.png" alt="Hopper showing the disassembly for _setSwipeScrollDirection in PreferencePanesSupport"></div>
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.
```c
#import <stdbool.h>
extern void setSwipeScrollDirection(bool direction);
```
Then, from Swift, we can simply call the function.
```swift
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](https://git.shadowfacts.net/shadowfacts/ScrollSwitcher). 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?

Binary file not shown.

After

Width:  |  Height:  |  Size: 242 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 199 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 234 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 262 KiB

BIN
site/static/2021/scrollswitcher/sync.mp4 (Stored with Git LFS) Normal file

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB