Browse Source

Add USB mouse/trackpad auto-detection, cleanup

main
Shadowfacts 3 months ago
parent
commit
99322530f4
Signed by: shadowfacts GPG Key ID: 94A5AB95422746E5
  1. 10
      ScrollSwitcher.xcodeproj/project.pbxproj
  2. 183
      ScrollSwitcher/AppDelegate.swift
  3. 33
      ScrollSwitcher/Direction.swift
  4. 52
      ScrollSwitcher/Preferences.swift
  5. 4
      ScrollSwitcher/ScrollSwitcher-Bridging-Header.h

10
ScrollSwitcher.xcodeproj/project.pbxproj

@ -11,6 +11,8 @@
D62D7D8326DE7A9F001DCC5F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = D62D7D8226DE7A9F001DCC5F /* Assets.xcassets */; };
D62D7D9226DF237A001DCC5F /* PreferencePanesSupport.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D62D7D9126DF237A001DCC5F /* PreferencePanesSupport.framework */; };
D6955CDC26DF287800EB0723 /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6955CDB26DF287800EB0723 /* main.swift */; };
D6AB3C8A26EE52020019A4F7 /* Preferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6AB3C8926EE52010019A4F7 /* Preferences.swift */; };
D6AB3C8C26EE56540019A4F7 /* Direction.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6AB3C8B26EE56540019A4F7 /* Direction.swift */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
@ -21,6 +23,8 @@
D62D7D8F26DF228D001DCC5F /* ScrollSwitcher-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "ScrollSwitcher-Bridging-Header.h"; sourceTree = "<group>"; };
D62D7D9126DF237A001DCC5F /* PreferencePanesSupport.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = PreferencePanesSupport.framework; path = ../../../../System/Library/PrivateFrameworks/PreferencePanesSupport.framework; sourceTree = "<group>"; };
D6955CDB26DF287800EB0723 /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = "<group>"; };
D6AB3C8926EE52010019A4F7 /* Preferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Preferences.swift; sourceTree = "<group>"; };
D6AB3C8B26EE56540019A4F7 /* Direction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Direction.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@ -58,6 +62,8 @@
D62D7D8C26DE7B36001DCC5F /* Info.plist */,
D6955CDB26DF287800EB0723 /* main.swift */,
D62D7D7E26DE7A9D001DCC5F /* AppDelegate.swift */,
D6AB3C8B26EE56540019A4F7 /* Direction.swift */,
D6AB3C8926EE52010019A4F7 /* Preferences.swift */,
D62D7D8F26DF228D001DCC5F /* ScrollSwitcher-Bridging-Header.h */,
D62D7D8226DE7A9F001DCC5F /* Assets.xcassets */,
);
@ -143,6 +149,8 @@
files = (
D6955CDC26DF287800EB0723 /* main.swift in Sources */,
D62D7D7F26DE7A9D001DCC5F /* AppDelegate.swift in Sources */,
D6AB3C8A26EE52020019A4F7 /* Preferences.swift in Sources */,
D6AB3C8C26EE56540019A4F7 /* Direction.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@ -283,6 +291,7 @@
"$(inherited)",
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 11.3;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = net.shadowfacts.ScrollSwitcher;
PRODUCT_NAME = "$(TARGET_NAME)";
@ -315,6 +324,7 @@
"$(inherited)",
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 11.3;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = net.shadowfacts.ScrollSwitcher;
PRODUCT_NAME = "$(TARGET_NAME)";

183
ScrollSwitcher/AppDelegate.swift

@ -6,14 +6,24 @@
//
import Cocoa
import IOKit
import Combine
import OSLog
let defaultsKey = "com.apple.swipescrolldirection"
let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "main")
class AppDelegate: NSObject, NSApplicationDelegate {
// things that need to be retained to keep them from disappearing
private var prefPanesSupport: Bundle!
private var item: NSStatusItem!
private var manager: IOHIDManager!
private var cancellables = Set<AnyCancellable>()
// internal not private because they need to be accessible from global IOHIDManager callbacks
var hidDevicesChangedSubject = PassthroughSubject<Void, Never>()
var trackpadCount = 0
var mouseCount = 0
func applicationDidFinishLaunching(_ aNotification: Notification) {
item = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength)
@ -23,6 +33,28 @@ class AppDelegate: NSObject, NSApplicationDelegate {
// update the icon when system prefs changes
DistributedNotificationCenter.default().addObserver(self, selector: #selector(updateIcon), name: .swipeScrollDirectionDidChangeNotification, object: nil)
// register for HID device addition/removal notifications
manager = IOHIDManagerCreate(kCFAllocatorDefault, 0 /* kIOHIDManagerOptionNone */)
var dict = IOServiceMatching(kIOHIDDeviceKey)! as! [String: Any]
dict[kIOHIDDeviceUsagePageKey] = kHIDPage_GenericDesktop
dict[kIOHIDDeviceUsageKey] = kHIDUsage_GD_Mouse
IOHIDManagerSetDeviceMatching(manager, dict as CFDictionary)
IOHIDManagerRegisterDeviceMatchingCallback(manager, hidDeviceAdded(context:result:sender:device:), nil)
IOHIDManagerRegisterDeviceRemovalCallback(manager, hidDeviceRemoved(context:result:sender:device:), nil)
IOHIDManagerScheduleWithRunLoop(manager, CFRunLoopGetCurrent(), CFRunLoopMode.commonModes.rawValue)
// handle HID device changes debounced, because IOKit sends a whole at initialization (and seemingly duplicates when devices are removed/connected)
hidDevicesChangedSubject
.debounce(for: .seconds(0.1), scheduler: RunLoop.main)
.sink { [unowned self] (_) in
logger.info("HID devices changed, trackpads: \(self.trackpadCount, privacy: .public), mice: \(self.mouseCount, privacy: .public)")
self.updateDirectionForAutoMode()
}
.store(in: &cancellables)
}
@objc private func updateIcon() {
@ -43,11 +75,31 @@ class AppDelegate: NSObject, NSApplicationDelegate {
menu.delegate = self
let state = Direction.current == .natural ? "On" : "Off"
let status = NSMenuItem(title: "Natural Scrolling: \(state)", action: nil, keyEquivalent: "")
status.isEnabled = true
status.attributedTitle = NSAttributedString(string: "Natural Scrolling: \(state)", attributes: [
.foregroundColor: NSColor.white,
])
menu.addItem(status)
let verb = Direction.current == .natural ? "Disable" : "Enable"
let toggleItem = NSMenuItem(title: "\(verb) Natural Scrolling", action: #selector(toggleScrollDirection), keyEquivalent: "")
menu.addItem(toggleItem)
menu.addItem(NSMenuItem.separator())
let autoItem = NSMenuItem(title: "Auto Switching Mode", action: nil, keyEquivalent: "")
let autoMenu = NSMenu()
for mode in Preferences.AutoMode.allCases {
let modeItem = NSMenuItem(title: mode.displayName, action: #selector(modeChanged(_:)), keyEquivalent: "")
modeItem.tag = mode.rawValue
modeItem.toolTip = mode.displayDescription
modeItem.state = Preferences.autoMode == mode ? .on : .off
autoMenu.addItem(modeItem)
}
autoItem.submenu = autoMenu
menu.addItem(autoItem)
menu.addItem(NSMenuItem.separator())
menu.addItem(withTitle: "Quit", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q")
return menu
}
@ -56,30 +108,28 @@ class AppDelegate: NSObject, NSApplicationDelegate {
setDirection(.current == Direction.natural ? .normal : .natural)
}
@objc private func modeChanged(_ sender: NSMenuItem) {
Preferences.autoMode = Preferences.AutoMode(rawValue: sender.tag) ?? .disabled
updateDirectionForAutoMode()
}
private func setDirection(_ new: Direction) {
// can't construct a UserDefaults with NSGlobalDomain, so set it via the command line
let proc = Process()
// let out = Pipe()
// let err = Pipe()
proc.launchPath = "/usr/bin/env"
let newVal = new == .normal ? "NO" : "YES"
proc.arguments = ["defaults", "write", "-g", "com.apple.swipescrolldirection", "-bool", newVal]
// proc.standardOutput = out
// proc.standardError = err
proc.launch()
proc.waitUntilExit()
// let outData = out.fileHandleForReading.readDataToEndOfFile()
// let errData = err.fileHandleForReading.readDataToEndOfFile()
if proc.terminationStatus != 0 {
fatalError("uh oh, exit code: \(proc.terminationStatus)")
}
// makes system preferences update
DistributedNotificationCenter.default().postNotificationName(.swipeScrollDirectionDidChangeNotification, object: nil, userInfo: nil, deliverImmediately: false)
// make the actual input behavior update
logger.debug("Changing scroll direction to \(new == .normal ? "Normal" : "Natural", privacy: .public)")
setSwipeScrollDirection(new == .natural)
}
private func updateDirectionForAutoMode() {
switch Preferences.autoMode {
case .disabled:
return
case .normalWhenMousePresent:
setDirection(mouseCount > 0 ? .normal : .natural)
case .naturalWhenTrackpadPresent:
setDirection(trackpadCount > 0 ? .natural : .normal)
}
}
}
@ -90,28 +140,81 @@ extension AppDelegate: NSMenuDelegate {
}
}
enum Direction: Equatable {
case normal, natural
extension Notification.Name {
static let swipeScrollDirectionDidChangeNotification = Notification.Name(rawValue: "SwipeScrollDirectionDidChangeNotification")
}
func hidDeviceAdded(context: UnsafeMutableRawPointer?, result: IOReturn, sender: UnsafeMutableRawPointer?, device: IOHIDDevice) {
// it is not clear to me why you can interpolate device here but not in the log message
let deviceDesc = "\(device)"
let name = IOHIDDeviceGetProperty(device, kIOHIDProductKey as CFString) as? String
guard let name = name else {
logger.warning("Could not get product name for \(deviceDesc, privacy: .public)")
return
}
let usage = IOHIDDeviceGetProperty(device, kIOHIDPrimaryUsageKey as CFString) as? UInt32
guard let usage = usage else {
logger.warning("Could not get usage for \(deviceDesc, privacy: .public)")
return
}
// we get this callback for non kHIDUsage_GD_Mouse devices even though we specify that in the matching dict
// (specifically, something with kHIDUsage_GD_SystemControl), so filter ourselves
guard usage == kHIDUsage_GD_Mouse else {
logger.info("Unexpected usage 0x\(usage, format: .hex, privacy: .public) for device '\(name, privacy: .public)'")
return
}
logger.debug("HID device '\(name, privacy: .public)' added")
let delegate = NSApp.delegate as! AppDelegate
if deviceNameIsProbablyTrackpad(name) {
delegate.trackpadCount += 1
} else {
delegate.mouseCount += 1
}
delegate.hidDevicesChangedSubject.send()
}
func hidDeviceRemoved(context: UnsafeMutableRawPointer?, result: IOReturn, sender: UnsafeMutableRawPointer?, device: IOHIDDevice) {
let deviceDesc = "\(device)"
static var current: Direction {
let value = UserDefaults.standard.bool(forKey: defaultsKey)
if value {
return .natural
} else {
return .normal
}
let name = IOHIDDeviceGetProperty(device, kIOHIDProductKey as CFString) as? String
guard let name = name else {
logger.warning("Could not get product name for \(deviceDesc, privacy: .public)")
return
}
var image: NSImage {
switch self {
case .normal:
return NSImage(systemSymbolName: "scroll", accessibilityDescription: nil)!
case .natural:
return NSImage(systemSymbolName: "scroll.fill", accessibilityDescription: nil)!
}
let usage = IOHIDDeviceGetProperty(device, kIOHIDPrimaryUsageKey as CFString) as? UInt32
guard let usage = usage else {
logger.warning("Could not get usage for \(deviceDesc, privacy: .public)")
return
}
guard usage == kHIDUsage_GD_Mouse else {
logger.info("Unexpected usage 0x\(usage, format: .hex, privacy: .public) for device '\(name, privacy: .public)'")
return
}
logger.debug("HID device '\(name, privacy: .public)' removed")
let delegate = NSApp.delegate as! AppDelegate
if deviceNameIsProbablyTrackpad(name) {
delegate.trackpadCount -= 1
} else {
delegate.mouseCount -= 1
}
delegate.hidDevicesChangedSubject.send()
}
extension Notification.Name {
static let swipeScrollDirectionDidChangeNotification = Notification.Name(rawValue: "SwipeScrollDirectionDidChangeNotification")
// dumb heuristics because USB HID doesn't differentiate between mice/trackpads
func deviceNameIsProbablyTrackpad(_ name: String) -> Bool {
if name.lowercased().contains("trackpad") {
return true
}
return false
}

33
ScrollSwitcher/Direction.swift

@ -0,0 +1,33 @@
//
// Direction.swift
// ScrollSwitcher
//
// Created by Shadowfacts on 9/12/21.
//
import AppKit
let defaultsKey = "com.apple.swipescrolldirection"
enum Direction: Equatable {
case normal, natural
static var current: Direction {
let value = UserDefaults.standard.bool(forKey: defaultsKey)
if value {
return .natural
} else {
return .normal
}
}
var image: NSImage {
switch self {
case .normal:
return NSImage(systemSymbolName: "scroll", accessibilityDescription: nil)!
case .natural:
return NSImage(systemSymbolName: "scroll.fill", accessibilityDescription: nil)!
}
}
}

52
ScrollSwitcher/Preferences.swift

@ -0,0 +1,52 @@
//
// Preferences.swift
// ScrollSwitcher
//
// Created by Shadowfacts on 9/12/21.
//
import Foundation
struct Preferences {
private static let autoModeKey = "autoMode"
static var autoMode: AutoMode {
get {
AutoMode(rawValue: UserDefaults.standard.integer(forKey: autoModeKey)) ?? .disabled
}
set {
UserDefaults.standard.set(newValue.rawValue, forKey: autoModeKey)
}
}
}
extension Preferences {
enum AutoMode: Int, Codable, CaseIterable, Equatable {
case disabled = 0
case normalWhenMousePresent = 1
case naturalWhenTrackpadPresent = 2
var displayName: String {
switch self {
case .disabled:
return "Disabled"
case .normalWhenMousePresent:
return "Normal when mouse present"
case .naturalWhenTrackpadPresent:
return "Natural when trackpad present"
}
}
var displayDescription: String {
switch self {
case .disabled:
return "Scroll direction is never changed automatically"
case .normalWhenMousePresent:
return "Scroll direction is changed to normal when at least 1 mouse is connected. Optimal for computers with built-in trackpads."
case .naturalWhenTrackpadPresent:
return "Scroll direction is changed to natural when at least 1 trackpad is connected. Optimal for computers with rarely-connected trackpads."
}
}
}
}

4
ScrollSwitcher/ScrollSwitcher-Bridging-Header.h

@ -8,8 +8,8 @@
#ifndef ScrollSwitcher_Bridging_Header_h
#define ScrollSwitcher_Bridging_Header_h
#import <CoreFoundation/CoreFoundation.h>
#import <stdbool.h>
void setSwipeScrollDirection(BOOL natural);
extern void setSwipeScrollDirection(bool natural);
#endif /* ScrollSwitcher_Bridging_Header_h */

Loading…
Cancel
Save