Add USB mouse/trackpad auto-detection, cleanup
This commit is contained in:
parent
4403fb8eca
commit
e7da1fac30
|
@ -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)";
|
||||
|
|
|
@ -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,31 +108,29 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension AppDelegate: NSMenuDelegate {
|
||||
|
@ -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
|
||||
// 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 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)' 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
|
||||
}
|
||||
|
|
|
@ -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)!
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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…
Reference in New Issue