Monorepo for Aesthetic.Computer aesthetic.computer
4
fork

Configure Feed

Select the types of activity you want to include in your feed.

slab/menuband: extract MenuBand into its own standalone app

Splits the menubar-piano work out of slab-menubar into a dedicated
MenuBand.app — a political project to bring the built-in macOS GM
instruments into the menu bar where they belong.

slab/menuband/
- SPM target (Sources/MenuBand) with NotepatController + virtual MIDI
source + AVAudioUnitSampler synth + global key-event tap + popover
settings panel.
- install.sh wraps the binary in MenuBand.app with stable bundle id
computer.aestheticcomputer.menuband, auto-detects an Apple
Developer cert in keychain, falls back to a self-signed identity.
- notarize.sh + MenuBand.entitlements ready for direct distribution
once a Developer ID Application cert is installed.
- AppIcon.icns: thin wide piano on a deep gradient squircle —
visualizes the "menu-constrained piano" framing.

slab/menubar-swift: drop the embedded notepat code now that it lives
in its own bundle. MenuBuilder no longer references NotepatController.

+2549 -9
+6
slab/menuband/.gitignore
··· 1 + .build/ 2 + .swiftpm/ 3 + DerivedData/ 4 + AppIcon.iconset/ 5 + *.xcodeproj/ 6 + *.swp
slab/menuband/AppIcon.icns

This is a binary file and will not be displayed.

+32
slab/menuband/Info.plist
··· 1 + <?xml version="1.0" encoding="UTF-8"?> 2 + <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> 3 + <plist version="1.0"> 4 + <dict> 5 + <key>CFBundleIdentifier</key> 6 + <string>computer.aestheticcomputer.menuband</string> 7 + <key>CFBundleExecutable</key> 8 + <string>MenuBand</string> 9 + <key>CFBundleName</key> 10 + <string>MenuBand</string> 11 + <key>CFBundleDisplayName</key> 12 + <string>MenuBand</string> 13 + <key>CFBundlePackageType</key> 14 + <string>APPL</string> 15 + <key>CFBundleVersion</key> 16 + <string>1</string> 17 + <key>CFBundleShortVersionString</key> 18 + <string>1.0</string> 19 + <key>CFBundleInfoDictionaryVersion</key> 20 + <string>6.0</string> 21 + <key>CFBundleIconFile</key> 22 + <string>AppIcon</string> 23 + <key>LSMinimumSystemVersion</key> 24 + <string>11.0</string> 25 + <key>LSUIElement</key> 26 + <true/> 27 + <key>NSHighResolutionCapable</key> 28 + <true/> 29 + <key>NSHumanReadableCopyright</key> 30 + <string>By aesthetic.computer</string> 31 + </dict> 32 + </plist>
+16
slab/menuband/MenuBand.entitlements
··· 1 + <?xml version="1.0" encoding="UTF-8"?> 2 + <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> 3 + <plist version="1.0"> 4 + <dict> 5 + <!-- Hardened Runtime — required for notarization. --> 6 + <!-- We don't need any of the Hardened Runtime exceptions; CoreMIDI, 7 + AVAudioEngine, CGEventTap, and Carbon RegisterEventHotKey all work 8 + under default Hardened Runtime. --> 9 + <key>com.apple.security.cs.allow-jit</key> 10 + <false/> 11 + <key>com.apple.security.cs.allow-unsigned-executable-memory</key> 12 + <false/> 13 + <key>com.apple.security.cs.disable-library-validation</key> 14 + <false/> 15 + </dict> 16 + </plist>
+13
slab/menuband/Package.swift
··· 1 + // swift-tools-version:5.9 2 + import PackageDescription 3 + 4 + let package = Package( 5 + name: "MenuBand", 6 + platforms: [.macOS(.v11)], 7 + targets: [ 8 + .executableTarget( 9 + name: "MenuBand", 10 + path: "Sources/MenuBand" 11 + ), 12 + ] 13 + )
+225
slab/menuband/Sources/MenuBand/AppDelegate.swift
··· 1 + import AppKit 2 + import Carbon 3 + 4 + final class AppDelegate: NSObject, NSApplicationDelegate { 5 + private var statusItem: NSStatusItem! 6 + private let notepat = NotepatController() 7 + private let hoverResponder = HoverResponder() 8 + private var hoveredElement: KeyboardIconRenderer.HitResult? = nil 9 + private var trackingArea: NSTrackingArea? 10 + private var typeModeHotkey: GlobalHotkey? 11 + private let popover = NSPopover() 12 + private var popoverVC: NotepatPopoverViewController? 13 + 14 + func applicationDidFinishLaunching(_ notification: Notification) { 15 + notepat.onChange = { [weak self] in 16 + DispatchQueue.main.async { self?.updateIcon() } 17 + } 18 + notepat.onLitChanged = { [weak self] in 19 + self?.updateIcon() 20 + } 21 + notepat.bootstrap() 22 + 23 + statusItem = NSStatusBar.system.statusItem(withLength: KeyboardIconRenderer.imageSize.width) 24 + if let button = statusItem.button { 25 + let cell = NoHighlightStatusBarCell() 26 + cell.imagePosition = .imageOnly 27 + cell.isBordered = false 28 + cell.highlightsBy = [] 29 + button.cell = cell 30 + button.imagePosition = .imageOnly 31 + button.target = self 32 + button.action = #selector(statusClicked(_:)) 33 + button.sendAction(on: [.leftMouseDown, .rightMouseDown]) 34 + button.isBordered = false 35 + 36 + hoverResponder.onMove = { [weak self] ev in self?.handleHover(event: ev) } 37 + hoverResponder.onExit = { [weak self] in self?.handleHoverExit() } 38 + let area = NSTrackingArea( 39 + rect: button.bounds, 40 + options: [.mouseMoved, .mouseEnteredAndExited, 41 + .activeAlways, .inVisibleRect], 42 + owner: hoverResponder, userInfo: nil 43 + ) 44 + button.addTrackingArea(area) 45 + trackingArea = area 46 + } 47 + updateIcon() 48 + 49 + // Global hotkey: Ctrl+Opt+Cmd+P toggles TYPE. 50 + let hotkey = GlobalHotkey { [weak self] in 51 + self?.notepat.toggleTypeMode() 52 + } 53 + let modMask: UInt32 = UInt32(cmdKey | controlKey | optionKey) 54 + hotkey.register(keyCode: UInt32(kVK_ANSI_P), modifiers: modMask) 55 + typeModeHotkey = hotkey 56 + } 57 + 58 + func applicationWillTerminate(_ notification: Notification) { 59 + notepat.shutdown() 60 + } 61 + 62 + private func updateIcon() { 63 + guard let button = statusItem.button else { return } 64 + statusItem.length = KeyboardIconRenderer.imageSize.width 65 + button.image = KeyboardIconRenderer.image( 66 + litNotes: notepat.litNotes, 67 + enabled: notepat.midiMode, 68 + typeMode: notepat.typeMode, 69 + melodicProgram: notepat.melodicProgram, 70 + hovered: hoveredElement 71 + ) 72 + } 73 + 74 + // MARK: - Hover 75 + 76 + private func handleHover(event: NSEvent) { 77 + guard let button = statusItem.button else { return } 78 + let imgSize = KeyboardIconRenderer.imageSize 79 + let bb = button.bounds 80 + let xOff = (bb.width - imgSize.width) / 2.0 81 + let yOff = (bb.height - imgSize.height) / 2.0 82 + let local = button.convert(event.locationInWindow, from: nil) 83 + let yLocal = button.isFlipped ? (bb.height - local.y) : local.y 84 + let pt = NSPoint(x: local.x - xOff, y: yLocal - yOff) 85 + let result = KeyboardIconRenderer.hit(at: pt) 86 + if hoveredElement != result { 87 + hoveredElement = result 88 + updateIcon() 89 + } 90 + } 91 + 92 + private func handleHoverExit() { 93 + if hoveredElement != nil { 94 + hoveredElement = nil 95 + updateIcon() 96 + } 97 + } 98 + 99 + // MARK: - Click + drag 100 + 101 + @objc private func statusClicked(_ sender: Any?) { 102 + guard let button = statusItem.button else { return } 103 + let event = NSApp.currentEvent 104 + let isRight = event?.type == .rightMouseDown 105 + let isCtrl = event?.modifierFlags.contains(.control) ?? false 106 + 107 + if hoveredElement != nil { 108 + hoveredElement = nil 109 + updateIcon() 110 + } 111 + 112 + if isRight || isCtrl { 113 + NSSound(named: NSSound.Name("Tink"))?.play() 114 + showPopover() 115 + return 116 + } 117 + guard let downEvent = event, downEvent.type == .leftMouseDown else { return } 118 + 119 + let imgSize = KeyboardIconRenderer.imageSize 120 + let bb = button.bounds 121 + let xOff = (bb.width - imgSize.width) / 2.0 122 + let yOff = (bb.height - imgSize.height) / 2.0 123 + func imagePoint(from windowPoint: NSPoint) -> NSPoint { 124 + let local = button.convert(windowPoint, from: nil) 125 + let yLocal = button.isFlipped ? (bb.height - local.y) : local.y 126 + return NSPoint(x: local.x - xOff, y: yLocal - yOff) 127 + } 128 + 129 + let initial = KeyboardIconRenderer.hit(at: imagePoint(from: downEvent.locationInWindow)) 130 + let startNote: UInt8 131 + switch initial { 132 + case .openSettings: 133 + NSSound(named: NSSound.Name("Tink"))?.play() 134 + showPopover() 135 + return 136 + case .note(let n): 137 + startNote = n 138 + case .none: 139 + return 140 + } 141 + 142 + let initialPt = imagePoint(from: downEvent.locationInWindow) 143 + let (vel0, pan0) = expression(for: startNote, at: initialPt) 144 + notepat.startTapNote(startNote, velocity: vel0, pan: pan0) 145 + var current: UInt8? = startNote 146 + while let next = NSApp.nextEvent( 147 + matching: [.leftMouseDragged, .leftMouseUp], 148 + until: .distantFuture, 149 + inMode: .eventTracking, 150 + dequeue: true 151 + ) { 152 + if next.type == .leftMouseUp { 153 + if let c = current { notepat.stopTapNote(c) } 154 + break 155 + } 156 + let pt = imagePoint(from: next.locationInWindow) 157 + let hovered = KeyboardIconRenderer.noteAt(pt) 158 + if hovered != current { 159 + if let prev = current { notepat.stopTapNote(prev) } 160 + if let nxt = hovered { 161 + let (v, p) = expression(for: nxt, at: pt) 162 + notepat.startTapNote(nxt, velocity: v, pan: p) 163 + } 164 + current = hovered 165 + } else if let c = current { 166 + let (_, p) = expression(for: c, at: pt) 167 + notepat.updateTapPan(c, pan: p) 168 + } 169 + } 170 + } 171 + 172 + private func expression(for midiNote: UInt8, at pt: NSPoint) -> (UInt8, UInt8) { 173 + guard let rect = KeyboardIconRenderer.keyRect(for: midiNote) else { 174 + return (100, 64) 175 + } 176 + let xRel = max(0, min(1, (pt.x - rect.minX) / rect.width)) 177 + let yRel = max(0, min(1, (pt.y - rect.minY) / rect.height)) 178 + let pan = UInt8(max(0, min(127, Int(round(xRel * 127))))) 179 + let yDist = abs(yRel - 0.5) * 2.0 180 + let vMin: Double = 60, vMax: Double = 120 181 + let vel = vMax - (vMax - vMin) * yDist 182 + let velocity = UInt8(max(1, min(127, Int(round(vel))))) 183 + return (velocity, pan) 184 + } 185 + 186 + // MARK: - Popover 187 + 188 + private func showPopover() { 189 + guard let button = statusItem.button else { return } 190 + if popoverVC == nil { 191 + let vc = NotepatPopoverViewController() 192 + vc.notepat = notepat 193 + popoverVC = vc 194 + popover.contentViewController = vc 195 + popover.behavior = .transient 196 + popover.animates = true 197 + _ = vc.view 198 + } 199 + popoverVC?.syncFromController() 200 + if popover.isShown { 201 + popover.performClose(nil) 202 + } else { 203 + let imgSize = KeyboardIconRenderer.imageSize 204 + let bb = button.bounds 205 + let xOff = (bb.width - imgSize.width) / 2.0 206 + let yOff = (bb.height - imgSize.height) / 2.0 207 + let latch = KeyboardIconRenderer.settingsRectPublic 208 + let anchor = NSRect( 209 + x: xOff + latch.minX, 210 + y: yOff + latch.minY, 211 + width: latch.width, 212 + height: latch.height 213 + ) 214 + // Activate the app + make the popover key so hover and clicks 215 + // register immediately. NSStatusItem popovers don't pull focus 216 + // by default; without this you have to click into the popover 217 + // once before its controls react. 218 + NSApp.activate(ignoringOtherApps: true) 219 + popover.show(relativeTo: anchor, of: button, preferredEdge: .minY) 220 + DispatchQueue.main.async { 221 + self.popover.contentViewController?.view.window?.makeKey() 222 + } 223 + } 224 + } 225 + }
+84
slab/menuband/Sources/MenuBand/GeneralMIDI.swift
··· 1 + import Foundation 2 + 3 + // General MIDI program names + family taxonomy. Used by the menubar instrument 4 + // picker. Program order matches NotepatSynth.setMelodicProgram(_:) (bankMSB 5 + // 0x79 in Apple's gs_instruments.dls). 6 + enum GeneralMIDI { 7 + static let programNames: [String] = [ 8 + "Acoustic Grand Piano", "Bright Acoustic Piano", "Electric Grand Piano", "Honky-tonk Piano", 9 + "Electric Piano 1", "Electric Piano 2", "Harpsichord", "Clavinet", 10 + "Celesta", "Glockenspiel", "Music Box", "Vibraphone", 11 + "Marimba", "Xylophone", "Tubular Bells", "Dulcimer", 12 + "Drawbar Organ", "Percussive Organ", "Rock Organ", "Church Organ", 13 + "Reed Organ", "Accordion", "Harmonica", "Tango Accordion", 14 + "Acoustic Guitar (nylon)", "Acoustic Guitar (steel)", "Electric Guitar (jazz)", "Electric Guitar (clean)", 15 + "Electric Guitar (muted)", "Overdriven Guitar", "Distortion Guitar", "Guitar Harmonics", 16 + "Acoustic Bass", "Electric Bass (finger)", "Electric Bass (pick)", "Fretless Bass", 17 + "Slap Bass 1", "Slap Bass 2", "Synth Bass 1", "Synth Bass 2", 18 + "Violin", "Viola", "Cello", "Contrabass", 19 + "Tremolo Strings", "Pizzicato Strings", "Orchestral Harp", "Timpani", 20 + "String Ensemble 1", "String Ensemble 2", "Synth Strings 1", "Synth Strings 2", 21 + "Choir Aahs", "Voice Oohs", "Synth Choir", "Orchestra Hit", 22 + "Trumpet", "Trombone", "Tuba", "Muted Trumpet", 23 + "French Horn", "Brass Section", "Synth Brass 1", "Synth Brass 2", 24 + "Soprano Sax", "Alto Sax", "Tenor Sax", "Baritone Sax", 25 + "Oboe", "English Horn", "Bassoon", "Clarinet", 26 + "Piccolo", "Flute", "Recorder", "Pan Flute", 27 + "Blown Bottle", "Shakuhachi", "Whistle", "Ocarina", 28 + "Lead 1 (square)", "Lead 2 (sawtooth)", "Lead 3 (calliope)", "Lead 4 (chiff)", 29 + "Lead 5 (charang)", "Lead 6 (voice)", "Lead 7 (fifths)", "Lead 8 (bass + lead)", 30 + "Pad 1 (new age)", "Pad 2 (warm)", "Pad 3 (polysynth)", "Pad 4 (choir)", 31 + "Pad 5 (bowed)", "Pad 6 (metallic)", "Pad 7 (halo)", "Pad 8 (sweep)", 32 + "FX 1 (rain)", "FX 2 (soundtrack)", "FX 3 (crystal)", "FX 4 (atmosphere)", 33 + "FX 5 (brightness)", "FX 6 (goblins)", "FX 7 (echoes)", "FX 8 (sci-fi)", 34 + "Sitar", "Banjo", "Shamisen", "Koto", 35 + "Kalimba", "Bagpipe", "Fiddle", "Shanai", 36 + "Tinkle Bell", "Agogo", "Steel Drums", "Woodblock", 37 + "Taiko Drum", "Melodic Tom", "Synth Drum", "Reverse Cymbal", 38 + "Guitar Fret Noise", "Breath Noise", "Seashore", "Bird Tweet", 39 + "Telephone Ring", "Helicopter", "Applause", "Gunshot", 40 + ] 41 + 42 + // 16 GM families × 8 programs. Used by the picker submenu hierarchy. 43 + static let families: [(name: String, range: ClosedRange<Int>)] = [ 44 + ("Piano", 0...7), 45 + ("Chromatic", 8...15), 46 + ("Organ", 16...23), 47 + ("Guitar", 24...31), 48 + ("Bass", 32...39), 49 + ("Strings", 40...47), 50 + ("Ensemble", 48...55), 51 + ("Brass", 56...63), 52 + ("Reed", 64...71), 53 + ("Pipe", 72...79), 54 + ("Synth Lead", 80...87), 55 + ("Synth Pad", 88...95), 56 + ("Synth FX", 96...103), 57 + ("Ethnic", 104...111), 58 + ("Percussive", 112...119), 59 + ("Sound FX", 120...127), 60 + ] 61 + 62 + /// Three-letter family abbreviation for the menubar picker label. 63 + static func familyAbbrev(for program: UInt8) -> String { 64 + switch Int(program) / 8 { 65 + case 0: return "PNO" 66 + case 1: return "CHR" 67 + case 2: return "ORG" 68 + case 3: return "GTR" 69 + case 4: return "BAS" 70 + case 5: return "STR" 71 + case 6: return "ENS" 72 + case 7: return "BRS" 73 + case 8: return "REE" 74 + case 9: return "PIP" 75 + case 10: return "LED" 76 + case 11: return "PAD" 77 + case 12: return "FX" 78 + case 13: return "ETH" 79 + case 14: return "PRC" 80 + case 15: return "SFX" 81 + default: return "—" 82 + } 83 + } 84 + }
+60
slab/menuband/Sources/MenuBand/GlobalHotkey.swift
··· 1 + import AppKit 2 + import Carbon 3 + 4 + // Thin wrapper around Carbon's RegisterEventHotKey so we can map a 5 + // keycode + modifier combination to a closure. Used for the system-wide 6 + // shortcut that toggles Notepat TYPE mode regardless of which app is 7 + // frontmost. 8 + final class GlobalHotkey { 9 + private var hotKeyRef: EventHotKeyRef? 10 + private var eventHandler: EventHandlerRef? 11 + private let onTrigger: () -> Void 12 + 13 + init(onTrigger: @escaping () -> Void) { 14 + self.onTrigger = onTrigger 15 + } 16 + 17 + /// Register the shortcut. `keyCode` uses Carbon `kVK_*` virtual key codes, 18 + /// `modifiers` uses the Carbon mask (`cmdKey | optionKey | controlKey | shiftKey`). 19 + @discardableResult 20 + func register(keyCode: UInt32, modifiers: UInt32) -> Bool { 21 + unregister() 22 + let hotKeyID = EventHotKeyID(signature: OSType(0x4E544B59), // 'NTKY' (Notepat key) 23 + id: 1) 24 + var ref: EventHotKeyRef? 25 + let regOK = RegisterEventHotKey(keyCode, modifiers, hotKeyID, 26 + GetApplicationEventTarget(), 0, &ref) 27 + guard regOK == noErr, let ref = ref else { 28 + NSLog("Notepat hotkey registration failed: \(regOK)") 29 + return false 30 + } 31 + hotKeyRef = ref 32 + 33 + var spec = EventTypeSpec(eventClass: OSType(kEventClassKeyboard), 34 + eventKind: UInt32(kEventHotKeyPressed)) 35 + let opaque = Unmanaged.passUnretained(self).toOpaque() 36 + let handler: EventHandlerUPP = { _, _, userData -> OSStatus in 37 + guard let userData = userData else { return noErr } 38 + let hk = Unmanaged<GlobalHotkey>.fromOpaque(userData).takeUnretainedValue() 39 + DispatchQueue.main.async { hk.onTrigger() } 40 + return noErr 41 + } 42 + let installOK = InstallEventHandler(GetApplicationEventTarget(), handler, 43 + 1, &spec, opaque, &eventHandler) 44 + if installOK != noErr { 45 + NSLog("Notepat hotkey event handler install failed: \(installOK)") 46 + unregister() 47 + return false 48 + } 49 + return true 50 + } 51 + 52 + func unregister() { 53 + if let ref = hotKeyRef { UnregisterEventHotKey(ref) } 54 + hotKeyRef = nil 55 + if let h = eventHandler { RemoveEventHandler(h) } 56 + eventHandler = nil 57 + } 58 + 59 + deinit { unregister() } 60 + }
+13
slab/menuband/Sources/MenuBand/HoverResponder.swift
··· 1 + import AppKit 2 + 3 + // NSResponder shim that forwards tracking-area mouse events to closures. 4 + // NSTrackingArea.owner has to be an NSResponder; this keeps AppDelegate from 5 + // having to subclass NSResponder. 6 + final class HoverResponder: NSResponder { 7 + var onMove: ((NSEvent) -> Void)? 8 + var onExit: (() -> Void)? 9 + 10 + override func mouseEntered(with event: NSEvent) { onMove?(event) } 11 + override func mouseMoved(with event: NSEvent) { onMove?(event) } 12 + override func mouseExited(with event: NSEvent) { onExit?() } 13 + }
+93
slab/menuband/Sources/MenuBand/KeyEventTap.swift
··· 1 + import Foundation 2 + import CoreGraphics 3 + 4 + // Low-latency global keyboard listener. Runs on a dedicated user-interactive 5 + // thread so key events bypass the main run loop entirely. 6 + final class KeyEventTap { 7 + /// Returns true to CONSUME the event (sink it — focused app won't see it), 8 + /// false to let it propagate normally. 9 + typealias Handler = (_ keyCode: UInt16, _ isDown: Bool, _ isRepeat: Bool, _ flags: CGEventFlags) -> Bool 10 + 11 + private var tap: CFMachPort? 12 + private var runLoopSource: CFRunLoopSource? 13 + private var thread: Thread? 14 + private var threadRunLoop: CFRunLoop? 15 + private let handler: Handler 16 + 17 + init(handler: @escaping Handler) { 18 + self.handler = handler 19 + } 20 + 21 + func start() -> Bool { 22 + guard tap == nil else { return true } 23 + 24 + let mask: CGEventMask = 25 + (1 << CGEventType.keyDown.rawValue) | 26 + (1 << CGEventType.keyUp.rawValue) | 27 + (1 << CGEventType.tapDisabledByTimeout.rawValue) | 28 + (1 << CGEventType.tapDisabledByUserInput.rawValue) 29 + 30 + let opaque = Unmanaged.passRetained(self).toOpaque() 31 + 32 + let callback: CGEventTapCallBack = { _, type, event, refcon in 33 + guard let refcon = refcon else { return Unmanaged.passUnretained(event) } 34 + let me = Unmanaged<KeyEventTap>.fromOpaque(refcon).takeUnretainedValue() 35 + 36 + if type == .tapDisabledByTimeout || type == .tapDisabledByUserInput { 37 + if let tap = me.tap { CGEvent.tapEnable(tap: tap, enable: true) } 38 + return Unmanaged.passUnretained(event) 39 + } 40 + if type == .keyDown || type == .keyUp { 41 + let kc = UInt16(event.getIntegerValueField(.keyboardEventKeycode)) 42 + let isRepeat = event.getIntegerValueField(.keyboardEventAutorepeat) != 0 43 + let consume = me.handler(kc, type == .keyDown, isRepeat, event.flags) 44 + if consume { return nil } // sink — don't propagate to focused app 45 + } 46 + return Unmanaged.passUnretained(event) 47 + } 48 + 49 + guard let tap = CGEvent.tapCreate( 50 + tap: .cgSessionEventTap, 51 + place: .headInsertEventTap, 52 + options: .defaultTap, // .defaultTap allows consuming events; .listenOnly does not 53 + eventsOfInterest: mask, 54 + callback: callback, 55 + userInfo: opaque 56 + ) else { 57 + Unmanaged<KeyEventTap>.fromOpaque(opaque).release() 58 + return false 59 + } 60 + self.tap = tap 61 + 62 + let source = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, tap, 0) 63 + self.runLoopSource = source 64 + 65 + let thread = Thread { [weak self] in 66 + guard let self = self, let source = self.runLoopSource, let tap = self.tap else { return } 67 + self.threadRunLoop = CFRunLoopGetCurrent() 68 + CFRunLoopAddSource(CFRunLoopGetCurrent(), source, .commonModes) 69 + CGEvent.tapEnable(tap: tap, enable: true) 70 + CFRunLoopRun() 71 + } 72 + thread.qualityOfService = .userInteractive 73 + thread.name = "Notepat-KeyTap" 74 + thread.start() 75 + self.thread = thread 76 + return true 77 + } 78 + 79 + func stop() { 80 + if let tap = tap { CGEvent.tapEnable(tap: tap, enable: false) } 81 + if let rl = threadRunLoop { CFRunLoopStop(rl) } 82 + if tap != nil { 83 + // Balance the passRetained from start(). 84 + Unmanaged.passUnretained(self).release() 85 + } 86 + tap = nil 87 + runLoopSource = nil 88 + thread = nil 89 + threadRunLoop = nil 90 + } 91 + 92 + deinit { stop() } 93 + }
+478
slab/menuband/Sources/MenuBand/KeyboardIconRenderer.swift
··· 1 + import AppKit 2 + 3 + // Tiny piano rendered into a single menubar status item with a flat 4 + // text-only header. Layout (left to right): 5 + // 6 + // [pad][picker][gap][piano keys (A3..D6, A3 hidden)][gap][MIDI][pad] 7 + // 8 + // Hit-testing returns .toggleMIDI / .pickInstrument / .note(midi) so the 9 + // drag-and-tap interaction in AppDelegate can route accordingly. Header 10 + // buttons are flat (no fill, no border) so they read like native menubar 11 + // text. Keys are skeuomorphic: white gradient + dark-accent black gradient. 12 + enum KeyboardIconRenderer { 13 + static let firstMidi: Int = 60 // C4 (middle C) 14 + static let lastMidi: Int = 83 // B5 — stop at the last QWERTY-letter key 15 + // (skip ;, ', ] which sit beyond the row) 16 + 17 + // Piano key sizes — wide whites give a generous hit area between black 18 + // keys (the exposed-white-between-blacks zone is whiteW - blackW). 19 + static let whiteW: CGFloat = 23.0 20 + static let whiteH: CGFloat = 21.0 21 + static let blackW: CGFloat = 13.5 22 + static let blackH: CGFloat = 12.0 // shorter so the white-below strip is 23 + // tall enough to drag across easily 24 + static let pad: CGFloat = 0.5 25 + 26 + // Settings — simple monochrome music note that reads like a native 27 + // status-bar icon. Click → popup menu with TYPE / MIDI / Instrument / 28 + // About. 29 + static let settingsW: CGFloat = 18.0 30 + static let settingsH: CGFloat = 21.0 31 + static let settingsGap: CGFloat = 4.0 32 + 33 + enum HitResult: Equatable { 34 + case openSettings 35 + case note(UInt8) 36 + } 37 + 38 + // Notepat layout key letters keyed by MIDI note. A3 (57) has no label 39 + // because notepat assigns it to the Control modifier rather than a letter. 40 + static let labelByMidi: [Int: String] = [ 41 + 58: "z", 59: "x", 60: "c", 61: "v", 62: "d", 63: "s", 42 + 64: "e", 65: "f", 66: "w", 67: "g", 68: "r", 69: "a", 43 + 70: "q", 71: "b", 72: "h", 73: "t", 74: "i", 75: "y", 44 + 76: "j", 77: "k", 78: "u", 79: "l", 80: "o", 81: "m", 45 + 82: "p", 83: "n", 84: ";", 85: "'", 86: "]", 46 + ] 47 + 48 + @inline(__always) 49 + private static func isWhite(_ midi: Int) -> Bool { 50 + switch midi % 12 { 51 + case 0, 2, 4, 5, 7, 9, 11: return true 52 + default: return false 53 + } 54 + } 55 + 56 + private static func whiteList() -> [Int] { 57 + (firstMidi...lastMidi).filter { isWhite($0) } 58 + } 59 + 60 + private static var pianoWidth: CGFloat { 61 + CGFloat(whiteList().count) * whiteW 62 + } 63 + 64 + static var imageSize: NSSize { 65 + let totalW = ceil(pad + pianoWidth + settingsGap + settingsW + pad) 66 + let totalH = ceil(whiteH + pad * 2) 67 + return NSSize(width: totalW, height: totalH) 68 + } 69 + 70 + private static var pianoOriginX: CGFloat { pad } 71 + 72 + /// Settings chip's visual rect IS its hit-test rect — they're identical 73 + /// so the user gets visual feedback wherever the click lands. 74 + private static var settingsRect: NSRect { settingsHitRect } 75 + 76 + static func image(litNotes: Set<UInt8>, 77 + enabled: Bool, 78 + typeMode: Bool = false, 79 + melodicProgram: UInt8 = 0, 80 + hovered: HitResult? = nil) -> NSImage { 81 + let whites = whiteList() 82 + var whiteIndex: [Int: Int] = [:] 83 + for (i, m) in whites.enumerated() { whiteIndex[m] = i } 84 + let size = imageSize 85 + 86 + let img = NSImage(size: size, flipped: false) { _ in 87 + 88 + // Piano. 89 + NSGraphicsContext.saveGraphicsState() 90 + // Touched / active = a brighter version of the user's accent 91 + // color (controlAccentColor lightened ~25% toward white) so the 92 + // pressed state pops without leaving the accent palette. 93 + let lit = NSColor.controlAccentColor.highlight(withLevel: 0.30) 94 + ?? NSColor.controlAccentColor 95 + let groove = NSColor.black.withAlphaComponent(0.55) 96 + let whiteHi = NSColor.white 97 + let whiteLo = NSColor(white: 0.88, alpha: 1.0) 98 + let blackHi = NSColor.controlAccentColor.shadow(withLevel: 0.30) ?? NSColor.controlAccentColor 99 + let blackLo = NSColor.controlAccentColor.shadow(withLevel: 0.55) ?? NSColor.controlAccentColor 100 + 101 + let leftmostMidi = firstMidi // C4 is the leftmost drawn white 102 + let rightmostMidi = lastMidi // B5 103 + for (idx, m) in whites.enumerated() { 104 + let rect = whiteRect(at: idx) 105 + let isLit = litNotes.contains(UInt8(m)) 106 + let isHover = hovered == .note(UInt8(m)) 107 + let isLeftmost = (m == leftmostMidi) 108 + let isRightmost = (m == rightmostMidi) 109 + let path = roundedKeyPath( 110 + rect: rect, 111 + tl: isLeftmost ? 2.5 : 0, 112 + tr: isRightmost ? 2.5 : 0, 113 + br: isRightmost ? 2.5 : 0, 114 + bl: isLeftmost ? 2.5 : 0 115 + ) 116 + if isLit { 117 + lit.setFill() 118 + path.fill() 119 + } else { 120 + NSGradient(starting: whiteHi, ending: whiteLo)!.draw(in: path, angle: -90) 121 + } 122 + if isHover && !isLit { 123 + NSColor.controlAccentColor.withAlphaComponent(0.50).setFill() 124 + path.fill() 125 + } 126 + groove.setStroke() 127 + path.lineWidth = 0.7 128 + path.stroke() 129 + if typeMode, let letter = labelByMidi[m] { 130 + drawWhiteLabel(letter, in: rect, lit: isLit) 131 + } 132 + } 133 + for m in firstMidi...lastMidi where !isWhite(m) { 134 + var leftWhite = m - 1 135 + while !isWhite(leftWhite) { leftWhite -= 1 } 136 + guard let leftIdx = whiteIndex[leftWhite] else { continue } 137 + let rect = blackRect(rightOfWhiteIndex: leftIdx) 138 + let isLit = litNotes.contains(UInt8(m)) 139 + let isHover = hovered == .note(UInt8(m)) 140 + let path = roundedKeyPath(rect: rect, tl: 0, tr: 0, br: 1.2, bl: 1.2) 141 + if isLit { 142 + lit.setFill() 143 + path.fill() 144 + } else { 145 + NSGradient(starting: blackHi, ending: blackLo)!.draw(in: path, angle: -90) 146 + } 147 + if isHover && !isLit { 148 + NSColor.white.withAlphaComponent(0.20).setFill() 149 + path.fill() 150 + } 151 + groove.setStroke() 152 + path.lineWidth = 0.6 153 + path.stroke() 154 + if typeMode, let letter = labelByMidi[m] { 155 + drawBlackLabel(letter, in: rect, lit: isLit) 156 + } 157 + } 158 + NSGraphicsContext.restoreGraphicsState() 159 + 160 + // Single accent-colored settings chip with a tiny dropdown 161 + // chevron — voice/qwerty/midi all live behind this one click. 162 + drawSettingsChip(in: settingsRect, hoverRect: settingsHitRect, 163 + anyActive: enabled || typeMode, 164 + hovered: hovered == .openSettings) 165 + return true 166 + } 167 + img.isTemplate = false 168 + return img 169 + } 170 + 171 + /// Public so the popover can anchor its arrow at the latch. 172 + static var settingsRectPublic: NSRect { settingsHitRect } 173 + 174 + // Settings button hit area extends from piano's right edge to the image 175 + // edge so the entire right-side region is one big click target. 176 + private static var settingsHitRect: NSRect { 177 + let leftX = pad + pianoWidth 178 + let rightX = imageSize.width 179 + return NSRect(x: leftX, y: 0, width: rightX - leftX, height: imageSize.height) 180 + } 181 + 182 + // MARK: - Hit testing 183 + 184 + static func hit(at point: NSPoint) -> HitResult? { 185 + if settingsHitRect.contains(point) { return .openSettings } 186 + let whites = whiteList() 187 + var whiteIndex: [Int: Int] = [:] 188 + for (i, m) in whites.enumerated() { whiteIndex[m] = i } 189 + // Black-key hit area = the visual blackRect. 1:1 mapping with what 190 + // the user sees on screen — clicking on visible black triggers black, 191 + // clicking visible white triggers white. 192 + for m in firstMidi...lastMidi where !isWhite(m) { 193 + var leftWhite = m - 1 194 + while !isWhite(leftWhite) { leftWhite -= 1 } 195 + guard let leftIdx = whiteIndex[leftWhite] else { continue } 196 + if blackRect(rightOfWhiteIndex: leftIdx).contains(point) { 197 + return .note(UInt8(m)) 198 + } 199 + } 200 + // White keys: y is unbounded so any cursor-y inside the button maps 201 + // to the white at that x — even when the menubar adds an extra pixel 202 + // or two of padding above/below the image, those edge clicks still 203 + // register as the underlying white. Black-band check above already 204 + // claims the black-key region; everything else falls through here. 205 + for (idx, m) in whites.enumerated() { 206 + let r = whiteRect(at: idx) 207 + let relaxed = NSRect(x: r.minX, y: -100, 208 + width: r.width, height: 200) 209 + if relaxed.contains(point) { return .note(UInt8(m)) } 210 + } 211 + return nil 212 + } 213 + 214 + /// Public lookup so callers (drag handler) can compute cursor-relative 215 + /// expression — e.g. y→velocity, x→pan within the active key's bounds. 216 + static func keyRect(for midi: UInt8) -> NSRect? { 217 + let m = Int(midi) 218 + let whites = whiteList() 219 + if isWhite(m) { 220 + guard let idx = whites.firstIndex(of: m) else { return nil } 221 + return whiteRect(at: idx) 222 + } else { 223 + var leftWhite = m - 1 224 + while !isWhite(leftWhite) { leftWhite -= 1 } 225 + guard let leftIdx = whites.firstIndex(of: leftWhite) else { return nil } 226 + return blackRect(rightOfWhiteIndex: leftIdx) 227 + } 228 + } 229 + 230 + /// Drag-friendly hit test for piano keys only. Vertically forgiving (y 231 + /// can be anywhere); horizontally tolerates a small overshoot past the 232 + /// leftmost/rightmost white key so a drag rolling past the edge keeps 233 + /// the edge key sounding. 234 + static func noteAt(_ point: NSPoint) -> UInt8? { 235 + let whites = whiteList() 236 + let leftEdge = pianoOriginX 237 + let rightEdge = pianoOriginX + CGFloat(whites.count) * whiteW 238 + let edgeTolerance: CGFloat = whiteW * 0.6 239 + guard point.x >= leftEdge - edgeTolerance, 240 + point.x < rightEdge + edgeTolerance else { return nil } 241 + 242 + // Black-key band: matches the visual blackRect exactly. 243 + let blackYMin = pad + (whiteH - blackH) 244 + if point.x >= leftEdge && point.x < rightEdge && point.y >= blackYMin { 245 + var whiteIndex: [Int: Int] = [:] 246 + for (i, m) in whites.enumerated() { whiteIndex[m] = i } 247 + for m in firstMidi...lastMidi where !isWhite(m) { 248 + var leftWhite = m - 1 249 + while !isWhite(leftWhite) { leftWhite -= 1 } 250 + guard let leftIdx = whiteIndex[leftWhite] else { continue } 251 + let rect = blackRect(rightOfWhiteIndex: leftIdx) 252 + if point.x >= rect.minX && point.x < rect.maxX { return UInt8(m) } 253 + } 254 + } 255 + // White by column, clamping x into the piano range so overshoot maps 256 + // to the leftmost/rightmost key. 257 + let clampedX = max(leftEdge, min(rightEdge - 0.001, point.x)) 258 + let col = Int((clampedX - leftEdge) / whiteW) 259 + let clamped = max(0, min(whites.count - 1, col)) 260 + return UInt8(whites[clamped]) 261 + } 262 + 263 + // MARK: - Layout helpers 264 + 265 + private static func whiteRect(at index: Int) -> NSRect { 266 + let x = pianoOriginX + CGFloat(index) * whiteW 267 + return NSRect(x: x, y: pad, width: whiteW, height: whiteH) 268 + } 269 + 270 + private static func blackRect(rightOfWhiteIndex idx: Int) -> NSRect { 271 + let xCenter = pianoOriginX + CGFloat(idx + 1) * whiteW 272 + let x = xCenter - blackW / 2.0 273 + let y = pad + (whiteH - blackH) 274 + return NSRect(x: x, y: y, width: blackW, height: blackH) 275 + } 276 + 277 + private static func roundedKeyPath(rect: NSRect, tl: CGFloat, tr: CGFloat, 278 + br: CGFloat, bl: CGFloat) -> NSBezierPath { 279 + let path = NSBezierPath() 280 + let minX = rect.minX, maxX = rect.maxX, minY = rect.minY, maxY = rect.maxY 281 + path.move(to: NSPoint(x: minX + bl, y: minY)) 282 + path.line(to: NSPoint(x: maxX - br, y: minY)) 283 + if br > 0 { 284 + path.appendArc(withCenter: NSPoint(x: maxX - br, y: minY + br), 285 + radius: br, startAngle: 270, endAngle: 360) 286 + } 287 + path.line(to: NSPoint(x: maxX, y: maxY - tr)) 288 + if tr > 0 { 289 + path.appendArc(withCenter: NSPoint(x: maxX - tr, y: maxY - tr), 290 + radius: tr, startAngle: 0, endAngle: 90) 291 + } 292 + path.line(to: NSPoint(x: minX + tl, y: maxY)) 293 + if tl > 0 { 294 + path.appendArc(withCenter: NSPoint(x: minX + tl, y: maxY - tl), 295 + radius: tl, startAngle: 90, endAngle: 180) 296 + } 297 + path.line(to: NSPoint(x: minX, y: minY + bl)) 298 + if bl > 0 { 299 + path.appendArc(withCenter: NSPoint(x: minX + bl, y: minY + bl), 300 + radius: bl, startAngle: 180, endAngle: 270) 301 + } 302 + path.close() 303 + return path 304 + } 305 + 306 + // MARK: - Key labels 307 + 308 + private static func drawWhiteLabel(_ text: String, in rect: NSRect, lit: Bool) { 309 + let attrs: [NSAttributedString.Key: Any] = [ 310 + .font: NSFont.systemFont(ofSize: 9.0, weight: .heavy), 311 + .foregroundColor: lit ? NSColor.white : NSColor(white: 0.28, alpha: 1.0), 312 + ] 313 + let str = NSAttributedString(string: text, attributes: attrs) 314 + let size = str.size() 315 + str.draw(at: NSPoint(x: rect.midX - size.width / 2, y: rect.minY + 0.4)) 316 + } 317 + 318 + private static func drawBlackLabel(_ text: String, in rect: NSRect, lit: Bool) { 319 + let attrs: [NSAttributedString.Key: Any] = [ 320 + .font: NSFont.systemFont(ofSize: 8.0, weight: .heavy), 321 + .foregroundColor: NSColor.white.withAlphaComponent(0.96), 322 + ] 323 + let str = NSAttributedString(string: text, attributes: attrs) 324 + let size = str.size() 325 + str.draw(at: NSPoint(x: rect.midX - size.width / 2, 326 + y: rect.midY - size.height / 2)) 327 + } 328 + 329 + /// Prefer Processing's bundled typeface for that Processing-IDE look. The 330 + /// font name varies across releases; try a few. Falls back to the system 331 + /// monospaced font (heavy) so the UI stays legible if it isn't installed. 332 + private static func processingFont(size: CGFloat) -> NSFont { 333 + let names = [ 334 + "ProcessingSansPro-Bold", 335 + "Processing-Sans-Pro-Bold", 336 + "Processing Sans Pro Bold", 337 + "ProcessingSansPro-Regular", 338 + "Processing Sans Pro", 339 + "Processing", 340 + ] 341 + for n in names { 342 + if let f = NSFont(name: n, size: size) { return f } 343 + } 344 + return NSFont.monospacedSystemFont(ofSize: size, weight: .heavy) 345 + } 346 + 347 + // MARK: - Header buttons (flat, hover-aware) 348 + 349 + private static func drawHoverBackdrop(in rect: NSRect, hovered: Bool) { 350 + guard hovered else { return } 351 + let r = rect.insetBy(dx: 1.0, dy: 1.5) 352 + let path = NSBezierPath(roundedRect: r, xRadius: 3, yRadius: 3) 353 + NSColor.labelColor.withAlphaComponent(0.10).setFill() 354 + path.fill() 355 + } 356 + 357 + /// `slider.horizontal.3` — three audio-mixer-style sliders, generic 358 + /// enough to cover TYPE / MIDI / Instrument / Octave / About without 359 + /// committing to one specific musical concept. Flat monochrome, blends 360 + /// with native menubar icons. Tints accent when any mode is active. 361 + /// 362 + /// Alternates: "ellipsis", "gearshape", "waveform", "music.note", 363 + /// "speaker.wave.2", "metronome", "switch.2". 364 + private static func drawSettingsChip(in rect: NSRect, hoverRect _: NSRect, 365 + anyActive: Bool, hovered: Bool) { 366 + let alpha: CGFloat = hovered ? 1.0 : 0.78 367 + let color: NSColor = anyActive 368 + ? NSColor.controlAccentColor 369 + : NSColor.labelColor.withAlphaComponent(alpha) 370 + drawTintedSymbol("slider.horizontal.3", in: rect, pointSize: 11.0, color: color) 371 + } 372 + 373 + private static func drawInstrumentLabel(in rect: NSRect, hoverRect: NSRect, 374 + program: UInt8, hovered: Bool) { 375 + drawHoverBackdrop(in: hoverRect, hovered: hovered) 376 + let safeIdx = max(0, min(127, Int(program))) 377 + let abbrev = GeneralMIDI.familyAbbrev(for: program) 378 + let label = String(format: "%@ %03d", abbrev, safeIdx) 379 + let alpha: CGFloat = hovered ? 1.0 : 0.82 380 + let attrs: [NSAttributedString.Key: Any] = [ 381 + .font: processingFont(size: 10.0), 382 + .foregroundColor: NSColor.labelColor.withAlphaComponent(alpha), 383 + .kern: 0.2, 384 + ] 385 + let str = NSAttributedString(string: label, attributes: attrs) 386 + let size = str.size() 387 + str.draw(at: NSPoint(x: rect.midX - size.width / 2, 388 + y: rect.midY - size.height / 2)) 389 + } 390 + 391 + private static func drawTypeButton(in rect: NSRect, hoverRect: NSRect, 392 + on: Bool, hovered: Bool) { 393 + drawHoverBackdrop(in: hoverRect, hovered: hovered) 394 + let alpha: CGFloat = hovered ? 1.0 : 0.78 395 + let activeColor = NSColor.controlAccentColor.highlight(withLevel: 0.30) 396 + ?? NSColor.controlAccentColor 397 + let color: NSColor = on ? activeColor : NSColor.labelColor.withAlphaComponent(alpha) 398 + drawQwertyMap(in: rect, color: color) 399 + } 400 + 401 + private static func drawMIDIButton(in rect: NSRect, hoverRect: NSRect, 402 + on: Bool, hovered: Bool) { 403 + drawHoverBackdrop(in: hoverRect, hovered: hovered) 404 + let alpha: CGFloat = hovered ? 1.0 : 0.78 405 + let activeColor = NSColor.controlAccentColor.highlight(withLevel: 0.30) 406 + ?? NSColor.controlAccentColor 407 + let color: NSColor = on ? activeColor : NSColor.labelColor.withAlphaComponent(alpha) 408 + 409 + // Layout: [music note] [tiny "midi"] inline, centered together. 410 + let iconSize: CGFloat = 11.0 411 + let textGap: CGFloat = 1.6 412 + let textAttrs: [NSAttributedString.Key: Any] = [ 413 + .font: NSFont.systemFont(ofSize: 6.5, weight: .heavy), 414 + .foregroundColor: color, 415 + .kern: 0.2, 416 + ] 417 + let textStr = NSAttributedString(string: "midi", attributes: textAttrs) 418 + let textSize = textStr.size() 419 + let totalW = iconSize + textGap + textSize.width 420 + let startX = rect.midX - totalW / 2 421 + let iconRect = NSRect(x: startX, y: rect.midY - iconSize / 2, 422 + width: iconSize, height: iconSize) 423 + drawTintedSymbol("music.note", in: iconRect, pointSize: 10.0, color: color) 424 + textStr.draw(at: NSPoint(x: startX + iconSize + textGap, 425 + y: rect.midY - textSize.height / 2)) 426 + } 427 + 428 + /// Tiny stylized QWERTY layout — three staggered rows of small rounded 429 + /// rects, like a typewriter keyboard from above. 430 + private static func drawQwertyMap(in rect: NSRect, color: NSColor) { 431 + color.setFill() 432 + let rows = [4, 4, 3] 433 + let rowOffsets: [CGFloat] = [0.0, 0.5, 1.0] // typewriter stagger 434 + let keySize: CGFloat = 1.9 435 + let hSpace: CGFloat = 0.5 436 + let vSpace: CGFloat = 0.6 437 + let maxKeys = rows.max() ?? 4 438 + let gridW = CGFloat(maxKeys) * keySize + CGFloat(maxKeys - 1) * hSpace 439 + let gridH = CGFloat(rows.count) * keySize + CGFloat(rows.count - 1) * vSpace 440 + let originX = rect.midX - gridW / 2 441 + let originY = rect.midY - gridH / 2 442 + for (rIdx, count) in rows.enumerated() { 443 + let y = originY + CGFloat(rows.count - 1 - rIdx) * (keySize + vSpace) 444 + let xOff = rowOffsets[rIdx] * (keySize + hSpace) 445 + for col in 0..<count { 446 + let x = originX + xOff + CGFloat(col) * (keySize + hSpace) 447 + let r = NSRect(x: x, y: y, width: keySize, height: keySize) 448 + NSBezierPath(roundedRect: r, xRadius: 0.35, yRadius: 0.35).fill() 449 + } 450 + } 451 + } 452 + 453 + /// Render an SF Symbol tinted to an arbitrary color. Templates alone 454 + /// don't reliably take a fill color when drawn outside a button, so we 455 + /// composite the symbol into a fresh image and use sourceIn to colour it. 456 + private static func drawTintedSymbol(_ name: String, in rect: NSRect, 457 + pointSize: CGFloat, color: NSColor) { 458 + guard let base = NSImage(systemSymbolName: name, accessibilityDescription: nil) else { return } 459 + let config = NSImage.SymbolConfiguration(pointSize: pointSize, weight: .bold) 460 + let configured = base.withSymbolConfiguration(config) ?? base 461 + let size = configured.size 462 + 463 + let tinted = NSImage(size: size) 464 + tinted.lockFocus() 465 + configured.draw(in: NSRect(origin: .zero, size: size), 466 + from: .zero, operation: .sourceOver, fraction: 1.0) 467 + color.set() 468 + NSRect(origin: .zero, size: size).fill(using: .sourceIn) 469 + tinted.unlockFocus() 470 + 471 + let drawRect = NSRect( 472 + x: rect.midX - size.width / 2, 473 + y: rect.midY - size.height / 2, 474 + width: size.width, height: size.height 475 + ) 476 + tinted.draw(in: drawRect) 477 + } 478 + }
+38
slab/menuband/Sources/MenuBand/NoHighlightStatusBarCell.swift
··· 1 + import AppKit 2 + 3 + // NSStatusBarButton draws a system click/hover pill behind its content. 4 + // Setting `highlightsBy = []` doesn't suppress it because the pill is drawn 5 + // by the cell's bezel routine, not the regular highlight state. Swapping in 6 + // a cell that no-ops the bezel and refuses to enter the highlighted state 7 + // removes the pill while leaving target/action and image drawing intact. 8 + final class NoHighlightStatusBarCell: NSButtonCell { 9 + override var isHighlighted: Bool { 10 + get { false } 11 + set { /* swallow — never enter highlighted state */ } 12 + } 13 + 14 + override func drawBezel(withFrame frame: NSRect, in controlView: NSView) { 15 + // Intentionally empty: this is where the menubar pill is painted. 16 + } 17 + 18 + override func highlight(_ flag: Bool, withFrame cellFrame: NSRect, in controlView: NSView) { 19 + // Force the cell to redraw without highlight, regardless of input state. 20 + super.highlight(false, withFrame: cellFrame, in: controlView) 21 + } 22 + 23 + override func draw(withFrame cellFrame: NSRect, in controlView: NSView) { 24 + // Bypass the default cell draw (which lays down the bezel/pill first) 25 + // and just composite the image ourselves, centered in the button. 26 + guard let image = self.image else { return } 27 + let imgSize = image.size 28 + let x = cellFrame.minX + (cellFrame.width - imgSize.width) / 2.0 29 + let y = cellFrame.minY + (cellFrame.height - imgSize.height) / 2.0 30 + let target = NSRect(x: x, y: y, width: imgSize.width, height: imgSize.height) 31 + image.draw(in: target, 32 + from: .zero, 33 + operation: .sourceOver, 34 + fraction: 1.0, 35 + respectFlipped: true, 36 + hints: nil) 37 + } 38 + }
+426
slab/menuband/Sources/MenuBand/NotepatController.swift
··· 1 + import AppKit 2 + import ApplicationServices 3 + import CoreGraphics 4 + 5 + final class NotepatController { 6 + private let midi = NotepatMIDI() 7 + private let synth = NotepatSynth() 8 + private var keyTap: KeyEventTap? 9 + private var heldNotes: [UInt16: UInt8] = [:] 10 + private let heldLock = NSLock() 11 + 12 + private let midiModeKey = "notepat.midiMode" 13 + private let typeModeKey = "notepat.typeMode" 14 + private let octaveShiftKey = "notepat.octaveShift" 15 + private let melodicProgramKey = "notepat.melodicProgram" 16 + private let keymapKey = "notepat.keymap" 17 + 18 + // Visual state — accessed only on the main thread. 19 + private(set) var litNotes: Set<UInt8> = [] 20 + private var litDownAt: [UInt8: CFTimeInterval] = [:] 21 + private let minVisibleSeconds: CFTimeInterval = 0.08 22 + 23 + var onChange: (() -> Void)? 24 + var onLitChanged: (() -> Void)? 25 + 26 + var midiMode: Bool { 27 + UserDefaults.standard.bool(forKey: midiModeKey) 28 + } 29 + 30 + var typeMode: Bool { 31 + UserDefaults.standard.bool(forKey: typeModeKey) 32 + } 33 + 34 + var keymap: Keymap { 35 + get { 36 + let raw = UserDefaults.standard.string(forKey: keymapKey) ?? "" 37 + return Keymap(rawValue: raw) ?? .notepat 38 + } 39 + set { 40 + UserDefaults.standard.set(newValue.rawValue, forKey: keymapKey) 41 + onChange?() 42 + } 43 + } 44 + 45 + var octaveShift: Int { 46 + get { UserDefaults.standard.integer(forKey: octaveShiftKey) } 47 + set { 48 + UserDefaults.standard.set(newValue, forKey: octaveShiftKey) 49 + releaseAllHeldNotes() 50 + onChange?() 51 + } 52 + } 53 + 54 + var melodicProgram: UInt8 { 55 + let raw = UserDefaults.standard.integer(forKey: melodicProgramKey) 56 + return UInt8(max(0, min(127, raw))) 57 + } 58 + 59 + func setMelodicProgram(_ program: UInt8) { 60 + UserDefaults.standard.set(Int(program), forKey: melodicProgramKey) 61 + synth.setMelodicProgram(program) 62 + onChange?() 63 + } 64 + 65 + 66 + func bootstrap() { 67 + // Built-in synth is always live. TYPE mode and MIDI mode are now 68 + // independent toggles: TYPE controls global keyboard capture + key 69 + // letter overlays; MIDI controls the virtual MIDI port output to 70 + // external DAWs. 71 + synth.start() 72 + synth.setMelodicProgram(melodicProgram) 73 + if typeMode { enableTypeMode(promptForPermission: false) } 74 + if midiMode { enableMIDIMode() } 75 + } 76 + 77 + func toggleMIDIMode() { 78 + if midiMode { disableMIDIMode() } else { enableMIDIMode() } 79 + } 80 + 81 + func toggleTypeMode() { 82 + if typeMode { 83 + disableTypeMode(playFeedback: true) 84 + } else { 85 + enableTypeMode(promptForPermission: true, playFeedback: true) 86 + } 87 + } 88 + 89 + func shutdown() { 90 + disableTypeMode() 91 + disableMIDIMode() 92 + synth.stop() 93 + } 94 + 95 + // MARK: - MIDI virtual port (Ableton-facing output) 96 + 97 + private func enableMIDIMode() { 98 + midi.start() 99 + synth.panic() // DAW takes over from internal synth 100 + UserDefaults.standard.set(true, forKey: midiModeKey) 101 + onChange?() 102 + } 103 + 104 + private func disableMIDIMode() { 105 + midi.stop() 106 + UserDefaults.standard.set(false, forKey: midiModeKey) 107 + onChange?() 108 + } 109 + 110 + // MARK: - TYPE mode (global keyboard capture + letter overlays) 111 + 112 + private func enableTypeMode(promptForPermission: Bool, playFeedback: Bool = false) { 113 + if !ensureAccessibility(prompt: promptForPermission) { 114 + UserDefaults.standard.set(false, forKey: typeModeKey) 115 + onChange?() 116 + return 117 + } 118 + let tap = KeyEventTap { [weak self] keyCode, isDown, isRepeat, flags in 119 + return self?.handleKey(keyCode: keyCode, isDown: isDown, isRepeat: isRepeat, flags: flags) ?? false 120 + } 121 + if !tap.start() { 122 + NSLog("Notepat: CGEventTap creation failed (likely missing Accessibility permission)") 123 + UserDefaults.standard.set(false, forKey: typeModeKey) 124 + onChange?() 125 + return 126 + } 127 + keyTap = tap 128 + UserDefaults.standard.set(true, forKey: typeModeKey) 129 + installClickAwayMonitor() 130 + if playFeedback { 131 + NSSound(named: NSSound.Name("Submarine"))?.play() 132 + } 133 + onChange?() 134 + } 135 + 136 + private func disableTypeMode(playFeedback: Bool = false) { 137 + removeClickAwayMonitor() 138 + keyTap?.stop() 139 + keyTap = nil 140 + releaseAllHeldNotes() 141 + UserDefaults.standard.set(false, forKey: typeModeKey) 142 + if playFeedback { 143 + NSSound(named: NSSound.Name("Pop"))?.play() 144 + } 145 + onChange?() 146 + } 147 + 148 + // MARK: - Click-away auto-disable 149 + 150 + private var clickAwayMonitor: Any? 151 + private var appActivationObserver: Any? 152 + 153 + private func installClickAwayMonitor() { 154 + // Global mouse-down monitor: fires for events going to any OTHER app. 155 + // A click outside our menubar item exits TYPE mode so we don't keep 156 + // sinking keystrokes after the user moves on. 157 + if clickAwayMonitor == nil { 158 + clickAwayMonitor = NSEvent.addGlobalMonitorForEvents( 159 + matching: [.leftMouseDown, .rightMouseDown, .otherMouseDown] 160 + ) { [weak self] _ in 161 + DispatchQueue.main.async { self?.disableTypeMode() } 162 + } 163 + } 164 + // App-activation observer: belt-and-suspenders for cmd-tab and 165 + // dock/Spotlight-launched activations that NSEvent global monitors 166 + // sometimes miss. 167 + if appActivationObserver == nil { 168 + appActivationObserver = NSWorkspace.shared.notificationCenter.addObserver( 169 + forName: NSWorkspace.didActivateApplicationNotification, 170 + object: nil, queue: .main 171 + ) { [weak self] note in 172 + guard let info = note.userInfo, 173 + let app = info[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication, 174 + app.bundleIdentifier != Bundle.main.bundleIdentifier 175 + else { return } 176 + self?.disableTypeMode() 177 + } 178 + } 179 + } 180 + 181 + private func removeClickAwayMonitor() { 182 + if let m = clickAwayMonitor { NSEvent.removeMonitor(m) } 183 + clickAwayMonitor = nil 184 + if let o = appActivationObserver { 185 + NSWorkspace.shared.notificationCenter.removeObserver(o) 186 + } 187 + appActivationObserver = nil 188 + } 189 + 190 + // Drums (low GM percussion notes) route to GM channel 10 (index 9) so the 191 + // built-in DLS synth picks the standard drum kit instead of piano. 192 + @inline(__always) 193 + private func channel(for note: UInt8) -> UInt8 { 194 + note < UInt8(KeyboardIconRenderer.firstMidi) ? 9 : 0 195 + } 196 + 197 + // MARK: - Menubar tap / drag-to-play 198 + 199 + private var tapHeld: Set<UInt8> = [] // notes currently held by mouse drag (main thread) 200 + private var tapNoteChannel: [UInt8: UInt8] = [:] // active channel per held note 201 + private var melodicVoiceCursor: UInt8 = 0 // round-robin across 8 melodic channels 202 + 203 + @inline(__always) 204 + private func nextMelodicChannel() -> UInt8 { 205 + let c = melodicVoiceCursor 206 + melodicVoiceCursor = (melodicVoiceCursor &+ 1) & 0x07 207 + return c 208 + } 209 + 210 + /// Begin holding a note from a menubar mouse interaction. Velocity (0–127) 211 + /// and pan (0–127, 64=center) are passed in by the caller — the drag 212 + /// handler computes them from the cursor's relative position inside the 213 + /// hovered key, giving expressive control: y closer to vertical center = 214 + /// louder, x within key = stereo pan. 215 + func startTapNote(_ midiNote: UInt8, velocity: UInt8 = 100, pan: UInt8 = 64) { 216 + if tapHeld.contains(midiNote) { return } 217 + tapHeld.insert(midiNote) 218 + let isDrum = midiNote < UInt8(KeyboardIconRenderer.firstMidi) 219 + let ch: UInt8 = isDrum ? 9 : nextMelodicChannel() 220 + tapNoteChannel[midiNote] = ch 221 + // Set pan first so the noteOn lands on a panned channel. 222 + midi.sendCC(10, value: pan, channel: ch) 223 + if !midiMode { synth.noteOn(midiNote, velocity: velocity, channel: ch) } 224 + midi.noteOn(midiNote, velocity: velocity, channel: ch) 225 + DispatchQueue.main.async { [weak self] in 226 + guard let self = self else { return } 227 + self.litDownAt[midiNote] = CACurrentMediaTime() 228 + if self.litNotes.insert(midiNote).inserted { 229 + self.onLitChanged?() 230 + } 231 + } 232 + } 233 + 234 + /// While dragging, update pan in real time as the cursor slides within 235 + /// the held note. Doesn't retrigger the note. 236 + func updateTapPan(_ midiNote: UInt8, pan: UInt8) { 237 + guard let ch = tapNoteChannel[midiNote] else { return } 238 + midi.sendCC(10, value: pan, channel: ch) 239 + } 240 + 241 + /// Release a note previously started with `startTapNote`. 242 + func stopTapNote(_ midiNote: UInt8) { 243 + guard tapHeld.contains(midiNote) else { return } 244 + tapHeld.remove(midiNote) 245 + let ch = tapNoteChannel.removeValue(forKey: midiNote) ?? channel(for: midiNote) 246 + let isDrum = midiNote < UInt8(KeyboardIconRenderer.firstMidi) 247 + // Drums are one-shot percussion: do NOT send synth.noteOff. Letting 248 + // the sample play through is what makes rapid taps overlap correctly 249 + // instead of cutting each other off. The MIDI port still sends noteOff 250 + // so external sequencers (Ableton drum racks) get a clean event pair. 251 + // Internal synth is silent in MIDI mode anyway. 252 + if !isDrum && !midiMode { 253 + synth.noteOff(midiNote, channel: ch) 254 + } 255 + midi.noteOff(midiNote, channel: ch) 256 + // Visual cleanup deferred so we don't pay image-render cost in the 257 + // mouse-up handler. 258 + DispatchQueue.main.async { [weak self] in 259 + guard let self = self else { return } 260 + let downAt = self.litDownAt.removeValue(forKey: midiNote) ?? 0 261 + let held = CACurrentMediaTime() - downAt 262 + let extinguish = { [weak self] in 263 + guard let self = self else { return } 264 + if self.litNotes.remove(midiNote) != nil { 265 + self.onLitChanged?() 266 + } 267 + } 268 + if held < self.minVisibleSeconds { 269 + DispatchQueue.main.asyncAfter(deadline: .now() + (self.minVisibleSeconds - held)) { 270 + extinguish() 271 + } 272 + } else { 273 + extinguish() 274 + } 275 + } 276 + } 277 + 278 + // MARK: - Permissions 279 + 280 + @discardableResult 281 + private func ensureAccessibility(prompt: Bool) -> Bool { 282 + // Always pass false here so the *system* modal doesn't fire. We show 283 + // our own alert that explains the situation and offers a "Reset grant" 284 + // escape hatch — the common cause of repeated prompts is a stale TCC 285 + // entry from a previous (ad-hoc / unsigned) build whose code signing 286 + // identity no longer matches the current bundle. 287 + let key = kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String 288 + let trusted = AXIsProcessTrustedWithOptions([key: false] as CFDictionary) 289 + if !trusted && prompt { 290 + DispatchQueue.main.async { self.presentAccessibilityAlert() } 291 + } 292 + return trusted 293 + } 294 + 295 + private func presentAccessibilityAlert() { 296 + let alert = NSAlert() 297 + alert.messageText = "MenuBand needs Accessibility access" 298 + alert.informativeText = """ 299 + To capture keystrokes globally, MenuBand must be enabled in \ 300 + System Settings → Privacy & Security → Accessibility. 301 + 302 + If you've already enabled it but this keeps prompting, the TCC \ 303 + entry is likely stale (left over from a previous build). Use \ 304 + "Reset & Re-prompt" to clear it and grant fresh. 305 + """ 306 + alert.alertStyle = .informational 307 + alert.addButton(withTitle: "Open Settings") 308 + alert.addButton(withTitle: "Reset & Re-prompt") 309 + alert.addButton(withTitle: "Cancel") 310 + NSApp.activate(ignoringOtherApps: true) 311 + let resp = alert.runModal() 312 + switch resp { 313 + case .alertFirstButtonReturn: 314 + if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility") { 315 + NSWorkspace.shared.open(url) 316 + } 317 + case .alertSecondButtonReturn: 318 + // Run `tccutil reset Accessibility <bundleID>` and then ask the 319 + // *system* to prompt fresh. This creates a new TCC entry tied to 320 + // our current code-signing identity. 321 + let bundleID = Bundle.main.bundleIdentifier ?? "computer.aestheticcomputer.menuband" 322 + let task = Process() 323 + task.launchPath = "/usr/bin/tccutil" 324 + task.arguments = ["reset", "Accessibility", bundleID] 325 + try? task.run() 326 + task.waitUntilExit() 327 + // Now request the system prompt explicitly. 328 + let key = kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String 329 + _ = AXIsProcessTrustedWithOptions([key: true] as CFDictionary) 330 + default: 331 + break 332 + } 333 + } 334 + 335 + // MARK: - Key handling (runs on KeyEventTap background thread) 336 + // Returns true to CONSUME the key event (sink it from the focused app); 337 + // false to let it pass through. 338 + 339 + private func handleKey(keyCode: UInt16, isDown: Bool, isRepeat: Bool, flags: CGEventFlags) -> Bool { 340 + // Escape exits TYPE mode (and consumes the keystroke). 341 + if isDown && keyCode == 53 { // kVK_Escape 342 + DispatchQueue.main.async { [weak self] in 343 + self?.disableTypeMode(playFeedback: true) 344 + } 345 + return true 346 + } 347 + // Modifier combos pass through so cmd-c, cmd-tab etc. work as usual. 348 + if flags.contains(.maskCommand) || flags.contains(.maskControl) || flags.contains(.maskAlternate) { return false } 349 + 350 + let shift = octaveShift // a single UserDefaults read; cheap 351 + 352 + if isDown { 353 + if isRepeat { return true } // consume repeats but don't retrigger 354 + guard let note = NotepatLayout.midiNote(forKeyCode: keyCode, octaveShift: shift, keymap: keymap) else { 355 + // Unmapped key: still consume in TYPE mode so it doesn't leak 356 + // through to the focused app. 357 + return true 358 + } 359 + heldLock.lock() 360 + let prev = heldNotes[keyCode] 361 + heldNotes[keyCode] = note 362 + heldLock.unlock() 363 + if let prev = prev { 364 + if !midiMode { synth.noteOff(prev) } 365 + midi.noteOff(prev) 366 + } 367 + if !midiMode { synth.noteOn(note) } 368 + midi.noteOn(note) 369 + DispatchQueue.main.async { [weak self] in 370 + guard let self = self else { return } 371 + self.litDownAt[note] = CACurrentMediaTime() 372 + if self.litNotes.insert(note).inserted { 373 + self.onLitChanged?() 374 + } 375 + } 376 + return true 377 + } else { 378 + heldLock.lock() 379 + let note = heldNotes.removeValue(forKey: keyCode) 380 + heldLock.unlock() 381 + guard let releasedNote = note else { return true } // consume the up too 382 + if !midiMode { synth.noteOff(releasedNote) } 383 + midi.noteOff(releasedNote) 384 + DispatchQueue.main.async { [weak self] in 385 + guard let self = self else { return } 386 + let downAt = self.litDownAt.removeValue(forKey: releasedNote) ?? 0 387 + let held = CACurrentMediaTime() - downAt 388 + let extinguish = { [weak self] in 389 + guard let self = self else { return } 390 + if self.litNotes.remove(releasedNote) != nil { 391 + self.onLitChanged?() 392 + } 393 + } 394 + if held < self.minVisibleSeconds { 395 + DispatchQueue.main.asyncAfter(deadline: .now() + (self.minVisibleSeconds - held)) { 396 + extinguish() 397 + } 398 + } else { 399 + extinguish() 400 + } 401 + } 402 + return true 403 + } 404 + } 405 + 406 + private func releaseAllHeldNotes() { 407 + heldLock.lock() 408 + let snapshot = heldNotes 409 + heldNotes.removeAll() 410 + heldLock.unlock() 411 + for (_, note) in snapshot { 412 + synth.noteOff(note) 413 + midi.noteOff(note) 414 + } 415 + midi.sendAllNotesOff() 416 + synth.panic() 417 + DispatchQueue.main.async { [weak self] in 418 + guard let self = self else { return } 419 + if !self.litNotes.isEmpty { 420 + self.litNotes.removeAll() 421 + self.litDownAt.removeAll() 422 + self.onLitChanged?() 423 + } 424 + } 425 + } 426 + }
+166
slab/menuband/Sources/MenuBand/NotepatMIDI.swift
··· 1 + import Foundation 2 + import CoreMIDI 3 + 4 + // Notepat keyboard layout → semitone offset from middle C (C4 = MIDI 60). 5 + // Mirrors NOTE_TO_KEYBOARD_KEY in system/public/aesthetic.computer/disks/notepat.mjs 6 + // "-X" = octave below, "+X" = octave above, "++X" = two octaves above. 7 + // Indexed by Carbon kVK_ANSI_* virtual key codes for zero-allocation lookup. 8 + enum Keymap: String { case notepat, ableton } 9 + 10 + enum NotepatLayout { 11 + // Pre-built dense lookup: index = virtual key code (0-127), value = semitone or Int8.min if unmapped. 12 + static let semitoneByKeyCode: [Int8] = { 13 + var table = [Int8](repeating: Int8.min, count: 128) 14 + // (kVK_ANSI_*, semitone offset from middle C) 15 + let mapping: [(UInt16, Int8)] = [ 16 + (6, -2), // Z -a# 17 + (7, -1), // X -b 18 + (8, 0), // C c 19 + (9, 1), // V c# 20 + (2, 2), // D d 21 + (1, 3), // S d# 22 + (14, 4), // E e 23 + (3, 5), // F f 24 + (13, 6), // W f# 25 + (5, 7), // G g 26 + (15, 8), // R g# 27 + (0, 9), // A a 28 + (12, 10), // Q a# 29 + (11, 11), // B b 30 + (4, 12), // H +c 31 + (17, 13), // T +c# 32 + (34, 14), // I +d 33 + (16, 15), // Y +d# 34 + (38, 16), // J +e 35 + (40, 17), // K +f 36 + (32, 18), // U +f# 37 + (37, 19), // L +g 38 + (31, 20), // O +g# 39 + (46, 21), // M +a 40 + (35, 22), // P +a# 41 + (45, 23), // N +b 42 + (41, 24), // ; ++c 43 + (39, 25), // ' ++c# 44 + (30, 26), // ] ++d 45 + ] 46 + for (kc, st) in mapping { table[Int(kc)] = st } 47 + return table 48 + }() 49 + 50 + /// Ableton Live's qwerty keymap (M to enable in Live). Home row a..k → C..C 51 + /// across an octave; w/e/t/y/u → black keys; o/p add another octave. 52 + static let semitoneByKeyCodeAbleton: [Int8] = { 53 + var t = [Int8](repeating: Int8.min, count: 128) 54 + let mapping: [(UInt16, Int8)] = [ 55 + (0, 0), // A C 56 + (13, 1), // W C# 57 + (1, 2), // S D 58 + (14, 3), // E D# 59 + (2, 4), // D E 60 + (3, 5), // F F 61 + (17, 6), // T F# 62 + (5, 7), // G G 63 + (16, 8), // Y G# 64 + (4, 9), // H A 65 + (32, 10), // U A# 66 + (38, 11), // J B 67 + (40, 12), // K C+1 68 + (31, 13), // O C#+1 69 + (37, 14), // L D+1 70 + (35, 15), // P D#+1 71 + (41, 16), // ; E+1 72 + ] 73 + for (kc, st) in mapping { t[Int(kc)] = st } 74 + return t 75 + }() 76 + 77 + @inline(__always) 78 + static func midiNote(forKeyCode keyCode: UInt16, 79 + octaveShift: Int, 80 + keymap: Keymap = .notepat) -> UInt8? { 81 + guard keyCode < 128 else { return nil } 82 + let table = (keymap == .ableton) ? semitoneByKeyCodeAbleton : semitoneByKeyCode 83 + let semitone = table[Int(keyCode)] 84 + if semitone == Int8.min { return nil } 85 + let value = 60 + Int(semitone) + (octaveShift * 12) 86 + guard value >= 0, value <= 127 else { return nil } 87 + return UInt8(value) 88 + } 89 + } 90 + 91 + final class NotepatMIDI { 92 + private var client: MIDIClientRef = 0 93 + private var source: MIDIEndpointRef = 0 94 + private var started = false 95 + 96 + deinit { stop() } 97 + 98 + func start() { 99 + guard !started else { return } 100 + let clientName = "Notepat" as CFString 101 + let status = MIDIClientCreate(clientName, nil, nil, &client) 102 + guard status == noErr else { 103 + NSLog("Notepat MIDIClientCreate failed: \(status)") 104 + return 105 + } 106 + let sourceName = "Notepat (slab)" as CFString 107 + let srcStatus = MIDISourceCreate(client, sourceName, &source) 108 + guard srcStatus == noErr else { 109 + NSLog("Notepat MIDISourceCreate failed: \(srcStatus)") 110 + MIDIClientDispose(client) 111 + client = 0 112 + return 113 + } 114 + started = true 115 + } 116 + 117 + func stop() { 118 + // Best-effort: send all-notes-off so Ableton doesn't hang on stuck notes. 119 + if started { 120 + sendAllNotesOff() 121 + if source != 0 { MIDIEndpointDispose(source) } 122 + if client != 0 { MIDIClientDispose(client) } 123 + source = 0 124 + client = 0 125 + started = false 126 + } 127 + } 128 + 129 + func noteOn(_ note: UInt8, velocity: UInt8 = 100, channel: UInt8 = 0) { 130 + guard started else { return } 131 + send([0x90 | (channel & 0x0F), note & 0x7F, velocity & 0x7F]) 132 + } 133 + 134 + func noteOff(_ note: UInt8, channel: UInt8 = 0) { 135 + guard started else { return } 136 + send([0x80 | (channel & 0x0F), note & 0x7F, 0]) 137 + } 138 + 139 + /// Send a Control Change message. CC 10 = pan (0=left, 64=center, 127=right). 140 + func sendCC(_ cc: UInt8, value: UInt8, channel: UInt8 = 0) { 141 + guard started else { return } 142 + send([0xB0 | (channel & 0x0F), cc & 0x7F, value & 0x7F]) 143 + } 144 + 145 + func sendAllNotesOff(channel: UInt8 = 0) { 146 + guard started else { return } 147 + // CC 123 = All Notes Off 148 + send([0xB0 | (channel & 0x0F), 123, 0]) 149 + } 150 + 151 + // MARK: - Internal 152 + 153 + private func send(_ bytes: [UInt8]) { 154 + var packetList = MIDIPacketList() 155 + let packet = MIDIPacketListInit(&packetList) 156 + // Timestamp 0 → "deliver as soon as possible, no scheduling." For live 157 + // play this is lower latency than scheduling against mach_absolute_time(). 158 + bytes.withUnsafeBufferPointer { buf in 159 + _ = MIDIPacketListAdd(&packetList, MemoryLayout<MIDIPacketList>.size, packet, 0, bytes.count, buf.baseAddress!) 160 + } 161 + let status = MIDIReceived(source, &packetList) 162 + if status != noErr { 163 + NSLog("Notepat MIDIReceived failed: \(status)") 164 + } 165 + } 166 + }
+356
slab/menuband/Sources/MenuBand/NotepatPopover.swift
··· 1 + import AppKit 2 + 3 + /// Settings popover for the menubar piano. Custom NSViewController with 4 + /// native AppKit controls (NSSwitch / NSPopUpButton / NSStepper) for a 5 + /// richer feel than a plain NSMenu. 6 + final class NotepatPopoverViewController: NSViewController { 7 + weak var notepat: NotepatController? 8 + 9 + private var typeSwitch: NSSwitch! 10 + private var midiSwitch: NSSwitch! 11 + private var instrumentPopUp: NSPopUpButton! 12 + private var octaveStepper: NSStepper! 13 + private var octaveLabel: NSTextField! 14 + private var keymapSegmented: NSSegmentedControl! 15 + 16 + override func loadView() { 17 + // Plain solid-color background — no NSVisualEffectView. The visual 18 + // effect view sampled the surrounding context and shifted appearance 19 + // when focus moved between the menu bar and the popover. A flat 20 + // background keeps the popover homogeneous in all states. 21 + let root = NSView() 22 + root.wantsLayer = true 23 + root.layer?.backgroundColor = NSColor.windowBackgroundColor.cgColor 24 + root.translatesAutoresizingMaskIntoConstraints = false 25 + 26 + // Vertical stack of rows. 27 + let stack = NSStackView() 28 + stack.orientation = .vertical 29 + stack.alignment = .leading 30 + stack.spacing = 10 31 + stack.edgeInsets = NSEdgeInsets(top: 14, left: 16, bottom: 14, right: 16) 32 + stack.translatesAutoresizingMaskIntoConstraints = false 33 + root.addSubview(stack) 34 + 35 + // Title row. 36 + let title = NSTextField(labelWithString: "MenuBand") 37 + title.font = NSFont.systemFont(ofSize: 13, weight: .bold) 38 + title.textColor = .labelColor 39 + stack.addArrangedSubview(title) 40 + 41 + let subtitle = NSTextField(labelWithString: "Built-in macOS instruments, in the menu bar.") 42 + subtitle.font = NSFont.systemFont(ofSize: 10.5) 43 + subtitle.textColor = .secondaryLabelColor 44 + stack.addArrangedSubview(subtitle) 45 + 46 + stack.addArrangedSubview(makeSeparator()) 47 + 48 + // TYPE switch row — shortcut hint in the sublabel so it's right 49 + // next to its target instead of repeated below. 50 + typeSwitch = NSSwitch() 51 + typeSwitch.target = self 52 + typeSwitch.action = #selector(typeSwitchToggled(_:)) 53 + stack.addArrangedSubview(makeSwitchRow( 54 + label: "Capture keystrokes", 55 + sublabel: "⌃⌥⌘P to toggle", 56 + switchControl: typeSwitch 57 + )) 58 + 59 + // MIDI switch row. 60 + midiSwitch = NSSwitch() 61 + midiSwitch.target = self 62 + midiSwitch.action = #selector(midiSwitchToggled(_:)) 63 + stack.addArrangedSubview(makeSwitchRow( 64 + label: "Send MIDI to DAW", 65 + sublabel: "Routes via virtual port", 66 + switchControl: midiSwitch 67 + )) 68 + 69 + stack.addArrangedSubview(makeSeparator()) 70 + 71 + // Instrument popup row. 72 + let instrumentLabel = NSTextField(labelWithString: "Instrument") 73 + instrumentLabel.font = NSFont.systemFont(ofSize: 11, weight: .semibold) 74 + instrumentLabel.textColor = .labelColor 75 + stack.addArrangedSubview(instrumentLabel) 76 + 77 + instrumentPopUp = NSPopUpButton(frame: .zero, pullsDown: false) 78 + instrumentPopUp.target = self 79 + instrumentPopUp.action = #selector(instrumentChanged(_:)) 80 + instrumentPopUp.translatesAutoresizingMaskIntoConstraints = false 81 + rebuildInstrumentMenu() 82 + stack.addArrangedSubview(instrumentPopUp) 83 + instrumentPopUp.widthAnchor.constraint(equalToConstant: 240).isActive = true 84 + 85 + stack.addArrangedSubview(makeSeparator()) 86 + 87 + // Octave row. 88 + let octaveRow = NSStackView() 89 + octaveRow.orientation = .horizontal 90 + octaveRow.alignment = .centerY 91 + octaveRow.spacing = 8 92 + let octaveTitle = NSTextField(labelWithString: "Octave") 93 + octaveTitle.font = NSFont.systemFont(ofSize: 11, weight: .semibold) 94 + octaveTitle.textColor = .labelColor 95 + octaveLabel = NSTextField(labelWithString: "+0") 96 + octaveLabel.font = NSFont.monospacedSystemFont(ofSize: 11, weight: .bold) 97 + octaveLabel.textColor = .labelColor 98 + octaveLabel.alignment = .center 99 + octaveLabel.widthAnchor.constraint(equalToConstant: 28).isActive = true 100 + octaveStepper = NSStepper() 101 + octaveStepper.minValue = -4 102 + octaveStepper.maxValue = 4 103 + octaveStepper.increment = 1 104 + octaveStepper.valueWraps = false 105 + octaveStepper.target = self 106 + octaveStepper.action = #selector(octaveChanged(_:)) 107 + let octaveReset = NSButton(title: "Reset", target: self, action: #selector(resetOctave)) 108 + octaveReset.bezelStyle = .recessed 109 + octaveReset.controlSize = .small 110 + octaveRow.addArrangedSubview(octaveTitle) 111 + octaveRow.addArrangedSubview(octaveLabel) 112 + octaveRow.addArrangedSubview(octaveStepper) 113 + octaveRow.addArrangedSubview(octaveReset) 114 + stack.addArrangedSubview(octaveRow) 115 + 116 + stack.addArrangedSubview(makeSeparator()) 117 + 118 + // Keymap toggle (Notepat / Ableton). 119 + let keymapLabel = NSTextField(labelWithString: "Keymap") 120 + keymapLabel.font = NSFont.systemFont(ofSize: 11, weight: .semibold) 121 + keymapLabel.textColor = .labelColor 122 + stack.addArrangedSubview(keymapLabel) 123 + 124 + keymapSegmented = NSSegmentedControl(labels: ["Notepat", "Ableton"], 125 + trackingMode: .selectOne, 126 + target: self, 127 + action: #selector(keymapChanged(_:))) 128 + keymapSegmented.translatesAutoresizingMaskIntoConstraints = false 129 + stack.addArrangedSubview(keymapSegmented) 130 + keymapSegmented.widthAnchor.constraint(equalToConstant: 240).isActive = true 131 + 132 + stack.addArrangedSubview(makeSeparator()) 133 + 134 + // About — inline rather than a separate dialog. 135 + let aboutTitle = NSTextField(labelWithString: "About") 136 + aboutTitle.font = NSFont.systemFont(ofSize: 11, weight: .semibold) 137 + aboutTitle.textColor = .labelColor 138 + stack.addArrangedSubview(aboutTitle) 139 + 140 + let aboutBody = NSTextField(wrappingLabelWithString: 141 + "A political project to bring the built-in macOS instruments — " + 142 + "the ones GarageBand uses — into the menu bar. Accessible " + 143 + "music-making is as essential as time, network connectivity, " + 144 + "and battery life.") 145 + aboutBody.font = NSFont.systemFont(ofSize: 10.5) 146 + aboutBody.textColor = .secondaryLabelColor 147 + aboutBody.maximumNumberOfLines = 0 148 + aboutBody.preferredMaxLayoutWidth = 248 149 + stack.addArrangedSubview(aboutBody) 150 + 151 + let linksRow = NSStackView() 152 + linksRow.orientation = .horizontal 153 + linksRow.alignment = .centerY 154 + linksRow.spacing = 8 155 + let acLink = NSButton(title: "aesthetic.computer", 156 + target: self, action: #selector(openAesthetic)) 157 + acLink.bezelStyle = .recessed 158 + acLink.controlSize = .small 159 + let npLink = NSButton(title: "notepat.com", 160 + target: self, action: #selector(openNotepat)) 161 + npLink.bezelStyle = .recessed 162 + npLink.controlSize = .small 163 + linksRow.addArrangedSubview(acLink) 164 + linksRow.addArrangedSubview(npLink) 165 + stack.addArrangedSubview(linksRow) 166 + 167 + stack.addArrangedSubview(makeSeparator()) 168 + 169 + // Quit — red stop button so it's visually distinct from the 170 + // toggles/links above. 171 + let quit = NSButton() 172 + quit.title = "Quit MenuBand" 173 + quit.image = NSImage(systemSymbolName: "stop.circle.fill", 174 + accessibilityDescription: "Quit") 175 + quit.imagePosition = .imageLeading 176 + quit.bezelStyle = .recessed 177 + quit.controlSize = .small 178 + quit.contentTintColor = NSColor.systemRed 179 + quit.target = self 180 + quit.action = #selector(quitApp) 181 + let quitTitle = NSAttributedString( 182 + string: "Quit MenuBand", 183 + attributes: [ 184 + .foregroundColor: NSColor.systemRed, 185 + .font: NSFont.systemFont(ofSize: 11, weight: .semibold), 186 + ] 187 + ) 188 + quit.attributedTitle = quitTitle 189 + stack.addArrangedSubview(quit) 190 + 191 + NSLayoutConstraint.activate([ 192 + stack.leadingAnchor.constraint(equalTo: root.leadingAnchor), 193 + stack.trailingAnchor.constraint(equalTo: root.trailingAnchor), 194 + stack.topAnchor.constraint(equalTo: root.topAnchor), 195 + stack.bottomAnchor.constraint(equalTo: root.bottomAnchor), 196 + ]) 197 + 198 + view = root 199 + preferredContentSize = NSSize(width: 280, height: 320) 200 + } 201 + 202 + /// Refresh control state from the controller — call right before showing. 203 + func syncFromController() { 204 + guard isViewLoaded, let n = notepat else { return } 205 + typeSwitch.state = n.typeMode ? .on : .off 206 + midiSwitch.state = n.midiMode ? .on : .off 207 + octaveStepper.integerValue = n.octaveShift 208 + updateOctaveLabel(n.octaveShift) 209 + keymapSegmented.selectedSegment = (n.keymap == .ableton) ? 1 : 0 210 + let target = Int(n.melodicProgram) 211 + for item in instrumentPopUp.itemArray { 212 + if item.tag == target { 213 + instrumentPopUp.select(item) 214 + break 215 + } 216 + } 217 + } 218 + 219 + // MARK: - Builders 220 + 221 + private func makeSeparator() -> NSView { 222 + let box = NSBox() 223 + box.boxType = .separator 224 + return box 225 + } 226 + 227 + private func makeSwitchRow(label: String, 228 + sublabel: String, 229 + switchControl: NSSwitch) -> NSView { 230 + let row = NSStackView() 231 + row.orientation = .horizontal 232 + row.alignment = .centerY 233 + row.spacing = 10 234 + row.distribution = .fill 235 + 236 + let labelStack = NSStackView() 237 + labelStack.orientation = .vertical 238 + labelStack.alignment = .leading 239 + labelStack.spacing = 1 240 + 241 + let title = NSTextField(labelWithString: label) 242 + title.font = NSFont.systemFont(ofSize: 12, weight: .semibold) 243 + title.textColor = .labelColor 244 + 245 + let sub = NSTextField(labelWithString: sublabel) 246 + sub.font = NSFont.systemFont(ofSize: 10) 247 + sub.textColor = .secondaryLabelColor 248 + 249 + labelStack.addArrangedSubview(title) 250 + labelStack.addArrangedSubview(sub) 251 + 252 + row.addArrangedSubview(labelStack) 253 + let spacer = NSView() 254 + spacer.setContentHuggingPriority(.defaultLow, for: .horizontal) 255 + row.addArrangedSubview(spacer) 256 + row.addArrangedSubview(switchControl) 257 + return row 258 + } 259 + 260 + private func rebuildInstrumentMenu() { 261 + let menu = NSMenu() 262 + for (familyName, range) in GeneralMIDI.families { 263 + let parent = NSMenuItem(title: familyName, action: nil, keyEquivalent: "") 264 + let sub = NSMenu() 265 + for prog in range { 266 + let title = String(format: "%03d %@", prog, GeneralMIDI.programNames[prog]) 267 + let item = NSMenuItem(title: title, action: nil, keyEquivalent: "") 268 + item.tag = prog 269 + sub.addItem(item) 270 + } 271 + parent.submenu = sub 272 + menu.addItem(parent) 273 + } 274 + // Flatten — NSPopUpButton displays leaf selections better when the 275 + // popUp menu has flat items. Build a flat menu instead with section 276 + // headers. 277 + let flat = NSMenu() 278 + for (familyName, range) in GeneralMIDI.families { 279 + let header = NSMenuItem(title: familyName.uppercased(), action: nil, keyEquivalent: "") 280 + header.isEnabled = false 281 + flat.addItem(header) 282 + for prog in range { 283 + let title = String(format: " %03d %@", prog, GeneralMIDI.programNames[prog]) 284 + let item = NSMenuItem(title: title, action: nil, keyEquivalent: "") 285 + item.tag = prog 286 + flat.addItem(item) 287 + } 288 + } 289 + instrumentPopUp.menu = flat 290 + } 291 + 292 + private func updateOctaveLabel(_ shift: Int) { 293 + let s: String 294 + if shift == 0 { s = "0" } 295 + else if shift > 0 { s = "+\(shift)" } 296 + else { s = "\(shift)" } 297 + octaveLabel.stringValue = s 298 + } 299 + 300 + // MARK: - Actions 301 + 302 + @objc private func typeSwitchToggled(_ sender: NSSwitch) { 303 + notepat?.toggleTypeMode() 304 + // Re-read in case Accessibility prompt cancelled. 305 + syncFromController() 306 + } 307 + 308 + @objc private func midiSwitchToggled(_ sender: NSSwitch) { 309 + notepat?.toggleMIDIMode() 310 + syncFromController() 311 + } 312 + 313 + @objc private func instrumentChanged(_ sender: NSPopUpButton) { 314 + guard let item = sender.selectedItem, item.tag >= 0 else { return } 315 + notepat?.setMelodicProgram(UInt8(item.tag)) 316 + } 317 + 318 + @objc private func octaveChanged(_ sender: NSStepper) { 319 + notepat?.octaveShift = sender.integerValue 320 + updateOctaveLabel(sender.integerValue) 321 + } 322 + 323 + @objc private func resetOctave() { 324 + notepat?.octaveShift = 0 325 + octaveStepper.integerValue = 0 326 + updateOctaveLabel(0) 327 + } 328 + 329 + @objc private func keymapChanged(_ sender: NSSegmentedControl) { 330 + notepat?.keymap = (sender.selectedSegment == 1) ? .ableton : .notepat 331 + } 332 + 333 + @objc private func openAesthetic() { 334 + if let url = URL(string: "https://aesthetic.computer") { 335 + NSWorkspace.shared.open(url) 336 + } 337 + } 338 + 339 + @objc private func openNotepat() { 340 + if let url = URL(string: "https://notepat.com") { 341 + NSWorkspace.shared.open(url) 342 + } 343 + } 344 + 345 + @objc private func quitApp() { 346 + // Unload our LaunchAgent first so launchd doesn't immediately 347 + // relaunch us, then terminate. 348 + let plist = "\(NSHomeDirectory())/Library/LaunchAgents/computer.aestheticcomputer.menuband.plist" 349 + let task = Process() 350 + task.launchPath = "/bin/launchctl" 351 + task.arguments = ["unload", plist] 352 + try? task.run() 353 + task.waitUntilExit() 354 + NSApp.terminate(nil) 355 + } 356 + }
+126
slab/menuband/Sources/MenuBand/NotepatSynth.swift
··· 1 + import Foundation 2 + import AVFoundation 3 + 4 + // Built-in soft-synth using Apple's bundled GM DLS sound bank 5 + // (`gs_instruments.dls`, shipped with every macOS install inside 6 + // CoreAudio.component). Real piano and drum-kit samples — not the bare 7 + // AVAudioUnitSampler default, which is a sine-ish placeholder. 8 + // 9 + // Two samplers are kept: one melodic (channel 0 — piano by default), one 10 + // percussion (channel 9 — GM drum kit). NotepatController routes drum notes 11 + // to the drum sampler. 12 + final class NotepatSynth { 13 + private let engine = AVAudioEngine() 14 + private let melodic = AVAudioUnitSampler() 15 + private let drums = AVAudioUnitSampler() 16 + private var started = false 17 + 18 + // Apple's DLS bank — present on every macOS install since 10.x. 19 + private static let bankURL = URL( 20 + fileURLWithPath: "/System/Library/Components/CoreAudio.component/Contents/Resources/gs_instruments.dls" 21 + ) 22 + 23 + func start() { 24 + guard !started else { return } 25 + engine.attach(melodic) 26 + engine.attach(drums) 27 + engine.connect(melodic, to: engine.mainMixerNode, format: nil) 28 + engine.connect(drums, to: engine.mainMixerNode, format: nil) 29 + engine.prepare() // pre-allocate buffers before .start() 30 + do { 31 + try engine.start() 32 + started = true 33 + } catch { 34 + NSLog("Notepat synth engine start failed: \(error)") 35 + return 36 + } 37 + loadDefaultPatches() 38 + primeForLowLatency() 39 + } 40 + 41 + /// Plays inaudible velocity-1 notes through both samplers immediately 42 + /// after start(). This forces sample-bank loads and audio-thread warmup 43 + /// to happen NOW instead of on the user's first real tap, so fast taps 44 + /// trigger sound with no perceptible delay. 45 + private func primeForLowLatency() { 46 + // Warmup notes: a few across the range so the sampler caches more of 47 + // its sample map. Velocity 1 is essentially silent. 48 + let melodicWarmup: [UInt8] = [60, 64, 67, 72] 49 + let drumWarmup: [UInt8] = [36, 38, 42, 46] 50 + for n in melodicWarmup { 51 + melodic.startNote(n, withVelocity: 1, onChannel: 0) 52 + } 53 + for n in drumWarmup { 54 + drums.startNote(n, withVelocity: 1, onChannel: 0) 55 + } 56 + // Stop them on the next run loop tick so the engine actually 57 + // schedules the start side first. 58 + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in 59 + guard let self = self else { return } 60 + for n in melodicWarmup { self.melodic.stopNote(n, onChannel: 0) } 61 + for n in drumWarmup { self.drums.stopNote(n, onChannel: 0) } 62 + } 63 + } 64 + 65 + private func loadDefaultPatches() { 66 + let url = NotepatSynth.bankURL 67 + guard FileManager.default.fileExists(atPath: url.path) else { 68 + NSLog("Notepat: gs_instruments.dls not found — falling back to default sampler tone") 69 + return 70 + } 71 + // CoreAudio's DLS bank uses Roland's GS conventions: 72 + // melodic instruments — bankMSB = 0x79 (kAUSampler_DefaultMelodicBankMSB) 73 + // percussion — bankMSB = 0x78 (kAUSampler_DefaultPercussionBankMSB) 74 + // Program 0 on each = the standard kit / acoustic grand piano. 75 + let melodicMSB: UInt8 = 0x79 76 + let percussionMSB: UInt8 = 0x78 77 + let lsb: UInt8 = 0 78 + do { 79 + try melodic.loadSoundBankInstrument(at: url, program: 0, bankMSB: melodicMSB, bankLSB: lsb) 80 + } catch { 81 + NSLog("Notepat: melodic patch load failed: \(error)") 82 + } 83 + do { 84 + try drums.loadSoundBankInstrument(at: url, program: 0, bankMSB: percussionMSB, bankLSB: lsb) 85 + } catch { 86 + NSLog("Notepat: drum kit load failed: \(error)") 87 + } 88 + } 89 + 90 + /// Switch the melodic sampler to a different GM program (0–127). 91 + /// Examples: 0=piano, 4=electric piano, 24=nylon guitar, 32=acoustic bass, 92 + /// 40=violin, 48=string ensemble, 56=trumpet, 73=flute, 80=square lead. 93 + func setMelodicProgram(_ program: UInt8) { 94 + guard started else { return } 95 + let url = NotepatSynth.bankURL 96 + guard FileManager.default.fileExists(atPath: url.path) else { return } 97 + try? melodic.loadSoundBankInstrument(at: url, program: program, bankMSB: 0x79, bankLSB: 0) 98 + } 99 + 100 + func stop() { 101 + guard started else { return } 102 + engine.stop() 103 + started = false 104 + } 105 + 106 + func noteOn(_ midi: UInt8, velocity: UInt8 = 100, channel: UInt8 = 0) { 107 + guard started else { return } 108 + // Each AVAudioUnitSampler is itself single-channel; we pick which 109 + // sampler based on the *requested* channel. 110 + let unit = (channel == 9) ? drums : melodic 111 + unit.startNote(midi, withVelocity: velocity, onChannel: 0) 112 + } 113 + 114 + func noteOff(_ midi: UInt8, channel: UInt8 = 0) { 115 + guard started else { return } 116 + let unit = (channel == 9) ? drums : melodic 117 + unit.stopNote(midi, onChannel: 0) 118 + } 119 + 120 + func panic() { 121 + guard started else { return } 122 + for unit in [melodic, drums] { 123 + for note: UInt8 in 0...127 { unit.stopNote(note, onChannel: 0) } 124 + } 125 + } 126 + }
+7
slab/menuband/Sources/MenuBand/main.swift
··· 1 + import AppKit 2 + 3 + let app = NSApplication.shared 4 + let delegate = AppDelegate() 5 + app.delegate = delegate 6 + app.setActivationPolicy(.accessory) // menubar-only, no Dock icon 7 + app.run()
+29
slab/menuband/computer.aestheticcomputer.menuband.plist.tmpl
··· 1 + <?xml version="1.0" encoding="UTF-8"?> 2 + <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> 3 + <plist version="1.0"> 4 + <dict> 5 + <key>Label</key> 6 + <string>computer.aestheticcomputer.menuband</string> 7 + <key>ProgramArguments</key> 8 + <array> 9 + <string>@HOME@/Applications/MenuBand.app/Contents/MacOS/MenuBand</string> 10 + </array> 11 + <key>EnvironmentVariables</key> 12 + <dict> 13 + <key>HOME</key> 14 + <string>@HOME@</string> 15 + <key>PATH</key> 16 + <string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string> 17 + </dict> 18 + <key>RunAtLoad</key> 19 + <true/> 20 + <key>KeepAlive</key> 21 + <true/> 22 + <key>ThrottleInterval</key> 23 + <integer>5</integer> 24 + <key>StandardOutPath</key> 25 + <string>/tmp/menuband.out</string> 26 + <key>StandardErrorPath</key> 27 + <string>/tmp/menuband.err</string> 28 + </dict> 29 + </plist>
+169
slab/menuband/install.sh
··· 1 + #!/usr/bin/env bash 2 + # install.sh — build, sign, and install MenuBand.app. 3 + # 4 + # Steps: 5 + # 1. swift build -c release 6 + # 2. Wrap binary in a proper .app bundle at ~/Applications/MenuBand.app 7 + # 3. Sign with stable identity (Apple Developer ID if available, else 8 + # self-signed; configurable via SIGN_IDENTITY env) 9 + # 4. Install + load LaunchAgent so MenuBand auto-starts at login 10 + # 11 + # Idempotent. Safe to re-run after edits. 12 + 13 + set -euo pipefail 14 + 15 + BOLD=$'\033[1m' 16 + CYAN=$'\033[1;36m' 17 + GREEN=$'\033[1;32m' 18 + YELLOW=$'\033[1;33m' 19 + RESET=$'\033[0m' 20 + 21 + SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" 22 + REPO_HOME="${HOME}" 23 + LAUNCH_AGENTS="${REPO_HOME}/Library/LaunchAgents" 24 + PLIST_PATH="${LAUNCH_AGENTS}/computer.aestheticcomputer.menuband.plist" 25 + PLIST_TMPL="${SCRIPT_DIR}/computer.aestheticcomputer.menuband.plist.tmpl" 26 + INFO_PLIST="${SCRIPT_DIR}/Info.plist" 27 + APP_DIR="${REPO_HOME}/Applications/MenuBand.app" 28 + APP_BIN_DIR="${APP_DIR}/Contents/MacOS" 29 + APP_BIN="${APP_BIN_DIR}/MenuBand" 30 + APP_RES="${APP_DIR}/Contents/Resources" 31 + 32 + say() { printf "%s• %s%s\n" "$CYAN" "$1" "$RESET"; } 33 + ok() { printf "%s✓ %s%s\n" "$GREEN" "$1" "$RESET"; } 34 + warn(){ printf "%s! %s%s\n" "$YELLOW" "$1" "$RESET"; } 35 + 36 + command -v swift >/dev/null 2>&1 || { 37 + echo "swift not found — install Xcode Command Line Tools first:" 38 + echo " xcode-select --install" 39 + exit 1 40 + } 41 + 42 + # Choose signing identity. If SIGN_IDENTITY is set in env, use it. Otherwise 43 + # look for a Developer ID Application or Mac App Distribution cert in the 44 + # keychain. Falls back to a self-signed local identity. 45 + SELF_SIGN_CN="MenuBand Self-Signed" 46 + 47 + discover_identity() { 48 + local kc="${HOME}/Library/Keychains/login.keychain-db" 49 + if [[ -n "${SIGN_IDENTITY:-}" ]]; then 50 + echo "${SIGN_IDENTITY}" 51 + return 52 + fi 53 + # Prefer Apple-issued certs. 54 + for prefix in "Developer ID Application" "Apple Distribution" "3rd Party Mac Developer Application" "Mac Developer"; do 55 + local found 56 + found="$(security find-identity -v -p codesigning "${kc}" 2>/dev/null | awk -F\" -v p="${prefix}" '$0 ~ p {print $2; exit}')" 57 + if [[ -n "${found}" ]]; then 58 + echo "${found}" 59 + return 60 + fi 61 + done 62 + # Self-signed fallback. 63 + if security find-certificate -c "${SELF_SIGN_CN}" "${kc}" >/dev/null 2>&1; then 64 + echo "${SELF_SIGN_CN}" 65 + return 66 + fi 67 + echo "" 68 + } 69 + 70 + ensure_self_signed_identity() { 71 + local kc="${HOME}/Library/Keychains/login.keychain-db" 72 + if security find-certificate -c "${SELF_SIGN_CN}" "${kc}" >/dev/null 2>&1; then 73 + return 0 74 + fi 75 + say "creating self-signed code signing identity '${SELF_SIGN_CN}' (one-time)" 76 + local tmpdir 77 + tmpdir="$(mktemp -d)" 78 + cat > "${tmpdir}/openssl.cnf" <<EOF 79 + [req] 80 + distinguished_name = dn 81 + prompt = no 82 + [dn] 83 + CN = ${SELF_SIGN_CN} 84 + [v3_ext] 85 + keyUsage = critical,digitalSignature 86 + extendedKeyUsage = critical,codeSigning 87 + basicConstraints = critical,CA:FALSE 88 + EOF 89 + openssl req -x509 -newkey rsa:2048 -nodes -days 36500 \ 90 + -keyout "${tmpdir}/key.pem" -out "${tmpdir}/cert.pem" \ 91 + -config "${tmpdir}/openssl.cnf" -extensions v3_ext >/dev/null 2>&1 || { 92 + rm -rf "${tmpdir}"; warn "openssl req failed"; return 1; } 93 + local pwd="menuband-import-$$" 94 + openssl pkcs12 -export -macalg sha1 -keypbe PBE-SHA1-3DES -certpbe PBE-SHA1-3DES \ 95 + -out "${tmpdir}/bundle.p12" -inkey "${tmpdir}/key.pem" -in "${tmpdir}/cert.pem" \ 96 + -name "${SELF_SIGN_CN}" -passout "pass:${pwd}" >/dev/null 2>&1 || { 97 + rm -rf "${tmpdir}"; warn "pkcs12 export failed"; return 1; } 98 + security import "${tmpdir}/bundle.p12" -k "${kc}" -P "${pwd}" \ 99 + -T /usr/bin/codesign -T /usr/bin/security >/dev/null 2>&1 || { 100 + rm -rf "${tmpdir}"; warn "keychain import failed"; return 1; } 101 + rm -rf "${tmpdir}" 102 + ok "self-signed identity created" 103 + } 104 + 105 + say "building MenuBand (swift build -c release)" 106 + cd "${SCRIPT_DIR}" 107 + swift build -c release >/dev/null 108 + 109 + BUILT="$(swift build -c release --show-bin-path)/MenuBand" 110 + if [[ ! -x "${BUILT}" ]]; then 111 + echo "build succeeded but binary not found at ${BUILT}" 112 + exit 1 113 + fi 114 + ok "built: ${BUILT}" 115 + 116 + say "unloading any existing MenuBand launch agent" 117 + if launchctl list | grep -q computer.aestheticcomputer.menuband; then 118 + launchctl unload "${PLIST_PATH}" 2>/dev/null || true 119 + ok "unloaded computer.aestheticcomputer.menuband" 120 + else 121 + warn "no running MenuBand to unload — skipping" 122 + fi 123 + 124 + say "installing app bundle → ${APP_DIR}" 125 + mkdir -p "${APP_BIN_DIR}" "${APP_RES}" 126 + cp "${BUILT}" "${APP_BIN}" 127 + chmod +x "${APP_BIN}" 128 + cp "${INFO_PLIST}" "${APP_DIR}/Contents/Info.plist" 129 + if [[ -f "${SCRIPT_DIR}/AppIcon.icns" ]]; then 130 + cp "${SCRIPT_DIR}/AppIcon.icns" "${APP_RES}/AppIcon.icns" 131 + fi 132 + 133 + # Sign with the best available identity. 134 + SIGN_ID="$(discover_identity)" 135 + if [[ -z "${SIGN_ID}" ]]; then 136 + ensure_self_signed_identity 137 + SIGN_ID="${SELF_SIGN_CN}" 138 + fi 139 + say "signing with: ${SIGN_ID}" 140 + codesign --force --deep --sign "${SIGN_ID}" \ 141 + --identifier computer.aestheticcomputer.menuband \ 142 + "${APP_DIR}" >/dev/null 2>&1 || warn "codesign failed" 143 + ok "signed" 144 + 145 + say "writing launchd plist → ${PLIST_PATH}" 146 + mkdir -p "${LAUNCH_AGENTS}" 147 + sed "s|@HOME@|${REPO_HOME}|g" "${PLIST_TMPL}" > "${PLIST_PATH}" 148 + ok "plist written" 149 + 150 + say "loading launch agent" 151 + launchctl load "${PLIST_PATH}" 152 + sleep 1 153 + if launchctl list | grep -q computer.aestheticcomputer.menuband; then 154 + ok "computer.aestheticcomputer.menuband is running" 155 + else 156 + warn "launchctl did not register the agent — check /tmp/menuband.err" 157 + fi 158 + 159 + printf "\n%sdone.%s\n" "${BOLD}" "${RESET}" 160 + echo " bundle: ${APP_DIR}" 161 + echo " binary: ${APP_BIN}" 162 + echo " plist: ${PLIST_PATH}" 163 + echo " signed by: ${SIGN_ID}" 164 + echo " stdout: /tmp/menuband.out" 165 + echo " stderr: /tmp/menuband.err" 166 + echo 167 + echo " rebuild+reload after edits: ./install.sh" 168 + echo " override signing identity: SIGN_IDENTITY=\"...\" ./install.sh" 169 + echo " tail logs: tail -f /tmp/menuband.err"
+75
slab/menuband/notarize.sh
··· 1 + #!/usr/bin/env bash 2 + # notarize.sh — submit MenuBand.app to Apple's notary service and staple 3 + # the ticket to the bundle so Gatekeeper accepts it offline. 4 + # 5 + # Prerequisites: 6 + # 1. The .app must already be code-signed with a Developer ID Application 7 + # certificate AND with hardened runtime + the entitlements file. The 8 + # install.sh now does this automatically when SIGN_IDENTITY is set or 9 + # when a Developer ID is found in the keychain. 10 + # 2. Apple ID + Team ID + an app-specific password. 11 + # The vault has the app-specific password at: 12 + # ~/aesthetic-computer/aesthetic-computer-vault/apple/app-specific-password.env.gpg 13 + # Decrypt it once with `devault.fish` (or by hand) and source the 14 + # resulting variable into your shell env, OR pass via the env vars 15 + # below. Required env: 16 + # APPLE_ID — your Apple Developer email (e.g. me@jas.life) 17 + # APPLE_TEAM_ID — 10-char team identifier from developer.apple.com 18 + # APPLE_APP_PASSWORD — app-specific password 19 + # 3. Xcode CLT installed (provides notarytool + stapler). 20 + # 21 + # Usage: 22 + # ./notarize.sh # uses ~/Applications/MenuBand.app 23 + # ./notarize.sh path/to/MyApp.app 24 + 25 + set -euo pipefail 26 + 27 + CYAN=$'\033[1;36m' 28 + GREEN=$'\033[1;32m' 29 + RED=$'\033[1;31m' 30 + RESET=$'\033[0m' 31 + say() { printf "%s• %s%s\n" "$CYAN" "$1" "$RESET"; } 32 + ok() { printf "%s✓ %s%s\n" "$GREEN" "$1" "$RESET"; } 33 + err() { printf "%s✗ %s%s\n" "$RED" "$1" "$RESET"; } 34 + 35 + APP="${1:-${HOME}/Applications/MenuBand.app}" 36 + if [[ ! -d "${APP}" ]]; then 37 + err "app bundle not found at ${APP}" 38 + exit 1 39 + fi 40 + 41 + : "${APPLE_ID:?APPLE_ID env var is required}" 42 + : "${APPLE_TEAM_ID:?APPLE_TEAM_ID env var is required}" 43 + : "${APPLE_APP_PASSWORD:?APPLE_APP_PASSWORD env var is required}" 44 + 45 + WORK="$(mktemp -d)" 46 + trap "rm -rf ${WORK}" EXIT 47 + ZIP="${WORK}/MenuBand.zip" 48 + 49 + say "verifying signature + hardened runtime" 50 + if ! codesign -dv --verbose=2 "${APP}" 2>&1 | grep -q "flags=.*runtime"; then 51 + err "bundle is not signed with hardened runtime — re-run install.sh with a Developer ID first" 52 + exit 1 53 + fi 54 + codesign -dv --verbose=2 "${APP}" 2>&1 | head -10 55 + 56 + say "zipping bundle for upload" 57 + ditto -c -k --keepParent "${APP}" "${ZIP}" 58 + ok "zipped: $(du -h "${ZIP}" | awk '{print $1}')" 59 + 60 + say "submitting to notary service (this can take a minute or two)" 61 + xcrun notarytool submit "${ZIP}" \ 62 + --apple-id "${APPLE_ID}" \ 63 + --team-id "${APPLE_TEAM_ID}" \ 64 + --password "${APPLE_APP_PASSWORD}" \ 65 + --wait 66 + 67 + ok "notarization accepted" 68 + 69 + say "stapling ticket to bundle" 70 + xcrun stapler staple "${APP}" 71 + xcrun stapler validate "${APP}" 72 + ok "stapled — bundle now passes Gatekeeper offline" 73 + 74 + echo 75 + echo "Distribute by zipping or DMG-ing the bundle. Try: ./dmg.sh"
+30
slab/menubar-swift/Info.plist
··· 1 + <?xml version="1.0" encoding="UTF-8"?> 2 + <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> 3 + <plist version="1.0"> 4 + <dict> 5 + <key>CFBundleIdentifier</key> 6 + <string>computer.slab.menubar</string> 7 + <key>CFBundleExecutable</key> 8 + <string>slab-menubar</string> 9 + <key>CFBundleName</key> 10 + <string>SlabMenubar</string> 11 + <key>CFBundleDisplayName</key> 12 + <string>Slab Menubar</string> 13 + <key>CFBundlePackageType</key> 14 + <string>APPL</string> 15 + <key>CFBundleVersion</key> 16 + <string>1</string> 17 + <key>CFBundleShortVersionString</key> 18 + <string>1.0</string> 19 + <key>CFBundleInfoDictionaryVersion</key> 20 + <string>6.0</string> 21 + <key>CFBundleIconFile</key> 22 + <string>AppIcon</string> 23 + <key>LSMinimumSystemVersion</key> 24 + <string>11.0</string> 25 + <key>LSUIElement</key> 26 + <true/> 27 + <key>NSHighResolutionCapable</key> 28 + <true/> 29 + </dict> 30 + </plist>
+1
slab/menubar-swift/Sources/SlabMenubar/MenuBuilder.swift
··· 88 88 parent.submenu = sub 89 89 return parent 90 90 } 91 + 91 92 }
+1 -1
slab/menubar-swift/computer.slab.menubar.plist.tmpl
··· 6 6 <string>computer.slab.menubar</string> 7 7 <key>ProgramArguments</key> 8 8 <array> 9 - <string>@HOME@/.local/bin/slab-menubar</string> 9 + <string>@HOME@/Applications/SlabMenubar.app/Contents/MacOS/slab-menubar</string> 10 10 </array> 11 11 <key>EnvironmentVariables</key> 12 12 <dict>
+105 -8
slab/menubar-swift/install.sh
··· 23 23 LAUNCH_AGENTS="${REPO_HOME}/Library/LaunchAgents" 24 24 PLIST_PATH="${LAUNCH_AGENTS}/computer.slab.menubar.plist" 25 25 PLIST_TMPL="${SCRIPT_DIR}/computer.slab.menubar.plist.tmpl" 26 - BIN_DIR="${REPO_HOME}/.local/bin" 27 - BIN_PATH="${BIN_DIR}/slab-menubar" 26 + INFO_PLIST="${SCRIPT_DIR}/Info.plist" 27 + APPS_DIR="${REPO_HOME}/Applications" 28 + APP_DIR="${APPS_DIR}/SlabMenubar.app" 29 + APP_BIN_DIR="${APP_DIR}/Contents/MacOS" 30 + APP_BIN="${APP_BIN_DIR}/slab-menubar" 31 + LEGACY_BIN="${REPO_HOME}/.local/bin/slab-menubar" 28 32 29 33 say() { printf "%s• %s%s\n" "$CYAN" "$1" "$RESET"; } 30 34 ok() { printf "%s✓ %s%s\n" "$GREEN" "$1" "$RESET"; } ··· 36 40 exit 1 37 41 } 38 42 43 + SIGN_CN="Slab Menubar Self-Signed" 44 + SIGN_KEYCHAIN="${HOME}/Library/Keychains/login.keychain-db" 45 + 46 + ensure_signing_identity() { 47 + # find-certificate works for self-signed certs even if untrusted, unlike 48 + # `find-identity -p codesigning` which filters by trust. 49 + if security find-certificate -c "${SIGN_CN}" "${SIGN_KEYCHAIN}" >/dev/null 2>&1; then 50 + return 0 51 + fi 52 + say "creating self-signed code signing identity '${SIGN_CN}' (one-time)" 53 + local tmpdir 54 + tmpdir="$(mktemp -d)" 55 + cat > "${tmpdir}/openssl.cnf" <<EOF 56 + [req] 57 + distinguished_name = dn 58 + prompt = no 59 + [dn] 60 + CN = ${SIGN_CN} 61 + [v3_ext] 62 + keyUsage = critical,digitalSignature 63 + extendedKeyUsage = critical,codeSigning 64 + basicConstraints = critical,CA:FALSE 65 + EOF 66 + if ! openssl req -x509 -newkey rsa:2048 -nodes -days 36500 \ 67 + -keyout "${tmpdir}/key.pem" \ 68 + -out "${tmpdir}/cert.pem" \ 69 + -config "${tmpdir}/openssl.cnf" \ 70 + -extensions v3_ext >/dev/null 2>&1; then 71 + warn "openssl req failed; will fall back to ad-hoc signing" 72 + rm -rf "${tmpdir}" 73 + return 1 74 + fi 75 + # Apple's Security framework PKCS12 importer needs SHA1-MAC + 3DES PBE, 76 + # AND a non-empty password (empty pass triggers MAC verification quirks 77 + # with OpenSSL 3 even with the legacy MAC algorithm). 78 + local p12pass="slab-import-$$" 79 + if ! openssl pkcs12 -export \ 80 + -macalg sha1 -keypbe PBE-SHA1-3DES -certpbe PBE-SHA1-3DES \ 81 + -out "${tmpdir}/bundle.p12" \ 82 + -inkey "${tmpdir}/key.pem" \ 83 + -in "${tmpdir}/cert.pem" \ 84 + -name "${SIGN_CN}" \ 85 + -passout "pass:${p12pass}" >/dev/null 2>&1; then 86 + warn "pkcs12 export failed; will fall back to ad-hoc signing" 87 + rm -rf "${tmpdir}" 88 + return 1 89 + fi 90 + if ! security import "${tmpdir}/bundle.p12" \ 91 + -k "${SIGN_KEYCHAIN}" \ 92 + -P "${p12pass}" \ 93 + -T /usr/bin/codesign \ 94 + -T /usr/bin/security >/dev/null 2>&1; then 95 + warn "keychain import failed; will fall back to ad-hoc signing" 96 + rm -rf "${tmpdir}" 97 + return 1 98 + fi 99 + rm -rf "${tmpdir}" 100 + ok "code signing identity created" 101 + warn "first signing may prompt for keychain access — click 'Always Allow'" 102 + return 0 103 + } 104 + 39 105 say "building slab-menubar (swift build -c release)" 40 106 cd "${SCRIPT_DIR}" 41 107 swift build -c release >/dev/null ··· 55 121 warn "no running menubar to unload — skipping" 56 122 fi 57 123 58 - say "installing binary → ${BIN_PATH}" 59 - mkdir -p "${BIN_DIR}" 60 - cp "${BUILT}" "${BIN_PATH}" 61 - chmod +x "${BIN_PATH}" 62 - ok "binary installed" 124 + say "installing app bundle → ${APP_DIR}" 125 + mkdir -p "${APP_BIN_DIR}" 126 + mkdir -p "${APP_DIR}/Contents/Resources" 127 + cp "${BUILT}" "${APP_BIN}" 128 + chmod +x "${APP_BIN}" 129 + cp "${INFO_PLIST}" "${APP_DIR}/Contents/Info.plist" 130 + if [[ -f "${SCRIPT_DIR}/AppIcon.icns" ]]; then 131 + cp "${SCRIPT_DIR}/AppIcon.icns" "${APP_DIR}/Contents/Resources/AppIcon.icns" 132 + fi 133 + 134 + # Sign the bundle. Prefer a stable self-signed certificate so the TCC 135 + # Accessibility grant survives rebuilds (ad-hoc embeds the cdhash in the 136 + # designated requirement, which changes every build). 137 + SIGN_OK=0 138 + if ensure_signing_identity; then 139 + if codesign --force --deep --sign "${SIGN_CN}" \ 140 + --identifier computer.slab.menubar \ 141 + "${APP_DIR}" >/dev/null 2>&1; then 142 + SIGN_OK=1 143 + ok "signed with '${SIGN_CN}' (stable identity — TCC grant should persist)" 144 + else 145 + warn "codesign with '${SIGN_CN}' failed; falling back to ad-hoc" 146 + fi 147 + fi 148 + if [[ "${SIGN_OK}" -eq 0 ]]; then 149 + codesign --force --deep --sign - \ 150 + --identifier computer.slab.menubar \ 151 + "${APP_DIR}" >/dev/null 2>&1 || warn "ad-hoc codesign also failed" 152 + fi 153 + ok "app bundle installed" 154 + 155 + if [[ -e "${LEGACY_BIN}" ]]; then 156 + rm -f "${LEGACY_BIN}" 157 + warn "removed legacy binary at ${LEGACY_BIN} — you may want to clear its old Accessibility entry in System Settings" 158 + fi 63 159 64 160 say "writing launchd plist → ${PLIST_PATH}" 65 161 mkdir -p "${LAUNCH_AGENTS}" ··· 76 172 fi 77 173 78 174 printf "\n%sdone.%s\n" "${BOLD}" "${RESET}" 79 - echo " binary: ${BIN_PATH}" 175 + echo " bundle: ${APP_DIR}" 176 + echo " binary: ${APP_BIN}" 80 177 echo " plist: ${PLIST_PATH}" 81 178 echo " stdout: /tmp/slab-menubar.out" 82 179 echo " stderr: /tmp/slab-menubar.err"