From 66d4e7800b27561e6dfd3b621fd038b308dd28d3 Mon Sep 17 00:00:00 2001 From: Tirth Date: Fri, 10 Jul 2026 21:26:43 +0530 Subject: [PATCH] Phase 9: trackpad volume gesture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- NotchBeat.xcodeproj/project.pbxproj | 4 + NotchBeat/App/AppDelegate.swift | 1 + NotchBeat/App/MenuBarContentView.swift | 7 + NotchBeat/App/NotchBeatApp.swift | 1 + NotchBeat/GestureEngine/GestureEngine.swift | 135 ++++++++++++++++-- .../GestureEngine/MultitouchSupport.swift | 128 +++++++++++++++++ .../VolumeController/VolumeController.swift | 62 +++++++- 7 files changed, 322 insertions(+), 16 deletions(-) create mode 100644 NotchBeat/GestureEngine/MultitouchSupport.swift diff --git a/NotchBeat.xcodeproj/project.pbxproj b/NotchBeat.xcodeproj/project.pbxproj index 7e454eb..bf37ed9 100644 --- a/NotchBeat.xcodeproj/project.pbxproj +++ b/NotchBeat.xcodeproj/project.pbxproj @@ -18,6 +18,7 @@ 852BCE6356651B307827CFA9 /* LiquidGlassUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = C001B28DA5319FDF1493DCE6 /* LiquidGlassUI.swift */; }; 8CECBA1806341C3ACDF9666D /* GlobalHotKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = E114462CC31BFC8BFDB6571D /* GlobalHotKey.swift */; }; 935A65F30FA58FFC1F3F9D3D /* NotchUIState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 797E3149A2DA6244346D8F0A /* NotchUIState.swift */; }; + A31B6E43848E58D0BB123FA5 /* MultitouchSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D8D3F3BC53E976887A43AB3 /* MultitouchSupport.swift */; }; A879C2CBF374A2AA1F4532C1 /* SpotifyAPIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 709D63BE29CA0D22B1F331FB /* SpotifyAPIClient.swift */; }; AA4978CC7D080FB667E9CFA7 /* NotchMetrics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D3213245D2618045EA009B9 /* NotchMetrics.swift */; }; AE3D9D73EE09457F9FC9F512 /* LockScreenLyricsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C6DDE1A937A6DE6D87F8066F /* LockScreenLyricsView.swift */; }; @@ -41,6 +42,7 @@ 21253425E6FA10293E2FFD4A /* NotchBeatApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotchBeatApp.swift; sourceTree = ""; }; 30FA4A6CC5E1F4A8DBCB4DAA /* LockScreenControls.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LockScreenControls.swift; sourceTree = ""; }; 474238E0460DBDA760586035 /* LockScreenUIState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LockScreenUIState.swift; sourceTree = ""; }; + 4D8D3F3BC53E976887A43AB3 /* MultitouchSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultitouchSupport.swift; sourceTree = ""; }; 58A3D07D6AF6BC0759ABB9C7 /* SpotifyAuthManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpotifyAuthManager.swift; sourceTree = ""; }; 5CE226CBE5A055D67B715329 /* MenuBarContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuBarContentView.swift; sourceTree = ""; }; 5DDE8CB4F434738E3DA92CE4 /* PKCE.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PKCE.swift; sourceTree = ""; }; @@ -88,6 +90,7 @@ isa = PBXGroup; children = ( D463E53A2075B4EFF62193C8 /* GestureEngine.swift */, + 4D8D3F3BC53E976887A43AB3 /* MultitouchSupport.swift */, ); path = GestureEngine; sourceTree = ""; @@ -258,6 +261,7 @@ BD741B1B99AB90F0372BE471 /* LyricsService.swift in Sources */, 2D36EB8D1A9988C8C2B7B781 /* MediaMetadataService.swift in Sources */, 5BB1E43ADDFAFED421D13ACF /* MenuBarContentView.swift in Sources */, + A31B6E43848E58D0BB123FA5 /* MultitouchSupport.swift in Sources */, CF607D3364BA55B9E3E6AD90 /* NotchBeatApp.swift in Sources */, C7236C71AD7D95EED5695CFD /* NotchLayout.swift in Sources */, AA4978CC7D080FB667E9CFA7 /* NotchMetrics.swift in Sources */, diff --git a/NotchBeat/App/AppDelegate.swift b/NotchBeat/App/AppDelegate.swift index 9eea3bb..408bcda 100644 --- a/NotchBeat/App/AppDelegate.swift +++ b/NotchBeat/App/AppDelegate.swift @@ -3,6 +3,7 @@ import AppKit @MainActor final class AppDelegate: NSObject, NSApplicationDelegate { let authManager = SpotifyAuthManager() + let gestureEngine = GestureEngine() private(set) lazy var metadataService = MediaMetadataService(auth: authManager) private lazy var notchWindowController = NotchWindowController(metadata: metadataService) private lazy var lockScreenController = PseudoLockScreenController(metadata: metadataService) diff --git a/NotchBeat/App/MenuBarContentView.swift b/NotchBeat/App/MenuBarContentView.swift index 11b0730..8e6c873 100644 --- a/NotchBeat/App/MenuBarContentView.swift +++ b/NotchBeat/App/MenuBarContentView.swift @@ -4,6 +4,7 @@ import SwiftUI struct MenuBarContentView: View { @ObservedObject var auth: SpotifyAuthManager + @ObservedObject var gestureEngine: GestureEngine let onToggleLockScreen: () -> Void var body: some View { @@ -29,6 +30,12 @@ struct MenuBarContentView: View { Button("Toggle Lock Screen (⌃⌘L)") { onToggleLockScreen() } + // Opt-in: enabling prompts for Input Monitoring the first time, since + // the left-edge volume slide reads raw trackpad data (PRD F4). + Toggle("Left-edge Volume Gesture", isOn: Binding( + get: { gestureEngine.isEnabled }, + set: { gestureEngine.setEnabled($0) } + )) Divider() Button("Quit NotchBeat") { NSApplication.shared.terminate(nil) diff --git a/NotchBeat/App/NotchBeatApp.swift b/NotchBeat/App/NotchBeatApp.swift index 553bc21..d381cc5 100644 --- a/NotchBeat/App/NotchBeatApp.swift +++ b/NotchBeat/App/NotchBeatApp.swift @@ -9,6 +9,7 @@ struct NotchBeatApp: App { MenuBarExtra("NotchBeat", systemImage: "music.note") { MenuBarContentView( auth: appDelegate.authManager, + gestureEngine: appDelegate.gestureEngine, onToggleLockScreen: { appDelegate.toggleLockScreen() } ) } diff --git a/NotchBeat/GestureEngine/GestureEngine.swift b/NotchBeat/GestureEngine/GestureEngine.swift index 6b11822..458e545 100644 --- a/NotchBeat/GestureEngine/GestureEngine.swift +++ b/NotchBeat/GestureEngine/GestureEngine.swift @@ -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 + 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, _ 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) + } } diff --git a/NotchBeat/GestureEngine/MultitouchSupport.swift b/NotchBeat/GestureEngine/MultitouchSupport.swift new file mode 100644 index 0000000..aa1bfa4 --- /dev/null +++ b/NotchBeat/GestureEngine/MultitouchSupport.swift @@ -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? + 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.stride == 96 else { + NSLog("NotchBeat: unexpected MTTouch stride \(MemoryLayout.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(_ 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.. Float? { + guard let device = defaultOutputDevice() else { return nil } + guard AudioObjectHasProperty(device, &volumeAddress) else { return nil } + var value = Float32(0) + var size = UInt32(MemoryLayout.size) + let status = AudioObjectGetPropertyData(device, &volumeAddress, 0, nil, &size, &value) + return status == noErr ? value : nil + } + + /// Sets the main volume, clamped to 0...1. No-op if the device's volume + /// isn't software-settable. + @discardableResult + func setVolume(_ value: Float) -> Bool { + guard let device = defaultOutputDevice() else { return false } + guard AudioObjectHasProperty(device, &volumeAddress) else { return false } + var settable = DarwinBoolean(false) + guard AudioObjectIsPropertySettable(device, &volumeAddress, &settable) == noErr, + settable.boolValue else { return false } + var clamped = Float32(min(max(value, 0), 1)) + let status = AudioObjectSetPropertyData( + device, &volumeAddress, 0, nil, UInt32(MemoryLayout.size), &clamped + ) + return status == noErr + } + + private func defaultOutputDevice() -> AudioDeviceID? { + var address = AudioObjectPropertyAddress( + mSelector: kAudioHardwarePropertyDefaultOutputDevice, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain + ) + var device = AudioDeviceID(0) + var size = UInt32(MemoryLayout.size) + let status = AudioObjectGetPropertyData( + AudioObjectID(kAudioObjectSystemObject), &address, 0, nil, &size, &device + ) + return (status == noErr && device != 0) ? device : nil + } }