Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions NotchBeat.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand All @@ -41,6 +42,7 @@
21253425E6FA10293E2FFD4A /* NotchBeatApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotchBeatApp.swift; sourceTree = "<group>"; };
30FA4A6CC5E1F4A8DBCB4DAA /* LockScreenControls.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LockScreenControls.swift; sourceTree = "<group>"; };
474238E0460DBDA760586035 /* LockScreenUIState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LockScreenUIState.swift; sourceTree = "<group>"; };
4D8D3F3BC53E976887A43AB3 /* MultitouchSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultitouchSupport.swift; sourceTree = "<group>"; };
58A3D07D6AF6BC0759ABB9C7 /* SpotifyAuthManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpotifyAuthManager.swift; sourceTree = "<group>"; };
5CE226CBE5A055D67B715329 /* MenuBarContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuBarContentView.swift; sourceTree = "<group>"; };
5DDE8CB4F434738E3DA92CE4 /* PKCE.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PKCE.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -88,6 +90,7 @@
isa = PBXGroup;
children = (
D463E53A2075B4EFF62193C8 /* GestureEngine.swift */,
4D8D3F3BC53E976887A43AB3 /* MultitouchSupport.swift */,
);
path = GestureEngine;
sourceTree = "<group>";
Expand Down Expand Up @@ -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 */,
Expand Down
1 change: 1 addition & 0 deletions NotchBeat/App/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
7 changes: 7 additions & 0 deletions NotchBeat/App/MenuBarContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import SwiftUI

struct MenuBarContentView: View {
@ObservedObject var auth: SpotifyAuthManager
@ObservedObject var gestureEngine: GestureEngine
let onToggleLockScreen: () -> Void

var body: some View {
Expand All @@ -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)
Expand Down
1 change: 1 addition & 0 deletions NotchBeat/App/NotchBeatApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ struct NotchBeatApp: App {
MenuBarExtra("NotchBeat", systemImage: "music.note") {
MenuBarContentView(
auth: appDelegate.authManager,
gestureEngine: appDelegate.gestureEngine,
onToggleLockScreen: { appDelegate.toggleLockScreen() }
)
}
Expand Down
135 changes: 125 additions & 10 deletions NotchBeat/GestureEngine/GestureEngine.swift
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
Comment on lines +33 to +37

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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.

Suggested change
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


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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Wrap the state reset in lock.withLock to ensure thread safety when disabling the gesture engine.

        lock.withLock {
            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<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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

This suggestion addresses two important issues in handleFrame:

  1. Thread Safety / Data Race: The gesture state is mutated on the background MultitouchSupport callback thread, but some state is also reset on the main thread in disable(). Wrapping the body in lock.withLock ensures thread-safe access.
  2. Boundary Volume Reachability: The 0.015 (1.5%) throttle threshold can prevent the volume from reaching exactly 0.0 (muted) or 1.0 (100%) if the final gesture increment is small. Adding || clamped == 0 || clamped == 1 ensures 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)
        }
    }

}
128 changes: 128 additions & 0 deletions NotchBeat/GestureEngine/MultitouchSupport.swift
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()
}
}
Loading