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 + } }