Phase 9: trackpad volume gesture (shared F4)#9
Conversation
System-wide two-finger left-edge slide → system volume (PRD §3.3 / F4), the hardest phase. Works regardless of cursor position or which surface is visible. - MultitouchSupport.swift: runtime bindings to the private framework via dlopen/dlsym (no public header, can't link directly). MTTouch uses the community-reverse-engineered 96-byte layout (offsets through 'normalized' are stable); the loader asserts the stride and bails gracefully otherwise. - VolumeController.swift: reads/sets kAudioHardwareServiceDeviceProperty_ VirtualMainVolume on the current default output device via Core Audio, so speakers / headphones / AirPlay all work. - GestureEngine.swift: arms only when BOTH fingers land in the left-edge strip (x < 0.16), which separates a deliberate edge slide from ordinary two-finger scrolling; dead-zone before engaging; vertical delta → volume with throttled Core Audio writes. The bare C callback routes through a shared instance. - Opt-in menu toggle 'Left-edge Volume Gesture'; enabling prompts for Input Monitoring (IOHIDRequestAccess) the first time. Off by default so nothing claims trackpad space or prompts unexpectedly at launch. App is unsandboxed (no App Sandbox entitlement), so the private-framework dlopen and Core Audio calls are permitted (PRD §4: can't ship on the MAS). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request implements a system-wide trackpad gesture for controlling system volume by reading raw trackpad contacts via the private MultitouchSupport framework and updating the volume using Core Audio. The feedback focuses on ensuring thread safety and improving gesture precision. Key recommendations include introducing a lock to synchronize access to gesture state variables across threads, adjusting the volume throttling logic to guarantee that boundary volumes (0.0 and 1.0) are reachable, and refactoring the volume address property to a file-scope constant to prevent exclusivity violations.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| private var engaged = false | ||
| private var armed = false | ||
| private var baselineY: Float = 0 | ||
| private var startVolume: Float = 0 | ||
| private var lastAppliedVolume: Float = -1 |
There was a problem hiding this comment.
To prevent data races on the gesture state variables (engaged, armed, etc.) between the main thread (in disable()) and the background MultitouchSupport callback thread (in handleFrame), we should introduce a lock to synchronize access.
| private var engaged = false | |
| private var armed = false | |
| private var baselineY: Float = 0 | |
| private var startVolume: Float = 0 | |
| private var lastAppliedVolume: Float = -1 | |
| private let lock = NSLock() | |
| private var engaged = false | |
| private var armed = false | |
| private var baselineY: Float = 0 | |
| private var startVolume: Float = 0 | |
| private var lastAppliedVolume: Float = -1 |
| engaged = false | ||
| armed = false |
| 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) | ||
| } |
There was a problem hiding this comment.
This suggestion addresses two important issues in handleFrame:
- Thread Safety / Data Race: The gesture state is mutated on the background
MultitouchSupportcallback thread, but some state is also reset on the main thread indisable(). Wrapping the body inlock.withLockensures thread-safe access. - Boundary Volume Reachability: The
0.015(1.5%) throttle threshold can prevent the volume from reaching exactly0.0(muted) or1.0(100%) if the final gesture increment is small. Adding|| clamped == 0 || clamped == 1ensures these boundary values are always reachable.
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)
}
}| final class VolumeController { | ||
| // TODO: Phase 5 — AudioObjectSetPropertyData on | ||
| // kAudioHardwareServiceDeviceProperty_VirtualMainVolume, with smoothing. | ||
| private var volumeAddress = AudioObjectPropertyAddress( | ||
| mSelector: kAudioHardwareServiceDeviceProperty_VirtualMainVolume, | ||
| mScope: kAudioDevicePropertyScopeOutput, | ||
| mElement: kAudioObjectPropertyElementMain | ||
| ) |
There was a problem hiding this comment.
Since VolumeController is called from the background MultitouchSupport callback thread, having volumeAddress as an instance var and passing it by reference (&volumeAddress) to Core Audio functions can lead to thread-safety and exclusivity violations in Swift. Moving volumeAddress to the file scope as a private let constant makes the class completely stateless and thread-safe.
| final class VolumeController { | |
| // TODO: Phase 5 — AudioObjectSetPropertyData on | |
| // kAudioHardwareServiceDeviceProperty_VirtualMainVolume, with smoothing. | |
| private var volumeAddress = AudioObjectPropertyAddress( | |
| mSelector: kAudioHardwareServiceDeviceProperty_VirtualMainVolume, | |
| mScope: kAudioDevicePropertyScopeOutput, | |
| mElement: kAudioObjectPropertyElementMain | |
| ) | |
| private let volumeAddress = AudioObjectPropertyAddress( | |
| mSelector: kAudioHardwareServiceDeviceProperty_VirtualMainVolume, | |
| mScope: kAudioDevicePropertyScopeOutput, | |
| mElement: kAudioObjectPropertyElementMain | |
| ) | |
| final class VolumeController { |
What changed
Phase 9 — the shared trackpad left-edge volume gesture (PRD §3.3 / F4), the hardest phase. A two-finger vertical slide that starts at the trackpad's physical left edge acts as a system-wide volume fader — regardless of cursor position or which surface (notch / lock screen / neither) is visible.
MultitouchSupport.swift— runtime bindings to the private framework viadlopen/dlsym(no public header, can't link directly).MTTouchuses the long-standing community-reverse-engineered 96-byte layout (cited in the source comment); offsets throughnormalizedare the stable part and all we read. The loader asserts the stride (verified = 96,normalized@ offset 32) and disables the gesture gracefully if a future OS changes it, rather than striding through touches incorrectly.VolumeController.swift— reads/setskAudioHardwareServiceDeviceProperty_VirtualMainVolumeon the current default output device via Core Audio, so speakers / headphones / AirPlay all work; re-resolves the device each call (it can change).GestureEngine.swift— arms only when both fingers land in the left-edge strip (x < 0.16), which is what separates a deliberate edge slide from ordinary two-finger scrolling (which starts anywhere); a dead-zone before engaging avoids twitch; vertical delta maps to volume with throttled Core Audio writes. The bare C callback (no context) routes through a shared instance.IOHIDRequestAccess) the first time. Off by default so nothing claims trackpad space or triggers a permission prompt unexpectedly at launch.Why
Completes the shared gesture layer from the brief — the notch/lock-screen surfaces handle now-playing; this adds the always-on volume fader. The app is unsandboxed (no App Sandbox entitlement), which is what permits the private-framework
dlopenand Core Audio calls (PRD §4 already notes this can't ship on the Mac App Store).Base
Top of the stack: 5 → 6 → 7 → 8 → 9, based on
phase-8-lockscreen-lyrics(open PR #8). Diff is Phase 9 only.Notes for review — needs your eyes + hands on the trackpad
MTTouchstride == 96 checked against the C layout, notch/lock screen unaffected.GestureEngine:edgeThreshold(how wide the left strip is),travelForFullRange(sensitivity),deadZone. Slide direction is natural (up = louder) — flip the sign inhandleFrameif you'd prefer otherwise.🤖 Generated with Claude Code