From 99322530f4e5ce2480066f668e2e7679a329f072 Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Sun, 19 Sep 2021 14:01:19 -0400 Subject: [PATCH] Add USB mouse/trackpad auto-detection, cleanup --- ScrollSwitcher.xcodeproj/project.pbxproj | 10 + ScrollSwitcher/AppDelegate.swift | 191 ++++++++++++++---- ScrollSwitcher/Direction.swift | 33 +++ ScrollSwitcher/Preferences.swift | 52 +++++ .../ScrollSwitcher-Bridging-Header.h | 4 +- 5 files changed, 244 insertions(+), 46 deletions(-) create mode 100644 ScrollSwitcher/Direction.swift create mode 100644 ScrollSwitcher/Preferences.swift diff --git a/ScrollSwitcher.xcodeproj/project.pbxproj b/ScrollSwitcher.xcodeproj/project.pbxproj index 625d87c..9ee73fd 100644 --- a/ScrollSwitcher.xcodeproj/project.pbxproj +++ b/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 = ""; }; D62D7D9126DF237A001DCC5F /* PreferencePanesSupport.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = PreferencePanesSupport.framework; path = ../../../../System/Library/PrivateFrameworks/PreferencePanesSupport.framework; sourceTree = ""; }; D6955CDB26DF287800EB0723 /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = ""; }; + D6AB3C8926EE52010019A4F7 /* Preferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Preferences.swift; sourceTree = ""; }; + D6AB3C8B26EE56540019A4F7 /* Direction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Direction.swift; sourceTree = ""; }; /* 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)"; diff --git a/ScrollSwitcher/AppDelegate.swift b/ScrollSwitcher/AppDelegate.swift index f76955b..5983e63 100644 --- a/ScrollSwitcher/AppDelegate.swift +++ b/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() + + // internal not private because they need to be accessible from global IOHIDManager callbacks + var hidDevicesChangedSubject = PassthroughSubject() + 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 - - 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)! - } - } -} - 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)" + + 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 + } + 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() +} + +// 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 +} diff --git a/ScrollSwitcher/Direction.swift b/ScrollSwitcher/Direction.swift new file mode 100644 index 0000000..e3120b9 --- /dev/null +++ b/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)! + } + } +} + diff --git a/ScrollSwitcher/Preferences.swift b/ScrollSwitcher/Preferences.swift new file mode 100644 index 0000000..132a0d6 --- /dev/null +++ b/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." + } + } + } +} diff --git a/ScrollSwitcher/ScrollSwitcher-Bridging-Header.h b/ScrollSwitcher/ScrollSwitcher-Bridging-Header.h index 1cf2f4b..26101ac 100644 --- a/ScrollSwitcher/ScrollSwitcher-Bridging-Header.h +++ b/ScrollSwitcher/ScrollSwitcher-Bridging-Header.h @@ -8,8 +8,8 @@ #ifndef ScrollSwitcher_Bridging_Header_h #define ScrollSwitcher_Bridging_Header_h -#import +#import -void setSwipeScrollDirection(BOOL natural); +extern void setSwipeScrollDirection(bool natural); #endif /* ScrollSwitcher_Bridging_Header_h */