-
Notifications
You must be signed in to change notification settings - Fork 0
Phase 9: trackpad volume gesture (shared F4) #9
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
tirthfx
wants to merge
1
commit into
phase-8-lockscreen-lyrics
Choose a base branch
from
phase-9-volume-gesture
base: phase-8-lockscreen-lyrics
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,13 +1,128 @@ | ||
| import Combine | ||
| import Foundation | ||
| import IOKit.hid | ||
|
|
||
| /// Custom trackpad gesture detection (ARCHITECTURE.md §3): | ||
| /// 1. Two-finger right-to-left swipe over the notch window → reveal lyrics | ||
| /// (standard in-window recognizer, no private API). | ||
| /// 2. System-wide two-finger vertical slide starting at the trackpad's | ||
| /// physical left edge → volume, via the private MultitouchSupport | ||
| /// framework (dlopen/dlsym). Requires Input Monitoring permission. | ||
| final class GestureEngine { | ||
| // TODO: Phase 4 — in-window two-finger swipe recognizer for lyrics. | ||
| // TODO: Phase 5 — MultitouchSupport left-edge slide with dead-zone / | ||
| // deliberate-start detection to avoid false triggers. | ||
| /// System-wide trackpad gesture → system volume (PRD §3.3 / F4, the shared | ||
| /// gesture). A two-finger vertical slide that STARTS at the trackpad's physical | ||
| /// left edge acts as a volume fader, regardless of cursor position or which | ||
| /// surface is visible. Raw trackpad contacts come from the private | ||
| /// MultitouchSupport framework; volume goes out through Core Audio. | ||
| /// | ||
| /// Not `@MainActor`: the MultitouchSupport callback fires on the framework's own | ||
| /// thread and mutates the gesture state below. `isEnabled` is only ever toggled | ||
| /// from the menu (main thread), and the callback never touches it. | ||
| final class GestureEngine: ObservableObject { | ||
| /// Off by default until the user opts in — it needs Input Monitoring and | ||
| /// claims a corner of normal trackpad space, so it shouldn't surprise. | ||
| @Published private(set) var isEnabled = false | ||
|
|
||
| private let volume = VolumeController() | ||
| private var multitouch: MultitouchSupport? | ||
|
|
||
| // MARK: Gesture tuning (normalized 0...1 trackpad coordinates) | ||
|
|
||
| /// Both fingers must start within this far from the left edge to arm. | ||
| private let edgeThreshold: Float = 0.16 | ||
| /// Vertical travel (fraction of trackpad height) that maps to full 0→100%. | ||
| private let travelForFullRange: Float = 0.7 | ||
| /// Ignore tiny wobble before engaging, so a resting touch doesn't drift. | ||
| private let deadZone: Float = 0.02 | ||
|
|
||
| // MARK: Gesture state (touched only from the multitouch callback thread) | ||
|
|
||
| private var engaged = false | ||
| private var armed = false | ||
| private var baselineY: Float = 0 | ||
| private var startVolume: Float = 0 | ||
| private var lastAppliedVolume: Float = -1 | ||
|
|
||
| static weak var shared: GestureEngine? | ||
|
|
||
| init() { | ||
| GestureEngine.shared = self | ||
| } | ||
|
|
||
| // MARK: Enable / disable | ||
|
|
||
| func setEnabled(_ enabled: Bool) { | ||
| enabled ? enable() : disable() | ||
| } | ||
|
|
||
| private func enable() { | ||
| guard !isEnabled else { return } | ||
| // Input Monitoring is required to receive raw trackpad frames; this | ||
| // prompts on first use and is a no-op once granted. | ||
| if IOHIDCheckAccess(kIOHIDRequestTypeListenEvent) != kIOHIDAccessTypeGranted { | ||
| _ = IOHIDRequestAccess(kIOHIDRequestTypeListenEvent) | ||
| } | ||
| let mt = MultitouchSupport() | ||
| guard let mt, mt.startListening(Self.frameCallback) else { | ||
| NSLog("NotchBeat: couldn't start MultitouchSupport; volume gesture unavailable") | ||
| multitouch = nil | ||
| return | ||
| } | ||
| multitouch = mt | ||
| isEnabled = true | ||
| } | ||
|
|
||
| private func disable() { | ||
| guard isEnabled else { return } | ||
| multitouch?.stopListening(Self.frameCallback) | ||
| multitouch = nil | ||
| engaged = false | ||
| armed = false | ||
|
Comment on lines
+72
to
+73
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| isEnabled = false | ||
| } | ||
|
|
||
| // MARK: Frame handling | ||
|
|
||
| /// C-compatible trampoline. Hops to the shared instance; the callback fires | ||
| /// on MultitouchSupport's own thread. | ||
| private static let frameCallback: MTContactCallback = { _, touchesRaw, count, _, _ in | ||
| guard let touchesRaw else { return } | ||
| let touches = touchesRaw.assumingMemoryBound(to: MTTouch.self) | ||
| GestureEngine.shared?.handleFrame(touches, Int(count)) | ||
| } | ||
|
|
||
| private func handleFrame(_ touches: UnsafePointer<MTTouch>, _ count: Int) { | ||
| // We only care about a clean two-finger contact. Anything else ends the | ||
| // gesture, so lifting or adding a finger cleanly disengages. | ||
| guard count == 2 else { | ||
| engaged = false | ||
| armed = false | ||
| return | ||
| } | ||
|
|
||
| let a = touches[0].normalized.position | ||
| let b = touches[1].normalized.position | ||
| let avgY = (a.y + b.y) / 2 | ||
|
|
||
| if !engaged { | ||
| engaged = true | ||
| // Arm only when BOTH fingers land in the left-edge strip — this is | ||
| // what separates a deliberate edge slide from ordinary two-finger | ||
| // scrolling, which starts anywhere on the surface. | ||
| armed = a.x < edgeThreshold && b.x < edgeThreshold | ||
| baselineY = avgY | ||
| startVolume = volume.currentVolume() ?? 0 | ||
| lastAppliedVolume = -1 | ||
| return | ||
| } | ||
|
|
||
| guard armed else { return } | ||
|
|
||
| let delta = avgY - baselineY | ||
| guard abs(delta) > deadZone else { return } | ||
|
|
||
| // Slide up (y increases) raises volume; one `travelForFullRange` of | ||
| // vertical travel sweeps the whole 0...1 range. | ||
| let target = startVolume + delta / travelForFullRange | ||
| let clamped = min(max(target, 0), 1) | ||
|
|
||
| // Throttle Core Audio writes: only when it moves ~1.5% (≈64 steps), | ||
| // finer than the 16 hardware increments but without spamming the device. | ||
| guard abs(clamped - lastAppliedVolume) >= 0.015 else { return } | ||
| lastAppliedVolume = clamped | ||
| volume.setVolume(clamped) | ||
| } | ||
|
Comment on lines
+87
to
+127
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This suggestion addresses two important issues in
private func handleFrame(_ touches: UnsafePointer<MTTouch>, _ count: Int) {
lock.withLock {
// We only care about a clean two-finger contact. Anything else ends the
// gesture, so lifting or adding a finger cleanly disengages.
guard count == 2 else {
engaged = false
armed = false
return
}
let a = touches[0].normalized.position
let b = touches[1].normalized.position
let avgY = (a.y + b.y) / 2
if !engaged {
engaged = true
// Arm only when BOTH fingers land in the left-edge strip — this is
// what separates a deliberate edge slide from ordinary two-finger
// scrolling, which starts anywhere on the surface.
armed = a.x < edgeThreshold && b.x < edgeThreshold
baselineY = avgY
startVolume = volume.currentVolume() ?? 0
lastAppliedVolume = -1
return
}
guard armed else { return }
let delta = avgY - baselineY
guard abs(delta) > deadZone else { return }
// Slide up (y increases) raises volume; one `travelForFullRange` of
// vertical travel sweeps the whole 0...1 range.
let target = startVolume + delta / travelForFullRange
let clamped = min(max(target, 0), 1)
// Throttle Core Audio writes: only when it moves ~1.5% (≈64 steps),
// finer than the 16 hardware increments but without spamming the device.
guard abs(clamped - lastAppliedVolume) >= 0.015 || clamped == 0 || clamped == 1 else { return }
lastAppliedVolume = clamped
volume.setVolume(clamped)
}
} |
||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,128 @@ | ||
| import CoreFoundation | ||
| import Foundation | ||
|
|
||
| // Bindings for the private MultitouchSupport.framework, loaded at runtime via | ||
| // dlopen/dlsym (it has no public header and can't be linked directly — see | ||
| // PRD §4 on why this app can't ship on the Mac App Store). | ||
| // | ||
| // The MTTouch memory layout below is the long-standing community-reverse- | ||
| // engineered one, as published in e.g. Erica Sadun's "MultitouchSupport" notes | ||
| // and the `mtdev` / `fingermgmt` projects | ||
| // (https://github.com/mrmekon/fingermgmt, https://github.com/calftrail/Touch). | ||
| // Field offsets through `normalized` are stable across macOS versions; that's | ||
| // all we read. The full 96-byte size matters only so the callback can stride | ||
| // correctly between multiple touches, so we assert it at load time. | ||
|
|
||
| struct MTPoint { | ||
| var x: Float = 0 | ||
| var y: Float = 0 | ||
| } | ||
|
|
||
| struct MTReadout { | ||
| var position = MTPoint() | ||
| var velocity = MTPoint() | ||
| } | ||
|
|
||
| /// One finger in a contact frame. `normalized.position` is 0...1 with the | ||
| /// origin at the trackpad's bottom-left (x grows rightward, y grows upward). | ||
| struct MTTouch { | ||
| var frame: Int32 = 0 | ||
| var timestamp: Double = 0 | ||
| var identifier: Int32 = 0 | ||
| var state: Int32 = 0 | ||
| var fingerID: Int32 = 0 | ||
| var handID: Int32 = 0 | ||
| var normalized = MTReadout() | ||
| var size: Float = 0 | ||
| var pressure: Int32 = 0 | ||
| var angle: Float = 0 | ||
| var majorAxis: Float = 0 | ||
| var minorAxis: Float = 0 | ||
| var absolute = MTReadout() | ||
| var unknown1: Int32 = 0 | ||
| var unknown2: Int32 = 0 | ||
| var density: Float = 0 | ||
| } | ||
|
|
||
| /// Frame callback: device, pointer to `numTouches` MTTouch structs, timestamp, | ||
| /// frame number. `device` is an opaque MTDeviceRef we ignore. The touches | ||
| /// pointer is typed raw here (a Swift-struct pointer isn't `@convention(c)`- | ||
| /// representable) and rebound to `MTTouch` at the call site — same ABI. | ||
| typealias MTContactCallback = @convention(c) ( | ||
| UnsafeRawPointer?, UnsafeRawPointer?, Int32, Double, Int32 | ||
| ) -> Void | ||
|
|
||
| /// Thin wrapper that loads the framework and its C entry points once. | ||
| final class MultitouchSupport { | ||
| typealias MTDeviceRef = UnsafeMutableRawPointer | ||
|
|
||
| private typealias CreateListFn = @convention(c) () -> Unmanaged<CFArray>? | ||
| private typealias RegisterFn = @convention(c) (MTDeviceRef, MTContactCallback) -> Void | ||
| private typealias UnregisterFn = @convention(c) (MTDeviceRef, MTContactCallback) -> Void | ||
| private typealias StartFn = @convention(c) (MTDeviceRef, Int32) -> Void | ||
| private typealias StopFn = @convention(c) (MTDeviceRef) -> Void | ||
|
|
||
| private let handle: UnsafeMutableRawPointer | ||
| private let createList: CreateListFn | ||
| private let register: RegisterFn | ||
| private let unregister: UnregisterFn | ||
| private let start: StartFn | ||
| private let stop: StopFn | ||
|
|
||
| private var devices: [MTDeviceRef] = [] | ||
|
|
||
| /// nil if the framework or any symbol is missing, or if the struct layout | ||
| /// doesn't match this OS (so we never stride through touches incorrectly). | ||
| init?() { | ||
| guard MemoryLayout<MTTouch>.stride == 96 else { | ||
| NSLog("NotchBeat: unexpected MTTouch stride \(MemoryLayout<MTTouch>.stride); disabling volume gesture") | ||
| return nil | ||
| } | ||
| let path = "/System/Library/PrivateFrameworks/MultitouchSupport.framework/MultitouchSupport" | ||
| guard let handle = dlopen(path, RTLD_LAZY) else { return nil } | ||
| func sym<T>(_ name: String, _ type: T.Type) -> T? { | ||
| guard let ptr = dlsym(handle, name) else { return nil } | ||
| return unsafeBitCast(ptr, to: T.self) | ||
| } | ||
| guard let createList = sym("MTDeviceCreateList", CreateListFn.self), | ||
| let register = sym("MTRegisterContactFrameCallback", RegisterFn.self), | ||
| let unregister = sym("MTUnregisterContactFrameCallback", UnregisterFn.self), | ||
| let start = sym("MTDeviceStart", StartFn.self), | ||
| let stop = sym("MTDeviceStop", StopFn.self) | ||
| else { | ||
| dlclose(handle) | ||
| return nil | ||
| } | ||
| self.handle = handle | ||
| self.createList = createList | ||
| self.register = register | ||
| self.unregister = unregister | ||
| self.start = start | ||
| self.stop = stop | ||
| } | ||
|
|
||
| /// Registers `callback` on every multitouch device and starts them. | ||
| /// Returns false if no devices were found. | ||
| @discardableResult | ||
| func startListening(_ callback: MTContactCallback) -> Bool { | ||
| guard let list = createList()?.takeRetainedValue() else { return false } | ||
| let count = CFArrayGetCount(list) | ||
| guard count > 0 else { return false } | ||
| for i in 0..<count { | ||
| guard let raw = CFArrayGetValueAtIndex(list, i) else { continue } | ||
| let device = MTDeviceRef(mutating: raw) | ||
| register(device, callback) | ||
| start(device, 0) | ||
| devices.append(device) | ||
| } | ||
| return !devices.isEmpty | ||
| } | ||
|
|
||
| func stopListening(_ callback: MTContactCallback) { | ||
| for device in devices { | ||
| stop(device) | ||
| unregister(device, callback) | ||
| } | ||
| devices.removeAll() | ||
| } | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
To prevent data races on the gesture state variables (
engaged,armed, etc.) between the main thread (indisable()) and the backgroundMultitouchSupportcallback thread (inhandleFrame), we should introduce a lock to synchronize access.