From 07dd6b891deeb65ef421cb12f8044369a49696f2 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Thu, 6 Aug 2026 14:19:55 -0700 Subject: [PATCH] Fix TrackDrum entry and recovery --- slab/menuband-trackpad/Info.plist | 4 +- slab/menuband-trackpad/README.md | 2 +- slab/menuband-trackpad/Sources/main.swift | 40 ++++++++++++++--- .../Sources/MenuBand/AppDelegate.swift | 7 +++ .../MenuBandTrackpadPluginClient.swift | 44 ++++++++++++++++--- system/public/menuband/advanced.html | 4 +- system/public/menuband/support.html | 2 +- 7 files changed, 84 insertions(+), 19 deletions(-) diff --git a/slab/menuband-trackpad/Info.plist b/slab/menuband-trackpad/Info.plist index 937818fbc2..97da7586a2 100644 --- a/slab/menuband-trackpad/Info.plist +++ b/slab/menuband-trackpad/Info.plist @@ -17,9 +17,9 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.1 + 1.2 CFBundleVersion - 2 + 3 LSApplicationCategoryType public.app-category.music LSMinimumSystemVersion diff --git a/slab/menuband-trackpad/README.md b/slab/menuband-trackpad/README.md index f175e69c88..7fa1c1ef94 100644 --- a/slab/menuband-trackpad/README.md +++ b/slab/menuband-trackpad/README.md @@ -7,7 +7,7 @@ makes no sound: Menu Band renders and plays its existing Tracktramp widget. The helper listens only on `127.0.0.1`, accepts one Menu Band client, and opens the private trackpad device only while Menu Band has explicitly armed a performance session. The wire format contains at most 16 normalized contacts. -While that session is active, TrackDrum hides and disconnects the system cursor +While that session is active, TrackDrum hides the system cursor and consumes mouse buttons, two-finger context clicks, scrolling, and gestures. It restores them when Menu Band exits the session. Escape or another double-tap of Command always exits through a permission-free key-state watchdog. A session diff --git a/slab/menuband-trackpad/Sources/main.swift b/slab/menuband-trackpad/Sources/main.swift index 06365f3971..7c27d265e4 100644 --- a/slab/menuband-trackpad/Sources/main.swift +++ b/slab/menuband-trackpad/Sources/main.swift @@ -22,6 +22,7 @@ private struct ContactFrame: Codable { let version: Int let timestamp: Double let contacts: [WireContact] + let event: String? } /// Owns the system-pointer boundary while Menu Band is using the trackpad as @@ -37,6 +38,8 @@ private final class TrackpadInteractionShield { private var exitTimer: Timer? private var escapeWasDown = false private var commandWasDown = false + private var commandExitArmed = false + private var commandExitArmAfter: CFTimeInterval = 0 private var lastCommandTap: CFTimeInterval? var onExitRequested: (() -> Void)? @@ -63,6 +66,7 @@ private final class TrackpadInteractionShield { exitTimer?.invalidate() exitTimer = nil lastCommandTap = nil + commandExitArmed = false stopEventTap() panels.forEach { $0.orderOut(nil) } panels.removeAll() @@ -75,7 +79,9 @@ private final class TrackpadInteractionShield { /// Permission-free emergency exit. Reading combined-session key state /// does not require a key tap, so Escape and the same double-Command - /// gesture that entered focus can always dismantle the click shield. + /// gesture that entered focus can always dismantle the click shield. The + /// watcher first observes a complete release after activation so the + /// entry gesture can never cancel the session it just opened. private func startExitWatchdog() { exitTimer?.invalidate() escapeWasDown = CGEventSource.keyState( @@ -84,6 +90,8 @@ private final class TrackpadInteractionShield { commandWasDown = CGEventSource.flagsState( .combinedSessionState ).contains(.maskCommand) + commandExitArmed = false + commandExitArmAfter = CACurrentMediaTime() + 0.35 let timer = Timer(timeInterval: 1.0 / 60.0, repeats: true) { [weak self] _ in guard let self else { return } @@ -95,8 +103,13 @@ private final class TrackpadInteractionShield { ).contains(.maskCommand) let escapePressed = escapeDown && !self.escapeWasDown var commandDoubleTapped = false - if commandDown && !self.commandWasDown { - let now = CACurrentMediaTime() + let now = CACurrentMediaTime() + if !self.commandExitArmed { + if !commandDown && now >= self.commandExitArmAfter { + self.commandExitArmed = true + self.lastCommandTap = nil + } + } else if commandDown && !self.commandWasDown { if let prior = self.lastCommandTap, now - prior <= 0.75 { commandDoubleTapped = true self.lastCommandTap = nil @@ -267,7 +280,7 @@ private final class TrackpadBridgeServer { self?.queue.async { [weak self] in guard let self, self.captureEnabled else { return } self.setCaptureEnabled(false) - self.client?.cancel() + self.sendExit() } } let parameters = NWParameters.tcp @@ -388,7 +401,7 @@ private final class TrackpadBridgeServer { } private func send(contacts: [TrackpadContact], timestamp: Double) { - guard captureEnabled || contacts.isEmpty, let client else { return } + guard captureEnabled || contacts.isEmpty, client != nil else { return } let bounded = contacts.prefix(16).map { WireContact( identifier: $0.identifier, @@ -400,8 +413,23 @@ private final class TrackpadBridgeServer { let frame = ContactFrame( version: 1, timestamp: timestamp.isFinite ? timestamp : CACurrentMediaTime(), - contacts: bounded + contacts: bounded, + event: nil ) + send(frame) + } + + private func sendExit() { + send(ContactFrame( + version: 1, + timestamp: CACurrentMediaTime(), + contacts: [], + event: "exit" + )) + } + + private func send(_ frame: ContactFrame) { + guard let client else { return } guard var payload = try? JSONEncoder().encode(frame), payload.count <= 16 * 1024 else { return } payload.append(0x0A) diff --git a/slab/menuband/Sources/MenuBand/AppDelegate.swift b/slab/menuband/Sources/MenuBand/AppDelegate.swift index 28cb018187..72664c5f6a 100644 --- a/slab/menuband/Sources/MenuBand/AppDelegate.swift +++ b/slab/menuband/Sources/MenuBand/AppDelegate.swift @@ -661,6 +661,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate { self.trackpadPluginConnected = connected debugLog("TrackDrum helper \(connected ? "connected" : "disconnected")") if !connected, self.trackpadPluginCaptureActive { + // CGDisplayHideCursor's global hide count can survive a + // helper SIGKILL. Balance it from Menu Band before teardown + // so a crashed TrackDrum can never strand an invisible cursor. + CGDisplayShowCursor(CGMainDisplayID()) self.handleTrackpadFrame( [], timestamp: CACurrentMediaTime(), callbackTime: CACurrentMediaTime() @@ -668,6 +672,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate { self.endPitchBendSession() } } + trackpadPlugin.onExitRequested = { [weak self] in + self?.exitPerformanceFocusFromEscape() + } trackpadPlugin.onFrame = { [weak self] contacts, timestamp, callbackTime in guard let self, self.trackpadPluginCaptureActive else { return } self.handleTrackpadFrame( diff --git a/slab/menuband/Sources/MenuBand/MenuBandTrackpadPluginClient.swift b/slab/menuband/Sources/MenuBand/MenuBandTrackpadPluginClient.swift index b3171fcc53..b81612bf91 100644 --- a/slab/menuband/Sources/MenuBand/MenuBandTrackpadPluginClient.swift +++ b/slab/menuband/Sources/MenuBand/MenuBandTrackpadPluginClient.swift @@ -11,6 +11,7 @@ final class MenuBandTrackpadPluginClient { static let helperBundleIdentifier = "computer.aestheticcomputer.menuband-trackpad" var onConnectionChanged: ((Bool) -> Void)? + var onExitRequested: (() -> Void)? var onFrame: (([TrackpadContact], Double, Double) -> Void)? private struct Command: Codable { @@ -30,6 +31,7 @@ final class MenuBandTrackpadPluginClient { let version: Int let timestamp: Double let contacts: [WireContact] + let event: String? } private let queue = DispatchQueue( @@ -41,7 +43,7 @@ final class MenuBandTrackpadPluginClient { private var receiveBuffer = Data() private var running = false private var captureEnabled = false - private var attemptedHelperLaunch = false + private var lastHelperLaunchAt: CFTimeInterval = -.infinity func start() { NSLog("MenuBand TrackDrum bridge: starting") @@ -95,6 +97,13 @@ final class MenuBandTrackpadPluginClient { self.connection = nil self.reportConnection(false) self.scheduleRetry() + case .waiting(let error): + NSLog("MenuBand TrackDrum bridge: connection waiting: %@", + String(describing: error)) + self.connection = nil + connection.cancel() + self.reportConnection(false) + self.scheduleRetry() case .cancelled: self.connection = nil self.reportConnection(false) @@ -104,16 +113,29 @@ final class MenuBandTrackpadPluginClient { } } connection.start(queue: queue) - launchInstalledHelperOnce() + launchInstalledHelperIfNeeded() } - private func launchInstalledHelperOnce() { - guard !attemptedHelperLaunch else { return } - attemptedHelperLaunch = true + private func launchInstalledHelperIfNeeded() { + let now = CACurrentMediaTime() + guard now - lastHelperLaunchAt >= 2.0 else { return } + lastHelperLaunchAt = now DispatchQueue.main.async { - guard let url = NSWorkspace.shared.urlForApplication( + guard NSRunningApplication.runningApplications( + withBundleIdentifier: Self.helperBundleIdentifier + ).isEmpty else { return } + let installedURL = URL( + fileURLWithPath: "/Applications/TrackDrum for Menu Band.app", + isDirectory: true + ) + let url = NSWorkspace.shared.urlForApplication( withBundleIdentifier: Self.helperBundleIdentifier - ) else { return } + ) ?? (FileManager.default.fileExists(atPath: installedURL.path) + ? installedURL : nil) + guard let url else { + NSLog("MenuBand TrackDrum helper is not installed") + return + } let configuration = NSWorkspace.OpenConfiguration() configuration.activates = false NSWorkspace.shared.openApplication( @@ -190,6 +212,14 @@ final class MenuBandTrackpadPluginClient { frame.version == 1, frame.timestamp.isFinite, frame.contacts.count <= 16 else { return } + if frame.event == "exit" { + guard frame.contacts.isEmpty else { return } + DispatchQueue.main.async { [weak self] in + self?.onExitRequested?() + } + return + } + guard frame.event == nil else { return } var contacts: [TrackpadContact] = [] contacts.reserveCapacity(frame.contacts.count) for contact in frame.contacts { diff --git a/system/public/menuband/advanced.html b/system/public/menuband/advanced.html index 02dbef5055..d34328a584 100644 --- a/system/public/menuband/advanced.html +++ b/system/public/menuband/advanced.html @@ -226,9 +226,9 @@

Got Menu Band? Now add TrackDrum. This small helper sends global trackpad contacts to Menu Band. While the instrument is active, it hides the pointer and prevents clicks or trackpad gestures from escaping. Press Escape or double-tap Command to exit. The sound and Tracktramp widget stay inside Menu Band; TrackDrum has no instrument window of its own.

diff --git a/system/public/menuband/support.html b/system/public/menuband/support.html index 3ac919925f..2109976f08 100644 --- a/system/public/menuband/support.html +++ b/system/public/menuband/support.html @@ -177,7 +177,7 @@
  • Typing doesn't play notes. In the Mac App Store version, Menu Band must be the active app: click the menu-bar piano, choose Notepat or Ableton, then type. The direct-download version can also play globally when Menu Band has Accessibility access under System Settings → Privacy & Security → Accessibility. Menu Band uses keystrokes only to play notes.
  • No sound. Confirm your Mac's output volume, and that a DAW isn't capturing the Menu Band MIDI source silently. With no DAW connected, the built-in synth should sound.
  • My DAW doesn't see Menu Band. Rescan MIDI devices in your DAW, or quit and reopen it after launching Menu Band so CoreMIDI re-enumerates the source.
  • -
  • Trackpad percussion doesn't open. Install and open TrackDrum for Menu Band, then reopen Menu Band and arm its percussion surface. TrackDrum runs without a window; the Tracktramp widget appears in Menu Band.
  • +
  • Trackpad percussion doesn't open. Install and open TrackDrum for Menu Band, then reopen Menu Band and arm its percussion surface. TrackDrum runs without a window; the Tracktramp widget appears in Menu Band.
  • Microphone sampling does nothing. Grant microphone permission (see above), then try holding ` again.
  • -- 2.51.2