From 835326d3d9f711e1d32b6c371ddf7f02df323a13 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Thu, 28 May 2026 11:48:07 -0700 Subject: [PATCH] menuband: cmd-cmd launcher daemon + bipolar fx-chart X axis MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MenuBandLauncher: tiny always-running helper that watches a double-tap right-⌘ via CGEventTap and spawns Menu Band.app directly (bypassing NSWorkspace, which would resolve the shared bundle back to the launcher itself). Signed with its own code-sign identifier so TCC tracks it independently and Accessibility trust survives rebuilds. - --focus-on-launch flag: launcher passes it on spawn, and AppDelegate fires toggleQuietFocusFromRightCommand 400ms post-init so a cold-launch cmd-cmd lands in the same popover- open + focus-armed state as a warm one. - Singleton guard in MenuBand/main.swift so the launcher and launchd KeepAlive don't fight over the NSStatusItem slot when both try to spawn at once. - Pitch-bend overlay: trackpad can re-engage the slide while the chart is still visible (gated on trackpadTouchActive so a passing mouse can't); plain horizontal swipe drives a bipolar fxX ∈ [-1, +1] — right is echo, left is space/reverb — so the puck slides past center on either side. Release ramp glides fxX back to 0 alongside the other fx. Co-Authored-By: Claude Opus 4.7 (1M context) --- slab/menuband/Package.swift | 9 + .../Sources/MenuBand/AppDelegate.swift | 193 +++++++--- .../Sources/MenuBand/PitchBendCursor.swift | 344 +++++++----------- slab/menuband/Sources/MenuBand/main.swift | 18 + .../Sources/MenuBandLauncher/main.swift | 185 ++++++++++ slab/menuband/bin/dev.sh | 11 +- ...theticcomputer.menubandlauncher.plist.tmpl | 29 ++ slab/menuband/install.sh | 61 +++- 8 files changed, 585 insertions(+), 265 deletions(-) create mode 100644 slab/menuband/Sources/MenuBandLauncher/main.swift create mode 100644 slab/menuband/computer.aestheticcomputer.menubandlauncher.plist.tmpl diff --git a/slab/menuband/Package.swift b/slab/menuband/Package.swift index d2db6c0af..049954f98 100644 --- a/slab/menuband/Package.swift +++ b/slab/menuband/Package.swift @@ -5,6 +5,15 @@ let package = Package( name: "MenuBand", platforms: [.macOS(.v11)], targets: [ + // Tiny always-running daemon whose only job is to watch for the + // double-tap right-Command gesture and relaunch Menu Band.app + // if its process isn't currently running. When MenuBand IS + // running, the launcher no-ops — the main app's own + // double-tap handler in AppDelegate fires instead. + .executableTarget( + name: "MenuBandLauncher", + path: "Sources/MenuBandLauncher" + ), .executableTarget( name: "MenuBand", path: "Sources/MenuBand", diff --git a/slab/menuband/Sources/MenuBand/AppDelegate.swift b/slab/menuband/Sources/MenuBand/AppDelegate.swift index 06735b1b6..26fccc935 100644 --- a/slab/menuband/Sources/MenuBand/AppDelegate.swift +++ b/slab/menuband/Sources/MenuBand/AppDelegate.swift @@ -63,6 +63,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate { /// per-bar sine wiggle + smooths the activity level so bars move /// continuously instead of snapping on note events. private var visualizerAnimTimer: Timer? + /// Last-published values for the icon's animated state. The tick + /// only calls `updateIcon()` when these move past a small + /// perceptual epsilon — when the synth is silent and nothing is + /// flashing, the status item stops repainting and idle CPU + /// drops to near zero instead of burning ~24 redraws per second. + private var visualizerLastDrawnLevel: CGFloat = -1 + private var visualizerLastDrawnMidiFlash: CGFloat = -1 + private var visualizerLastDrawnMetroFlash: CGFloat = -1 private var visualizerSmoothedLevel: CGFloat = 0 /// Running adaptive peak — mirrors the main waveform's auto-gain /// (`smoothedPeak` in WaveformView) so the menubar bars normalize @@ -204,18 +212,20 @@ final class AppDelegate: NSObject, NSApplicationDelegate { /// Current bend amount in [-1, 1] (mapped to ±2 semitones via /// the GM default bend range). 0 = no bend. private var bendAmount: Float = 0 - /// Current "space" amount in [0, 1], driven by the trackpad - /// X-axis on the same bend gesture. 0 = dry/up-front, 1 = big + /// Current "space" amount in [0, 1], the reverb half of the + /// bipolar X axis (negative side). 0 = dry/up-front, 1 = big /// room. Eases back to 0 alongside the bend spring on release. private var spaceAmount: Float = 0 - /// Current echo amount in [0, 1], driven by ⌥Option + horizontal - /// trackpad on the same bend gesture. Held with the other fx - /// after release (see `startFxRelease`). + /// Current echo amount in [0, 1], the delay half of the bipolar + /// X axis (positive side). Held with the other fx after release + /// (see `startFxRelease`). private var echoAmount: Float = 0 - /// True while the active gesture is the ⌥Option echo axis (vs. - /// the plain X-axis space sweep). Selects which custom cursor / - /// overlay wheel shows and which axis dx is routed to. - private var echoAxisActive = false + /// Bipolar X-axis driver in [-1, +1] for the chart's puck. Right + /// (positive) feeds `echoAmount`, left (negative) feeds + /// `spaceAmount`; center is 0 = no fx. Lets a single horizontal + /// swipe pick either effect — the puck visibly slides past + /// center to the side the user dragged. + private var fxX: Float = 0 /// Post-release "dead zone": all fx hold here fully engaged for /// `fxHoldDuration`, so resuming play within the window keeps the /// sound intact. Fires into `startFxRamp` only if nothing resumes. @@ -229,6 +239,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { private var fxRampFromBend: Float = 0 private var fxRampFromSpace: Float = 0 private var fxRampFromEcho: Float = 0 + private var fxRampFromX: Float = 0 private var fxRampStart: Date? /// True while one or more fingers rest on the trackpad (fed by /// LocalKeyCapture's touch sensor). The bend holds — and survives @@ -780,6 +791,21 @@ final class AppDelegate: NSObject, NSApplicationDelegate { ) { _ in IconTinter.applyTintedIcon() } + + // `--focus-on-launch` is set by MenuBandLauncher when it + // wakes MenuBand from a double-tap ⌘⌘. The shortcut should + // not just launch the app — it should land in the same + // popover-open + focus-armed state the in-process double-tap + // handler produces. Fire that handler once the run loop has + // settled (the status item button isn't positioned yet inside + // applicationDidFinishLaunching, so showPopover bails without + // the deferred dispatch). + if CommandLine.arguments.contains("--focus-on-launch") { + debugLog("--focus-on-launch flag detected; arming focus after run-loop settles") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { [weak self] in + self?.toggleQuietFocusFromRightCommand() + } + } } // MARK: - Popover lifecycle @@ -1051,11 +1077,18 @@ final class AppDelegate: NSObject, NSApplicationDelegate { menuBand.playFocusCue(rising: false) localCapture.disarm(reason: .cancelled) } else { + // If the popover is closed, open it first so the gesture + // has a visible target — arming silently with no UI on + // screen leaves the user wondering whether anything happened. + let wasClosed = !isPopoverPanelShown + if wasClosed { + showPopover() + } // Start: arm capture immediately so the very next keys // play even while ⌘ is still held (right-⌘+f → plays F // AND enables). No overlay (beginFocusCapture… // deliberately doesn't call showExpandedForPopover). - beginFocusCaptureFromShortcut() + beginFocusCaptureFromShortcut(keepPopoverOpen: wasClosed) // Blue glow + rising bell + all keys flash at once. FocusFlashOverlay.shared.flash(rising: true) menuBand.playFocusCue(rising: true) @@ -1085,14 +1118,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate { beginFocusCaptureFromShortcut() } - private func beginFocusCaptureFromShortcut() { + private func beginFocusCaptureFromShortcut(keepPopoverOpen: Bool = false) { let frontmost = NSWorkspace.shared.frontmostApplication if frontmost?.bundleIdentifier == Bundle.main.bundleIdentifier { appBeforeFocusCapture = nil } else { appBeforeFocusCapture = frontmost } - closePopover() + if !keepPopoverOpen { + closePopover() + } if menuBand.typeMode { menuBand.disableTypeModeForFocusCapture() } @@ -1296,7 +1331,28 @@ final class AppDelegate: NSObject, NSApplicationDelegate { if KeyboardIconRenderer.metronomeFlash < 0.01 { KeyboardIconRenderer.metronomeFlash = 0 } - self.updateIcon() + // Only repaint the status item when one of the animated + // signals has actually moved a visible amount. The phase + // value updates every tick but the per-bar wiggle is too + // subtle to see when the levels are flat (silent floor), + // so the eye won't notice the skipped frame — but + // skipping is the difference between idle CPU at ~0% + // versus a steady drain from constant menubar repaints. + let level = self.visualizerSmoothedLevel + let midi = CGFloat(KeyboardIconRenderer.midiActivityFlash) + let metro = CGFloat(KeyboardIconRenderer.metronomeFlash) + let levelStep: CGFloat = 0.01 + let flashStep: CGFloat = 0.02 + let dirty = + abs(level - self.visualizerLastDrawnLevel) > levelStep + || abs(midi - self.visualizerLastDrawnMidiFlash) > flashStep + || abs(metro - self.visualizerLastDrawnMetroFlash) > flashStep + if dirty { + self.visualizerLastDrawnLevel = level + self.visualizerLastDrawnMidiFlash = midi + self.visualizerLastDrawnMetroFlash = metro + self.updateIcon() + } } RunLoop.main.add(timer, forMode: .common) visualizerAnimTimer = timer @@ -2411,6 +2467,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate { tvPanel.tv.ampProvider = { [weak self] in self?.currentSynthAmp() ?? 0 } + // Skip the KidLisp tick while the user is actively + // playing — held notes mean the synth + MIDI path + // need the main thread, and the bend gesture's pin + // timer fires at 60Hz. The TV catches up between + // performances and never blocks audio scheduling. + tvPanel.tv.busyProvider = { [weak self] in + guard let self = self else { return false } + return self.menuBand.litNotes.count > 0 + || self.pitchBendCursorLocked + || self.fxRampTimer != nil + } // Warm the cache so the first click pops a populated // menu instead of "Loading…". fetchKidLispChooserPieces(force: false) { } @@ -2508,33 +2575,43 @@ final class AppDelegate: NSObject, NSApplicationDelegate { // MARK: - Pitch-bend gesture private func handlePitchBendCursorMove(event: NSEvent) { - // Single-finger trackpad gesture: only active while the - // user is holding a KEYBOARD note. Mouse-tapped piano - // notes don't engage pitch-bend so the user can drag - // across menubar piano keys with the mouse normally. - // Shift held lets the wheel engage even with no key - // physically down — so you can grab still-ringing lingering - // notes and warp the whole sound. Shift also broadcasts the - // bend to ALL playing channels (see setBend allChannels). + // Single-finger trackpad gesture: normally active while the + // user is holding a KEYBOARD note. Mouse-tapped piano notes + // don't engage pitch-bend so the user can drag across menubar + // piano keys with the mouse normally. Shift held lets the + // wheel engage even with no key physically down — so you can + // grab still-ringing lingering notes and warp the whole sound. + // Shift also broadcasts the bend to ALL playing channels + // (see setBend allChannels). + // + // Additionally, while the chart overlay is still visible + // (post-release spring-back, fx-hold), a trackpad-driven + // mouseMoved re-engages the slide without requiring a + // keyboard note. We gate on trackpadTouchActive (NSTouch + // begun) so a passing MOUSE move can't reactivate the bend + // — only an actual finger on the trackpad. let shift = event.modifierFlags.contains(.shift) - guard menuBand.keyboardNotesHeld || shift else { return } + let chartUp = pitchBendOverlay?.isVisible ?? false + let reengageViaTrackpad = chartUp && trackpadTouchActive + guard menuBand.keyboardNotesHeld || shift || reengageViaTrackpad else { return } let dy = Float(event.deltaY) let dx = Float(event.deltaX) guard dy != 0 || dx != 0 else { return } // Negate so swipe UP on trackpad → pitch UP (NSEvent.deltaY // is positive when the cursor moves DOWN on screen). let bendDelta = -dy * Self.bendSensitivityPerPoint - // Horizontal is two-in-one: plain X = "space" (reverb), - // ⌥Option + X = "echo". Whichever the user is on takes dx; - // the other is left exactly where it was, so you can set - // one, switch the modifier, and set the other independently. - if event.modifierFlags.contains(.option) { - echoAmount = max(0, min(1, echoAmount + dx * Self.echoSensitivityPerPoint)) - echoAxisActive = true - } else { - spaceAmount = max(0, min(1, spaceAmount + dx * Self.spaceSensitivityPerPoint)) - echoAxisActive = false - } + // Horizontal is a single BIPOLAR fx axis: center = 0 = no fx, + // right = echo (trailing delay), left = space (reverb). + // One swipe direction picks one effect; center kills both. + // The puck's X position visualises this signed value directly. + // ⌥Option lets you fine-tune the same axis at lower + // sensitivity for precise hold-and-tweak. + let xSens: Float = event.modifierFlags.contains(.option) + ? Self.echoSensitivityPerPoint * Float(0.3) + : Self.echoSensitivityPerPoint + fxX = max(Float(-1), min(Float(1), fxX + dx * xSens)) + echoAmount = max(Float(0), fxX) + spaceAmount = max(Float(0), -fxX) cancelFxRelease() bendAmount += bendDelta // No clamp — the trackpad accumulator can swing past ±1 so @@ -2594,9 +2671,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate { guard let self = self, self.pitchBendCursorLocked, self.pitchBendCursorPushed else { return } - (self.echoAxisActive - ? EchoCursor.cursor(forEcho: self.echoAmount) - : PitchBendCursor.cursor(forBend: self.bendAmount)).set() + PitchBendCursor.cursor(forBend: self.bendAmount, + echo: self.echoAmount).set() } timer.tolerance = 1.0 / 120.0 RunLoop.main.add(timer, forMode: .common) @@ -2627,13 +2703,18 @@ final class AppDelegate: NSObject, NSApplicationDelegate { return overlay } - /// The wheel image for whichever fx axis is currently active — - /// the echo "repeats" cursor while ⌥Option is the driven axis, - /// otherwise the pitch-bend lever wheel. + /// XY-pad image for the floating overlay. The chart is a + /// frozen modulation pad at the lock point; the puck inside + /// rides up/down with bend and right with echo. Both axes + /// read at once and the chart never moves, so the user has + /// a stable reference frame for the whole gesture and the + /// post-release spring-back. private func currentFxCursorImage() -> NSImage { - echoAxisActive - ? EchoCursor.image(forEcho: echoAmount) - : PitchBendCursor.image(forBend: bendAmount) + // Pass the bipolar fxX so the puck slides both sides of + // center — positive (right) is echo, negative (left) is + // space/reverb. PitchBendCursor renders the signed value + // directly on the puck X axis. + PitchBendCursor.image(forBend: bendAmount, echo: fxX) } private func showPitchBendOverlay() { @@ -2644,6 +2725,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate { private func updatePitchBendOverlayImage() { guard let overlay = pitchBendOverlay, overlay.isVisible else { return } + // Chart is frozen at the lock point; only the puck inside + // moves, so a single image swap each tick is enough. overlay.update(image: currentFxCursorImage()) } @@ -2670,10 +2753,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate { NSCursor.pop() pitchBendCursorPushed = false } - // Tear down the overlay first, THEN show the system cursor — - // order matters so the user never sees the bend wheel and the - // real cursor on screen at the same time during the handoff. - pitchBendOverlay?.dismiss() + // Keep the chart overlay frozen at the lock point through + // the release ramp so the puck visibly slides back to + // center as bend/echo decay — `startFxRamp` (or the no-fx + // early return inside `startFxRelease`) dismisses it. The + // real system cursor comes back now; the chart floats + // above on the screenSaver level so both read at once. showSystemCursorIfNeeded() CGAssociateMouseAndMouseCursorPosition(1) pitchBendCursorLocked = false @@ -2688,8 +2773,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate { /// handler when the swipe resumes). private func startFxRelease() { cancelFxRelease() - // Nothing engaged → no fade needed, stay idle. - if bendAmount == 0 && spaceAmount == 0 && echoAmount == 0 { return } + // Nothing engaged → no fade needed, stay idle. The overlay + // (kept alive through `endPitchBendSession` for the ramp + // visualisation) has nothing to animate, so tear it down now. + if bendAmount == 0 && spaceAmount == 0 && echoAmount == 0 && fxX == 0 { + pitchBendOverlay?.dismiss() + return + } let hold = Timer(timeInterval: Self.fxHoldDuration, repeats: false) { [weak self] _ in self?.startFxRamp() } @@ -2707,6 +2797,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { fxRampFromBend = bendAmount fxRampFromSpace = spaceAmount fxRampFromEcho = echoAmount + fxRampFromX = fxX fxRampStart = Date() let timer = Timer(timeInterval: 1.0 / 60.0, repeats: true) { [weak self] timer in @@ -2720,6 +2811,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { self.bendAmount = self.fxRampFromBend * k self.spaceAmount = self.fxRampFromSpace * k self.echoAmount = self.fxRampFromEcho * k + self.fxX = self.fxRampFromX * k // allChannels so lingering / shift-bent voices on other // channels un-bend with everything else, not just held. self.menuBand.setBend(amount: self.bendAmount, allChannels: true) @@ -2733,6 +2825,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { self.bendAmount = 0 self.spaceAmount = 0 self.echoAmount = 0 + self.fxX = 0 self.menuBand.setBend(amount: 0, allChannels: true) self.menuBand.setSpace(amount: 0) self.menuBand.setEcho(amount: 0) @@ -2741,6 +2834,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate { timer.invalidate() self.fxRampTimer = nil self.fxRampStart = nil + // Ramp completed → tear down the chart (kept + // alive by endPitchBendSession purely so the user + // could watch the puck slide back to center). + self.pitchBendOverlay?.dismiss() } } timer.tolerance = 1.0 / 120.0 diff --git a/slab/menuband/Sources/MenuBand/PitchBendCursor.swift b/slab/menuband/Sources/MenuBand/PitchBendCursor.swift index c6876fe3f..6847d08b8 100644 --- a/slab/menuband/Sources/MenuBand/PitchBendCursor.swift +++ b/slab/menuband/Sources/MenuBand/PitchBendCursor.swift @@ -1,144 +1,147 @@ import AppKit -/// Custom mouse cursor used while one or more notes are sounding. -/// Visually a tiny vertical wheel (think pitch-bend lever) so the -/// user gets a hint that single-finger trackpad Y movement is now -/// going to bend the pitch. The wheel **stretches** in the bend -/// direction (bend up → wheel taller above center, ridges shift -/// up; bend down → mirror) so the cursor itself reads the current -/// pitch offset. +/// Custom cursor replacement used while the trackpad bend gesture +/// is engaged. It renders as a small XY modulation pad — frozen at +/// the lock point — with a puck that slides up/down to show the +/// current pitch-bend and right to show echo amount. The puck IS +/// the live state visualisation; the chart itself never moves, so +/// the user has a stable reference frame to read both axes +/// against as the audio rubber-bands back to neutral on release. enum PitchBendCursor { - /// Centered, no-bend cursor — used as the push baseline. - static let neutral: NSCursor = cursor(forBend: 0) + /// Centered, no-bend, no-echo cursor — used as the push baseline + /// fallback for in-app cursorUpdate handlers (the actual + /// visual live one is the floating overlay window). + static let neutral: NSCursor = cursor(forBend: 0, echo: 0) - /// Hot-spot used by both `cursor(forBend:)` and the floating - /// overlay window — keeps the bend wheel anchored over the - /// frozen cursor position when CGAssociateMouseAndMouseCursorPosition - /// detaches the system cursor. - static let hotSpot = NSPoint(x: 16, y: 20) - static let cursorSize = NSSize(width: 32, height: 40) + /// Hot-spot at the chart's center so the overlay window anchors + /// the chart directly over the user's frozen cursor position. + static let hotSpot = NSPoint(x: 40, y: 40) + static let cursorSize = NSSize(width: 80, height: 80) - /// Same bitmap the `cursor(forBend:)` factory uses, exposed - /// so a floating overlay window can draw the wheel itself - /// while the system cursor is hidden via CGDisplayHideCursor. - /// That hide-and-draw approach is what kills the cross-app - /// cursor flicker — other apps' cursorUpdate handlers - /// can't fight us if there's no system cursor for them to - /// reset. static func image(forBend amount: Float) -> NSImage { - return buildImage(forBend: amount) + buildImage(bend: CGFloat(amount), echo: 0) } - /// Build a cursor whose internal grip ridges + body extension - /// reflect a normalized bend amount in [-1, +1]. +1 = full - /// pitch up (ridges shift up, top of wheel stretched), −1 = - /// full pitch down (mirror). - static func cursor(forBend amount: Float) -> NSCursor { - let image = buildImage(forBend: amount) - return NSCursor(image: image, hotSpot: hotSpot) + static func image(forBend bend: Float, echo: Float) -> NSImage { + buildImage(bend: CGFloat(bend), echo: CGFloat(echo)) } - private static func buildImage(forBend amount: Float) -> NSImage { - let bend = max(-1, min(1, CGFloat(amount))) - let size = cursorSize - let image = NSImage(size: size, flipped: false) { rect in - let center = NSPoint(x: rect.midX, y: rect.midY) - let bodyW: CGFloat = 12 - // Base height plus an extension biased toward the bend - // direction. Total height grows with |bend| but the - // extra mass sits on the side the user is pulling - // toward, so the wheel reads as "leaning into" the - // pitch rather than just inflating uniformly. - let baseH: CGFloat = 20 - let stretch: CGFloat = 8 * abs(bend) - let bodyH = baseH + stretch - // Bias the body's vertical center: when bending up - // (positive), shift the body upward; when down, shift - // the body downward. The hot spot (center of view) - // stays constant so cursor placement is stable. - let bias: CGFloat = bend * 4 - let bodyRect = NSRect( - x: center.x - bodyW / 2, - y: center.y - bodyH / 2 + bias, - width: bodyW, - height: bodyH - ) - - let shadow = NSShadow() - shadow.shadowColor = NSColor.black.withAlphaComponent(0.55) - shadow.shadowOffset = .zero - shadow.shadowBlurRadius = 2 - NSGraphicsContext.saveGraphicsState() - shadow.set() - - let bodyPath = NSBezierPath(roundedRect: bodyRect, - xRadius: bodyW / 2, - yRadius: bodyW / 2) - // Hue tracks bend direction so the wheel reads at a - // glance: neutral = white; bending up tints toward - // accent (system color); bending down tints toward - // a complementary warm tone. - let accent = NSColor.controlAccentColor - let warm = NSColor(srgbRed: 1.0, green: 0.45, blue: 0.35, alpha: 1) - let topColor: NSColor = bend > 0 - ? (NSColor.white.blended(withFraction: bend * 0.75, of: accent) ?? .white) - : (NSColor.white.blended(withFraction: -bend * 0.45, of: warm) ?? .white) - let bottomColor: NSColor = bend < 0 - ? (NSColor(white: 0.78, alpha: 1).blended(withFraction: -bend * 0.75, of: warm) - ?? NSColor(white: 0.78, alpha: 1)) - : (NSColor(white: 0.78, alpha: 1).blended(withFraction: bend * 0.45, of: accent) - ?? NSColor(white: 0.78, alpha: 1)) - let bodyGradient = NSGradient(starting: topColor, ending: bottomColor) - bodyGradient?.draw(in: bodyPath, angle: -90) - NSColor.black.withAlphaComponent(0.6).setStroke() - bodyPath.lineWidth = 0.8 - bodyPath.stroke() - - NSGraphicsContext.restoreGraphicsState() + static func cursor(forBend amount: Float) -> NSCursor { + cursor(forBend: amount, echo: 0) + } - // Three horizontal grip ridges. Spacing tightens as - // the bend grows so the ridges look "compressed" on - // the side opposite the bend, which sells the lever - // squishing. - let ridgeOffsets: [CGFloat] = [-3, 0, 3] - let ridgeShift = bend * 4 - for offset in ridgeOffsets { - let path = NSBezierPath() - let y = center.y + offset + ridgeShift - path.move(to: NSPoint(x: bodyRect.minX + 2, y: y)) - path.line(to: NSPoint(x: bodyRect.maxX - 2, y: y)) - NSColor.black.withAlphaComponent(0.45).setStroke() - path.lineWidth = 0.9 - path.stroke() - } + static func cursor(forBend bend: Float, echo: Float) -> NSCursor { + NSCursor(image: image(forBend: bend, echo: echo), hotSpot: hotSpot) + } - // ↕ glyph above the wheel — slides up/down with the - // bend so the affordance keeps reading even when the - // wheel itself is heavily distorted. - let arrows = NSAttributedString( - string: "↕", - attributes: [ - .font: NSFont.systemFont(ofSize: 9, weight: .heavy), - .foregroundColor: NSColor.black.withAlphaComponent(0.85), - ] - ) - let arrowSize = arrows.size() - arrows.draw(at: NSPoint( - x: center.x - arrowSize.width / 2, - y: bodyRect.maxY + 1 - )) + private static func buildImage(bend: CGFloat, echo: CGFloat) -> NSImage { + let bendC = max(-1, min(1, bend)) + // `echo` is the bipolar fx-X driver in [-1, +1]: positive + // (right) is echo, negative (left) is space/reverb. We keep + // the parameter name `echo` for source-call stability; the + // chart treats it as a signed X value. + let xC = max(-1, min(1, echo)) + let size = cursorSize + return NSImage(size: size, flipped: false) { rect in + drawChart(in: rect, bend: bendC, echo: xC) return true } - return image + } + + private static func drawChart(in rect: NSRect, bend: CGFloat, echo: CGFloat) { + // Inset so the rounded background doesn't clip on the + // cursor canvas edge. + let chart = rect.insetBy(dx: 4, dy: 4) + let bg = NSBezierPath(roundedRect: chart, xRadius: 6, yRadius: 6) + // Semi-transparent dark plate so the puck and labels read + // against any app background. Adapts implicitly with the + // system appearance via the system accent reference. + NSColor.black.withAlphaComponent(0.5).setFill() + bg.fill() + NSColor.white.withAlphaComponent(0.45).setStroke() + bg.lineWidth = 0.8 + bg.stroke() + + let cx = chart.midX + let cy = chart.midY + + // Faint center crosshair — gives the puck a "zero" reference + // so the user can see when they've returned to neutral on + // either axis independently. + let cross = NSBezierPath() + cross.move(to: NSPoint(x: chart.minX + 6, y: cy)) + cross.line(to: NSPoint(x: chart.maxX - 6, y: cy)) + cross.move(to: NSPoint(x: cx, y: chart.minY + 6)) + cross.line(to: NSPoint(x: cx, y: chart.maxY - 6)) + NSColor.white.withAlphaComponent(0.22).setStroke() + cross.lineWidth = 0.5 + cross.stroke() + + // Axis labels: + / − for bend, « space (left) / » echo + // (right). Small, faint. + let labelAttrs: [NSAttributedString.Key: Any] = [ + .font: NSFont.systemFont(ofSize: 8, weight: .heavy), + .foregroundColor: NSColor.white.withAlphaComponent(0.6), + ] + let plus = NSAttributedString(string: "+", attributes: labelAttrs) + let minus = NSAttributedString(string: "−", attributes: labelAttrs) + let echoMark = NSAttributedString(string: "»", attributes: labelAttrs) + let spaceMark = NSAttributedString(string: "«", attributes: labelAttrs) + plus.draw(at: NSPoint(x: cx - plus.size().width / 2, + y: chart.maxY - plus.size().height - 1)) + minus.draw(at: NSPoint(x: cx - minus.size().width / 2, + y: chart.minY + 1)) + echoMark.draw(at: NSPoint(x: chart.maxX - echoMark.size().width - 2, + y: cy - echoMark.size().height / 2)) + spaceMark.draw(at: NSPoint(x: chart.minX + 2, + y: cy - spaceMark.size().height / 2)) + + // Puck position. Y axis uses the full [-1, +1] range of bend; + // X axis uses [0, +1] of echo (puck starts at center and + // rides right). Inset by puck radius so the puck stays + // inside the chart at extremes. + let puckR: CGFloat = 5.5 + let halfW = chart.width / 2 - puckR - 3 + let halfH = chart.height / 2 - puckR - 3 + let puckX = cx + echo * halfW + let puckY = cy + bend * halfH + let puckRect = NSRect(x: puckX - puckR, y: puckY - puckR, + width: puckR * 2, height: puckR * 2) + + // Hue tracks bend direction so the puck reads at a glance. + let accent = NSColor.controlAccentColor + let warm = NSColor(srgbRed: 1.0, green: 0.45, blue: 0.35, alpha: 1) + let puckColor: NSColor + if bend > 0 { + puckColor = NSColor.white.blended(withFraction: bend * 0.7, of: accent) ?? .white + } else if bend < 0 { + puckColor = NSColor.white.blended(withFraction: -bend * 0.7, of: warm) ?? .white + } else { + puckColor = .white + } + + // Soft drop shadow so the puck pops off the chart plate. + let shadow = NSShadow() + shadow.shadowColor = NSColor.black.withAlphaComponent(0.65) + shadow.shadowOffset = .zero + shadow.shadowBlurRadius = 3 + NSGraphicsContext.saveGraphicsState() + shadow.set() + let puckPath = NSBezierPath(ovalIn: puckRect) + puckColor.setFill() + puckPath.fill() + NSGraphicsContext.restoreGraphicsState() + NSColor.black.withAlphaComponent(0.55).setStroke() + puckPath.lineWidth = 0.6 + puckPath.stroke() } } -/// Borderless transparent panel that draws the pitch-bend wheel -/// at the cursor's locked screen position. Floats above every -/// app so the wheel stays visible regardless of which window the -/// mouse is over — pair with `CGDisplayHideCursor` to hide the -/// real system cursor and you get a flicker-free custom cursor -/// that doesn't fight other apps' cursorUpdate handlers. +/// Borderless transparent panel that draws the XY pad at the +/// cursor's locked screen position. Floats above every app so the +/// chart stays visible regardless of which window the mouse is +/// over — pair with `CGDisplayHideCursor` to hide the real system +/// cursor so the chart visibly replaces it. final class PitchBendCursorOverlayWindow: NSPanel { private let imageView = NSImageView() @@ -168,18 +171,20 @@ final class PitchBendCursorOverlayWindow: NSPanel { /// `screenPoint` is the absolute screen position the cursor /// is currently locked at. The panel is repositioned so the - /// wheel image lands centered on the hot spot at that point. + /// chart lands centered on the hot spot at that point. func show(image: NSImage, atScreenPoint screenPoint: NSPoint) { imageView.image = image let hot = PitchBendCursor.hotSpot let origin = NSPoint(x: screenPoint.x - hot.x, y: screenPoint.y - hot.y) setFrameOrigin(origin) + alphaValue = 1 if !isVisible { orderFrontRegardless() } } - /// Update only the wheel image; position stays put. Used by - /// the bend-amount changes and the rubber-band decay tick. + /// Update only the chart image (puck position changes); window + /// position stays put. The chart is intentionally frozen at the + /// lock point — only the internal puck moves. func update(image: NSImage) { imageView.image = image } @@ -189,91 +194,6 @@ final class PitchBendCursorOverlayWindow: NSPanel { } } -/// Custom cursor for the ⌥Option + horizontal "echo" axis. Reads as -/// a bright leading capsule with trailing, fading repeats to the -/// right — the repeats multiply, lengthen and brighten as the echo -/// amount grows, so the cursor itself shows how much tail you've -/// dialed in. Uses the SAME `PitchBendCursor.cursorSize` / `hotSpot` -/// so it drops straight into `PitchBendCursorOverlayWindow`. -enum EchoCursor { - static func cursor(forEcho amount: Float) -> NSCursor { - NSCursor(image: buildImage(forEcho: amount), - hotSpot: PitchBendCursor.hotSpot) - } - - static func image(forEcho amount: Float) -> NSImage { - buildImage(forEcho: amount) - } - - private static func buildImage(forEcho amount: Float) -> NSImage { - let echo = max(0, min(1, CGFloat(amount))) - let size = PitchBendCursor.cursorSize - return NSImage(size: size, flipped: false) { rect in - let center = NSPoint(x: rect.midX, y: rect.midY) - let capW: CGFloat = 7 - let capH: CGFloat = 16 - // 1 dry head + up to 4 repeats; count tracks amount so a - // small echo shows one ghost, a big one a long trail. - let repeats = Int((echo * 4).rounded()) - // Repeats march RIGHT; spacing widens with the amount so - // the trail visibly stretches as you sweep. - let gap: CGFloat = 4 + echo * 5 - - let accent = NSColor.controlAccentColor - - // Draw farthest (faintest) repeat first so nearer, brighter - // capsules paint over the tails — matches how the audio - // repeats sit under the dry hit. - for i in stride(from: repeats, through: 0, by: -1) { - let x = center.x - capW / 2 + CGFloat(i) * (capW + gap) - let capRect = NSRect(x: x, y: center.y - capH / 2, - width: capW, height: capH) - let path = NSBezierPath(roundedRect: capRect, - xRadius: capW / 2, - yRadius: capW / 2) - if i == 0 { - let shadow = NSShadow() - shadow.shadowColor = NSColor.black.withAlphaComponent(0.55) - shadow.shadowOffset = .zero - shadow.shadowBlurRadius = 2 - NSGraphicsContext.saveGraphicsState() - shadow.set() - NSColor.white.setFill() - path.fill() - NSColor.black.withAlphaComponent(0.6).setStroke() - path.lineWidth = 0.8 - path.stroke() - NSGraphicsContext.restoreGraphicsState() - } else { - // Geometric fade for the repeats; brightness also - // scales with the overall amount so a bigger echo - // reads as a hotter, more present trail. - let decay = pow(0.62, CGFloat(i)) - let alpha = (0.25 + 0.6 * echo) * decay - (accent.blended(withFraction: 0.35, - of: .white) ?? accent) - .withAlphaComponent(alpha).setFill() - path.fill() - } - } - - // » glyph above the head — points down the trail so the - // affordance still reads when the trail is short. - let arrows = NSAttributedString( - string: "»", - attributes: [ - .font: NSFont.systemFont(ofSize: 9, weight: .heavy), - .foregroundColor: NSColor.black.withAlphaComponent(0.85), - ] - ) - let aSize = arrows.size() - arrows.draw(at: NSPoint(x: center.x - aSize.width / 2, - y: center.y + capH / 2 + 1)) - return true - } - } -} - extension NSCursor { /// Convenience to push the neutral pitch-bend cursor onto the /// stack. Mirrors the original `PitchBendCursor.shared.push()` diff --git a/slab/menuband/Sources/MenuBand/main.swift b/slab/menuband/Sources/MenuBand/main.swift index b43b2da3d..43846ee91 100644 --- a/slab/menuband/Sources/MenuBand/main.swift +++ b/slab/menuband/Sources/MenuBand/main.swift @@ -7,6 +7,24 @@ if KLCLI.runIfRequested(CommandLine.arguments) { exit(0) } +// Singleton guard: when MenuBand is spawned by both launchd's +// KeepAlive (after crash / sleep wake) AND MenuBandLauncher's +// double-tap path at the same time, we get two instances fighting +// for the same NSStatusItem slot. The later instance quits silently +// so the first keeps its menubar slot and run-loop intact. +do { + let myPid = ProcessInfo.processInfo.processIdentifier + let duplicate = NSWorkspace.shared.runningApplications.contains { app in + guard app.processIdentifier != myPid, + let url = app.executableURL else { return false } + return url.lastPathComponent == "MenuBand" + } + if duplicate { + NSLog("MenuBand: duplicate instance detected — exiting") + exit(0) + } +} + let app = NSApplication.shared let delegate = AppDelegate() app.delegate = delegate diff --git a/slab/menuband/Sources/MenuBandLauncher/main.swift b/slab/menuband/Sources/MenuBandLauncher/main.swift new file mode 100644 index 000000000..cb42dee1a --- /dev/null +++ b/slab/menuband/Sources/MenuBandLauncher/main.swift @@ -0,0 +1,185 @@ +// MenuBandLauncher — tiny always-running helper. +// +// Job: watch for a double-tap of the right-Command key, and if Menu +// Band's main process isn't running, launch it. When Menu Band IS +// running, the launcher no-ops because the main app has its own +// equivalent handler in AppDelegate.startRightCommandTapMonitor — +// firing both would toggle focus capture twice. +// +// CGEventTap variant: an earlier version used +// NSEvent.addGlobalMonitorForEvents, which silently delivered zero +// events after each codesign rebuild even with AXIsProcessTrusted +// returning true. CGEventTap fails LOUDLY when Accessibility isn't +// granted (CGEvent.tapCreate returns nil), and it explicitly notifies +// us when the tap is disabled at runtime, so we can re-enable or +// surface the problem. +// +// Lives at Menu Band.app/Contents/MacOS/MenuBandLauncher and is +// signed with its own identifier +// (computer.aestheticcomputer.menubandlauncher) so TCC tracks it +// separately from the main binary. + +import AppKit +import ApplicationServices +import Foundation + +final class Launcher { + private static let menuBandBundleID = "computer.aestheticcomputer.menuband" + // Carbon virtual keycodes: kVK_Command (left, conventional) = 55, + // kVK_RightCommand = 54. Some keyboards only emit 55 for both + // sides, distinguishing via device-specific flag bits in the low + // 16 bits of CGEventFlags (NX_DEVICELCMDKEYMASK = 0x8, + // NX_DEVICERCMDKEYMASK = 0x10). Accept either keycode and use + // the device flag to identify the side actually pressed. + private static let leftCommandKeyCode: Int64 = 55 + private static let rightCommandKeyCode: Int64 = 54 + private static let nxDeviceLCmd: UInt64 = 0x8 + private static let nxDeviceRCmd: UInt64 = 0x10 + private static let doubleTapWindow: CFTimeInterval = 0.50 + + private var lastPressAt: CFTimeInterval = 0 + private var tap: CFMachPort? + private var runLoopSource: CFRunLoopSource? + + func start() -> Bool { + let trusted = AXIsProcessTrusted() + NSLog("MenuBandLauncher: start, AXIsProcessTrusted=\(trusted)") + + let mask = (1 << CGEventType.flagsChanged.rawValue) | + (1 << CGEventType.tapDisabledByTimeout.rawValue) | + (1 << CGEventType.tapDisabledByUserInput.rawValue) + + let opaque = Unmanaged.passUnretained(self).toOpaque() + let callback: CGEventTapCallBack = { proxy, type, event, userInfo in + guard let userInfo = userInfo else { + return Unmanaged.passUnretained(event) + } + let launcher = Unmanaged.fromOpaque(userInfo).takeUnretainedValue() + launcher.handle(type: type, event: event) + return Unmanaged.passUnretained(event) + } + + guard let tap = CGEvent.tapCreate( + tap: .cgSessionEventTap, + place: .headInsertEventTap, + options: .listenOnly, + eventsOfInterest: CGEventMask(mask), + callback: callback, + userInfo: opaque + ) else { + NSLog("MenuBandLauncher: CGEvent.tapCreate FAILED — Accessibility permission missing or tap denied. Grant Accessibility to MenuBandLauncher in System Settings.") + return false + } + self.tap = tap + let src = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, tap, 0) + CFRunLoopAddSource(CFRunLoopGetCurrent(), src, .commonModes) + self.runLoopSource = src + CGEvent.tapEnable(tap: tap, enable: true) + NSLog("MenuBandLauncher: CGEventTap installed") + return true + } + + private func handle(type: CGEventType, event: CGEvent) { + if type == .tapDisabledByTimeout || type == .tapDisabledByUserInput { + NSLog("MenuBandLauncher: tap disabled (\(type.rawValue)) — re-enabling") + if let tap = tap { CGEvent.tapEnable(tap: tap, enable: true) } + return + } + guard type == .flagsChanged else { return } + + let keyCode = event.getIntegerValueField(.keyboardEventKeycode) + let flags = event.flags + let rawFlags = flags.rawValue + let isCmdKey = (keyCode == Self.leftCommandKeyCode || keyCode == Self.rightCommandKeyCode) + let side: String + if (rawFlags & Self.nxDeviceRCmd) != 0 { side = "right" } + else if (rawFlags & Self.nxDeviceLCmd) != 0 { side = "left" } + else { side = "?" } + NSLog("MenuBandLauncher: flagsChanged keyCode=\(keyCode) side=\(side) flags=0x\(String(rawFlags, radix: 16))") + + guard isCmdKey else { return } + + // Down edge: .maskCommand is set on press, cleared on release. + let isDown = flags.contains(.maskCommand) + guard isDown else { return } + + // Bare ⌘ only. Reject chords so they can't pair into a + // future double-tap candidate. + let chordMask: CGEventFlags = [ + .maskShift, .maskAlternate, .maskControl, + .maskAlphaShift, .maskSecondaryFn + ] + if !flags.intersection(chordMask).isEmpty { + lastPressAt = 0 + return + } + + let now = CACurrentMediaTime() + if now - lastPressAt <= Self.doubleTapWindow { + lastPressAt = 0 + let running = isMenuBandRunning() + NSLog("MenuBandLauncher: double-tap ⌘ (\(side)) detected; menuband running=\(running)") + if !running { + launchMenuBand() + } + } else { + lastPressAt = now + } + } + + private func isMenuBandRunning() -> Bool { + // Filter on the executable file name — the launcher and the + // main binary share a bundle identifier in NSWorkspace's view + // even though they're signed with distinct code-sign + // identifiers, so we can't trust the bundle ID match alone. + let myPid = ProcessInfo.processInfo.processIdentifier + return NSWorkspace.shared.runningApplications.contains { app in + guard app.processIdentifier != myPid, + let url = app.executableURL else { return false } + return url.lastPathComponent == "MenuBand" + } + } + + private func launchMenuBand() { + // NSWorkspace.openApplication(at:) on the bundle URL gets + // tricked into returning the launcher's own + // NSRunningApplication, because LaunchServices treats both + // binaries inside the bundle as "the app from this bundle is + // already running." Bypass it by spawning the MenuBand + // executable directly with Process. detached: stdin/stdout/ + // stderr point at /dev/null so MenuBand isn't tied to the + // launcher's lifetime. + let bundlePath = NSString(string: "~/Applications/Menu Band.app") + .expandingTildeInPath + let exePath = bundlePath + "/Contents/MacOS/MenuBand" + NSLog("MenuBandLauncher: launching \(exePath)") + let task = Process() + task.executableURL = URL(fileURLWithPath: exePath) + // --focus-on-launch tells AppDelegate to open the popover + // and arm focus capture immediately after init, so a + // single ⌘⌘ relaunches AND lands in the same focused state + // the in-process double-tap handler produces. + task.arguments = ["--focus-on-launch"] + task.standardInput = FileHandle.nullDevice + task.standardOutput = FileHandle.nullDevice + task.standardError = FileHandle.nullDevice + do { + try task.run() + NSLog("MenuBandLauncher: spawned MenuBand pid=\(task.processIdentifier)") + } catch { + NSLog("MenuBandLauncher: spawn failed — \(error)") + } + } +} + +let app = NSApplication.shared +app.setActivationPolicy(.accessory) // background helper; allow event delivery + +let launcher = Launcher() +if !launcher.start() { + // tapCreate failed. Exit non-zero so launchd's ThrottleInterval + // gates re-launch attempts at 5s rather than tight-looping. + exit(2) +} + +app.run() diff --git a/slab/menuband/bin/dev.sh b/slab/menuband/bin/dev.sh index e7d0b3642..9b72034d2 100755 --- a/slab/menuband/bin/dev.sh +++ b/slab/menuband/bin/dev.sh @@ -21,13 +21,22 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROJECT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" # Stop the launchd-managed production daemon so we don't end up with -# two menubar items fighting over the same status item slot. +# two menubar items fighting over the same status item slot. Also +# stop the launcher helper — otherwise it'd see the dev binary die +# (or any spurious cmd-cmd) and relaunch the prod .app behind our +# back, putting the dev + prod menubar items in conflict again. PLIST="${HOME}/Library/LaunchAgents/computer.aestheticcomputer.menuband.plist" +LAUNCHER_PLIST="${HOME}/Library/LaunchAgents/computer.aestheticcomputer.menubandlauncher.plist" +if [[ -f "${LAUNCHER_PLIST}" ]] && launchctl list | grep -q computer.aestheticcomputer.menubandlauncher; then + printf "%s• stopping launchd Menu Band launcher%s\n" "$CYAN" "$RESET" + launchctl unload "${LAUNCHER_PLIST}" 2>/dev/null || true +fi if [[ -f "${PLIST}" ]] && launchctl list | grep -q computer.aestheticcomputer.menuband; then printf "%s• stopping launchd Menu Band%s\n" "$CYAN" "$RESET" launchctl unload "${PLIST}" 2>/dev/null || true fi pkill -f "/MenuBand$" 2>/dev/null || true +pkill -f "/MenuBandLauncher$" 2>/dev/null || true sleep 0.3 cd "${PROJECT_DIR}" diff --git a/slab/menuband/computer.aestheticcomputer.menubandlauncher.plist.tmpl b/slab/menuband/computer.aestheticcomputer.menubandlauncher.plist.tmpl new file mode 100644 index 000000000..33c0fa893 --- /dev/null +++ b/slab/menuband/computer.aestheticcomputer.menubandlauncher.plist.tmpl @@ -0,0 +1,29 @@ + + + + + Label + computer.aestheticcomputer.menubandlauncher + ProgramArguments + + @HOME@/Applications/Menu Band.app/Contents/MacOS/MenuBandLauncher + + EnvironmentVariables + + HOME + @HOME@ + PATH + /opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin + + RunAtLoad + + KeepAlive + + ThrottleInterval + 5 + StandardOutPath + /tmp/menubandlauncher.out + StandardErrorPath + /tmp/menubandlauncher.err + + diff --git a/slab/menuband/install.sh b/slab/menuband/install.sh index b2d45ded2..00b0d7549 100755 --- a/slab/menuband/install.sh +++ b/slab/menuband/install.sh @@ -23,10 +23,13 @@ REPO_HOME="${HOME}" LAUNCH_AGENTS="${REPO_HOME}/Library/LaunchAgents" PLIST_PATH="${LAUNCH_AGENTS}/computer.aestheticcomputer.menuband.plist" PLIST_TMPL="${SCRIPT_DIR}/computer.aestheticcomputer.menuband.plist.tmpl" +LAUNCHER_PLIST_PATH="${LAUNCH_AGENTS}/computer.aestheticcomputer.menubandlauncher.plist" +LAUNCHER_PLIST_TMPL="${SCRIPT_DIR}/computer.aestheticcomputer.menubandlauncher.plist.tmpl" INFO_PLIST="${SCRIPT_DIR}/Info.plist" APP_DIR="${REPO_HOME}/Applications/Menu Band.app" APP_BIN_DIR="${APP_DIR}/Contents/MacOS" APP_BIN="${APP_BIN_DIR}/MenuBand" +APP_LAUNCHER_BIN="${APP_BIN_DIR}/MenuBandLauncher" APP_RES="${APP_DIR}/Contents/Resources" say() { printf "%s• %s%s\n" "$CYAN" "$1" "$RESET"; } @@ -137,7 +140,29 @@ if [[ "${ARCHS}" != *"arm64"* ]] || [[ "${ARCHS}" != *"x86_64"* ]]; then fi ok "built universal (${ARCHS}): ${BUILT}" -say "unloading any existing MenuBand launch agent" +# MenuBandLauncher — same two-slice + lipo dance for the tiny helper +# that relaunches Menu Band when the double-tap right-⌘ gesture fires +# while the main process isn't running. +say "building MenuBandLauncher arm64 slice" +swift build -c release --target MenuBandLauncher --triple "${ARM_TRIPLE}" >/dev/null +ARM_LAUNCHER="$(swift build -c release --target MenuBandLauncher --triple "${ARM_TRIPLE}" --show-bin-path)/MenuBandLauncher" +[[ -x "${ARM_LAUNCHER}" ]] || { echo "launcher arm64 build missing at ${ARM_LAUNCHER}"; exit 1; } + +say "building MenuBandLauncher x86_64 slice (Intel Macs)" +swift build -c release --target MenuBandLauncher --triple "${X86_TRIPLE}" >/dev/null +X86_LAUNCHER="$(swift build -c release --target MenuBandLauncher --triple "${X86_TRIPLE}" --show-bin-path)/MenuBandLauncher" +[[ -x "${X86_LAUNCHER}" ]] || { echo "launcher x86_64 build missing at ${X86_LAUNCHER}"; exit 1; } + +say "lipo'ing launcher slices" +BUILT_LAUNCHER="${SCRIPT_DIR}/.build/universal/MenuBandLauncher" +lipo -create -output "${BUILT_LAUNCHER}" "${ARM_LAUNCHER}" "${X86_LAUNCHER}" +ok "built universal launcher: ${BUILT_LAUNCHER}" + +say "unloading any existing MenuBand launch agents" +if launchctl list | grep -q computer.aestheticcomputer.menubandlauncher; then + launchctl unload "${LAUNCHER_PLIST_PATH}" 2>/dev/null || true + ok "unloaded computer.aestheticcomputer.menubandlauncher" +fi if launchctl list | grep -q computer.aestheticcomputer.menuband; then launchctl unload "${PLIST_PATH}" 2>/dev/null || true ok "unloaded computer.aestheticcomputer.menuband" @@ -149,6 +174,10 @@ say "installing app bundle → ${APP_DIR}" mkdir -p "${APP_BIN_DIR}" "${APP_RES}" cp "${BUILT}" "${APP_BIN}" chmod +x "${APP_BIN}" +cp "${BUILT_LAUNCHER}" "${APP_LAUNCHER_BIN}" +chmod +x "${APP_LAUNCHER_BIN}" +# Strip DWARF off the launcher too, same rationale as the main binary. +strip -S "${APP_LAUNCHER_BIN}" # Strip DWARF debug symbols from the shipped binary. Without this the # release binary embeds every source file's absolute path under # /Users//aesthetic-computer/slab/menuband/Sources/... — harmless @@ -205,6 +234,23 @@ if ! codesign --force --deep --sign "${SIGN_ID}" \ warn "codesign failed — bundle is not signed with hardened runtime" exit 1 fi +# Re-sign the launcher binary with its OWN distinct identifier. The +# --deep above propagates the bundle's identifier +# (computer.aestheticcomputer.menuband) onto every nested binary, +# which makes TCC merge the two binaries into the same Accessibility +# entry. macOS then silently revokes that entry on each rebuild +# because the bundle hash changes. Giving the launcher its own +# identifier lets TCC track it independently and persist trust by +# Developer ID across re-signs. +if ! codesign --force --sign "${SIGN_ID}" \ + --identifier computer.aestheticcomputer.menubandlauncher \ + --options runtime \ + --entitlements "${ENTITLEMENTS}" \ + --timestamp \ + "${APP_LAUNCHER_BIN}" 2>&1; then + warn "launcher re-sign failed" + exit 1 +fi ok "signed" # Verify the signed bundle BEFORE launchctl load. IconTinter.swift calls @@ -224,19 +270,26 @@ else exit 1 fi -say "writing launchd plist → ${PLIST_PATH}" +say "writing launchd plists → ${PLIST_PATH}, ${LAUNCHER_PLIST_PATH}" mkdir -p "${LAUNCH_AGENTS}" sed "s|@HOME@|${REPO_HOME}|g" "${PLIST_TMPL}" > "${PLIST_PATH}" -ok "plist written" +sed "s|@HOME@|${REPO_HOME}|g" "${LAUNCHER_PLIST_TMPL}" > "${LAUNCHER_PLIST_PATH}" +ok "plists written" -say "loading launch agent" +say "loading launch agents" launchctl load "${PLIST_PATH}" +launchctl load "${LAUNCHER_PLIST_PATH}" sleep 1 if launchctl list | grep -q computer.aestheticcomputer.menuband; then ok "computer.aestheticcomputer.menuband is running" else warn "launchctl did not register the agent — check /tmp/menuband.err" fi +if launchctl list | grep -q computer.aestheticcomputer.menubandlauncher; then + ok "computer.aestheticcomputer.menubandlauncher is running" +else + warn "launcher agent did not register — check /tmp/menubandlauncher.err" +fi printf "\n%sdone.%s\n" "${BOLD}" "${RESET}" echo " bundle: ${APP_DIR}" -- 2.51.2