Monorepo for Aesthetic.Computer aesthetic.computer
4
fork

Configure Feed

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

slab/menuband: MIDI default off, update banner, MongoDB crash logs, Notepat.com

Defaults:
- Fresh installs no longer auto-enable MIDI mode. Most users hear the
built-in synth first; DAW routing is the opt-in path. Existing users'
choice is preserved via the UserDefaults presence-check (only the
fallback default for first-launch changes).

Update banner:
- New UpdateChecker.swift fetches `latest.json` from
assets.aesthetic.computer/menuband/, compares against the bundled
CFBundleShortVersionString via dotted-integer ordering. Caches 1h.
- Popover shows a Tahoe-tinted banner under the subtitle when newer is
available, with a button that opens aesthetic.computer/menuband.
- Manifest at assets.aesthetic.computer/menuband/latest.json (DO Spaces,
hosted via the existing assets sync — bump its `version` field to
trigger the banner on next release).

Crash logs → MongoDB:
- New netlify function `menuband-logs.mjs` stores POSTed crash reports
in the `menuband-logs` collection (mirrors `os-install-reports`). Doc
shape: {receivedAt, filename, version, bytes, log, meta}. 5 MB body
cap, sanitized filename, no auth.
- lith/server.mjs routes `/menuband-logs` through `directFn` with an
inline `express.text({type:"*/*", limit:"5mb"})` body parser, so
event.body is the raw .ips text. Dropped the previous filesystem
write + console.log entirely — nothing hits lith stdout.

UI copy:
- Input segmented control middle label: "Notepat" → "Notepat.com" (the
domain it points users to, not the app name).

Re-deployed:
- App + DMG re-notarized + stapled. DMG submission
61c140ed-75d1-499f-bc8b-4ee771f531d7. md5
2b9d32b598f82a916be953620e741d71. Page download URL bumped to
?v=2b9d32b for cache-bust.

User-side install:
- Removed legacy ~/Applications/MenuBand.app (without space).
- Reinstalled fresh ~/Applications/Menu Band.app via install.sh.
- Reset notepat.midiMode UserDefault to false so this user's running
app reflects the new off-by-default behavior.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

+230 -37
+6 -30
lith/server.mjs
··· 1007 1007 app.all("/sotce-blog/*rest", directFn("sotce-blog")); 1008 1008 app.all("/profile/*rest", directFn("profile")); 1009 1009 1010 - // Menu Band crash-log intake. The macOS app reads its own crash reports 1011 - // from ~/Library/Logs/DiagnosticReports/ and POSTs them here when the user 1012 - // clicks "Send" in the popover. We never auto-receive — uploads are 1013 - // always user-initiated. Logs are saved to /var/log/menuband-logs/ on the 1014 - // VPS for offline triage. 5MB body cap to keep abuse manageable; a real 1015 - // .ips is ~80–200 KB. 1016 - app.post("/menuband-logs", express.text({ type: "*/*", limit: "5mb" }), (req, res) => { 1017 - const body = typeof req.body === "string" ? req.body 1018 - : Buffer.isBuffer(req.body) ? req.body.toString("utf-8") 1019 - : ""; 1020 - if (!body || body.length === 0) { 1021 - return res.status(400).json({ ok: false, error: "empty body" }); 1022 - } 1023 - const filenameHeader = String(req.headers["x-menuband-filename"] || ""); 1024 - const versionHeader = String(req.headers["x-menuband-version"] || "?"); 1025 - // Strip path separators + length-cap so the user-supplied name can't 1026 - // escape the logs dir or blow up disk inodes. 1027 - const safeName = filenameHeader.replace(/[^a-zA-Z0-9._-]/g, "_").slice(0, 200) || "crash.ips"; 1028 - const stamp = `${Date.now()}-v${versionHeader.replace(/[^0-9.]/g, "")}-${safeName}`; 1029 - const dir = "/var/log/menuband-logs"; 1030 - try { 1031 - mkdirSync(dir, { recursive: true }); 1032 - writeFileSync(join(dir, stamp), body, { mode: 0o644 }); 1033 - console.log(`📝 menuband crash log saved: ${stamp} (${body.length} bytes)`); 1034 - res.json({ ok: true, file: stamp }); 1035 - } catch (err) { 1036 - console.error("menuband-logs write failed:", err); 1037 - res.status(500).json({ ok: false, error: "write failed" }); 1038 - } 1039 - }); 1010 + // Menu Band crash-log intake → MongoDB collection "menuband-logs". Body is 1011 + // the raw .ips text; metadata comes from headers. The text-body parser 1012 + // runs only for this route so other routes' JSON parsing stays untouched. 1013 + app.post("/menuband-logs", 1014 + express.text({ type: "*/*", limit: "5mb" }), 1015 + directFn("menuband-logs")); 1040 1016 1041 1017 // Static files 1042 1018 app.use(express.static(PUBLIC, { extensions: ["html"], dotfiles: "allow" }));
+5 -2
slab/menuband/Sources/MenuBand/MenuBandController.swift
··· 164 164 // Built-in synth is always live. TYPE mode and MIDI mode are now 165 165 // independent toggles: TYPE controls global keyboard capture + key 166 166 // letter overlays; MIDI controls the virtual MIDI port output to 167 - // external DAWs. MIDI mode defaults to ON for fresh installs. 167 + // external DAWs. MIDI mode defaults to *off* for fresh installs — 168 + // most users hear the synth first; DAW routing is the opt-in path 169 + // they pick after they know they want it. Existing users' choice 170 + // is preserved (only the default for first-launch changes). 168 171 synth.start() 169 172 synth.setMelodicProgram(melodicProgram) 170 173 if UserDefaults.standard.object(forKey: midiModeKey) == nil { 171 - UserDefaults.standard.set(true, forKey: midiModeKey) 174 + UserDefaults.standard.set(false, forKey: midiModeKey) 172 175 } 173 176 if typeMode { enableTypeMode(promptForPermission: false) } 174 177 if midiMode { enableMIDIMode() }
+66 -4
slab/menuband/Sources/MenuBand/MenuBandPopover.swift
··· 67 67 private var crashStatusLabel: NSTextField! 68 68 private var crashHintLabel: NSTextField! 69 69 private var crashSendButton: NSButton! 70 + private var updateBanner: NSView! 71 + private var updateLabel: NSTextField! 70 72 private var keyMonitor: Any? 71 73 72 74 override func loadView() { ··· 99 101 subtitle.textColor = .secondaryLabelColor 100 102 stack.addArrangedSubview(subtitle) 101 103 104 + // Update banner — hidden until UpdateChecker reports a newer 105 + // release. Tinted accent so the user notices it without it feeling 106 + // like an alert. 107 + updateBanner = NSView() 108 + updateBanner.wantsLayer = true 109 + updateBanner.layer?.backgroundColor = NSColor.controlAccentColor 110 + .withAlphaComponent(0.14).cgColor 111 + updateBanner.layer?.cornerRadius = 6 112 + updateBanner.translatesAutoresizingMaskIntoConstraints = false 113 + updateLabel = NSTextField(labelWithString: "") 114 + updateLabel.font = NSFont.systemFont(ofSize: 11, weight: .semibold) 115 + updateLabel.textColor = .labelColor 116 + updateLabel.lineBreakMode = .byWordWrapping 117 + updateLabel.maximumNumberOfLines = 0 118 + updateLabel.translatesAutoresizingMaskIntoConstraints = false 119 + let updateLink = NSButton(title: "Open menuband.com", 120 + target: self, 121 + action: #selector(openMenuBandSite)) 122 + updateLink.bezelStyle = .recessed 123 + updateLink.controlSize = .small 124 + updateLink.translatesAutoresizingMaskIntoConstraints = false 125 + updateBanner.addSubview(updateLabel) 126 + updateBanner.addSubview(updateLink) 127 + NSLayoutConstraint.activate([ 128 + updateLabel.leadingAnchor.constraint(equalTo: updateBanner.leadingAnchor, constant: 10), 129 + updateLabel.topAnchor.constraint(equalTo: updateBanner.topAnchor, constant: 7), 130 + updateLabel.trailingAnchor.constraint(equalTo: updateBanner.trailingAnchor, constant: -10), 131 + updateLink.leadingAnchor.constraint(equalTo: updateBanner.leadingAnchor, constant: 10), 132 + updateLink.topAnchor.constraint(equalTo: updateLabel.bottomAnchor, constant: 4), 133 + updateLink.bottomAnchor.constraint(equalTo: updateBanner.bottomAnchor, constant: -7), 134 + ]) 135 + stack.addArrangedSubview(updateBanner) 136 + updateBanner.widthAnchor.constraint(equalToConstant: 248).isActive = true 137 + updateBanner.isHidden = true 138 + 102 139 stack.addArrangedSubview(makeSeparator()) 103 140 104 141 // Input mode picker. Three states: 105 - // Pointer — mouse only, two octaves (Notepat range) 106 - // Notepat — global keystroke capture, two octaves 107 - // Ableton — global keystroke capture, one octave (Live's M-mode) 142 + // Pointer — mouse only, two octaves (Notepat range) 143 + // Notepat.com — global keystroke capture, two octaves 144 + // Ableton — global keystroke capture, one octave (Live's M-mode) 108 145 // Hovering a segment previews that mode in the menubar piano (range 109 146 // shrinks/grows, letter labels appear) and lets you tap keys for a 110 147 // quick demo without committing. ··· 114 151 stack.addArrangedSubview(inputLabel) 115 152 116 153 inputSegmented = HoverSegmentedControl( 117 - labels: ["Pointer", "Notepat", "Ableton"], 154 + labels: ["Pointer", "Notepat.com", "Ableton"], 118 155 trackingMode: .selectOne, 119 156 target: self, 120 157 action: #selector(inputModeChanged(_:)) ··· 329 366 instrumentList.scrollProgramIntoView(n.melodicProgram) 330 367 updateSelfTestLabel(state: n.midiMode ? n.midiSelfTest : .unknown) 331 368 refreshCrashStatus() 369 + refreshUpdateBanner() 332 370 // Wire up live updates so the label reflects loopback results as 333 371 // they land (test runs ~50ms after toggle-on; result settles a moment 334 372 // later). ··· 413 451 crashSendButton.isHidden = false 414 452 crashSendButton.title = n == 1 ? "Send 1 report" : "Send all (\(n))" 415 453 crashSendButton.isEnabled = true 454 + } 455 + } 456 + 457 + /// Hit the manifest at assets.aesthetic.computer/menuband/latest.json 458 + /// and show the banner if there's a newer version available than the 459 + /// one running. Cached for an hour inside UpdateChecker. 460 + private func refreshUpdateBanner() { 461 + let current = UpdateChecker.currentVersion() 462 + UpdateChecker.fetchLatest { [weak self] info in 463 + guard let self = self, let info = info else { return } 464 + if UpdateChecker.isNewer(info.version, than: current) { 465 + let notes = info.notes?.isEmpty == false ? " — \(info.notes!)" : "" 466 + self.updateLabel.stringValue = 467 + "Update available: \(info.version)\(notes)" 468 + self.updateBanner.isHidden = false 469 + } else { 470 + self.updateBanner.isHidden = true 471 + } 472 + } 473 + } 474 + 475 + @objc private func openMenuBandSite() { 476 + if let url = URL(string: "https://aesthetic.computer/menuband") { 477 + NSWorkspace.shared.open(url) 416 478 } 417 479 } 418 480
+72
slab/menuband/Sources/MenuBand/UpdateChecker.swift
··· 1 + import Foundation 2 + 3 + /// Polls a small JSON manifest hosted on the AC CDN to decide whether 4 + /// there's a newer Menu Band release than the one running. The manifest 5 + /// lives at `assets.aesthetic.computer/menuband/latest.json` and is the 6 + /// only side-effect of a release — bumping its `version` field is what 7 + /// triggers the in-popover update banner. 8 + /// 9 + /// Manifest schema (kept tiny so the file caches well on Cloudflare): 10 + /// ```json 11 + /// { "version": "0.1", "url": "https://aesthetic.computer/menuband", 12 + /// "notes": "First release." } 13 + /// ``` 14 + enum UpdateChecker { 15 + static let manifestURL = URL(string: "https://assets.aesthetic.computer/menuband/latest.json")! 16 + 17 + struct VersionInfo: Codable { 18 + let version: String 19 + let url: String? 20 + let notes: String? 21 + } 22 + 23 + /// In-memory cache. Refresh policy: bypass when older than 1h. The 24 + /// request itself uses `reloadIgnoringLocalCacheData` so the OS HTTP 25 + /// cache doesn't pin a stale manifest after a release. 26 + private static var cached: VersionInfo? 27 + private static var lastCheck: Date? 28 + 29 + static func currentVersion() -> String { 30 + (Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String) ?? "0.0" 31 + } 32 + 33 + /// Fetch the latest manifest. Calls back on the main thread. Returns 34 + /// nil on network/parse failure (silent fallback — we don't want to 35 + /// pester the user when their wi-fi is down). 36 + static func fetchLatest(completion: @escaping (VersionInfo?) -> Void) { 37 + if let info = cached, let ts = lastCheck, Date().timeIntervalSince(ts) < 3600 { 38 + completion(info) 39 + return 40 + } 41 + var req = URLRequest(url: manifestURL) 42 + req.cachePolicy = .reloadIgnoringLocalCacheData 43 + req.timeoutInterval = 8 44 + URLSession.shared.dataTask(with: req) { data, _, _ in 45 + var parsed: VersionInfo? 46 + if let data = data { 47 + parsed = try? JSONDecoder().decode(VersionInfo.self, from: data) 48 + } 49 + if let info = parsed { 50 + cached = info 51 + lastCheck = Date() 52 + } 53 + DispatchQueue.main.async { completion(parsed) } 54 + }.resume() 55 + } 56 + 57 + /// `true` iff `latest > current` under naive dotted-integer ordering. 58 + /// "0.2" > "0.1", "1.0" > "0.99", "0.1.1" > "0.1". Missing components 59 + /// count as 0 so "0.1" and "0.1.0" compare equal. 60 + static func isNewer(_ latest: String, than current: String) -> Bool { 61 + let l = latest.split(separator: ".").map { Int($0) ?? 0 } 62 + let c = current.split(separator: ".").map { Int($0) ?? 0 } 63 + let count = Swift.max(l.count, c.count) 64 + for i in 0..<count { 65 + let lv = i < l.count ? l[i] : 0 66 + let cv = i < c.count ? c[i] : 0 67 + if lv > cv { return true } 68 + if lv < cv { return false } 69 + } 70 + return false 71 + } 72 + }
+80
system/netlify/functions/menuband-logs.mjs
··· 1 + // Menu Band crash-log intake. 2 + // POST /menuband-logs 3 + // 4 + // The macOS Menu Band app reads its own crash reports from 5 + // ~/Library/Logs/DiagnosticReports/MenuBand-*.ips and POSTs them here 6 + // when the user clicks "Send" in the popover. We never auto-receive — 7 + // uploads are always user-initiated. Body is the raw .ips text; 8 + // metadata comes from request headers so the body stays unmodified. 9 + // 10 + // Headers: 11 + // Content-Type: text/plain; charset=utf-8 12 + // X-Menuband-Filename: "MenuBand-2026-04-28-211718.ips" 13 + // X-Menuband-Version: "0.1" 14 + // 15 + // Stored in collection "menuband-logs" for offline triage. 16 + 17 + import { MongoClient } from "mongodb"; 18 + 19 + const MONGODB_CONNECTION_STRING = process.env.MONGODB_CONNECTION_STRING; 20 + const MONGODB_NAME = process.env.MONGODB_NAME || "aesthetic"; 21 + 22 + const MAX_BODY_BYTES = 5 * 1024 * 1024; // 5 MB — real .ips files are 80–200 KB 23 + 24 + function respond(statusCode, body) { 25 + return { 26 + statusCode, 27 + headers: { 28 + "Content-Type": "application/json", 29 + "Access-Control-Allow-Origin": "*", 30 + "Access-Control-Allow-Headers": "Content-Type, X-Menuband-Filename, X-Menuband-Version", 31 + "Access-Control-Allow-Methods": "POST, OPTIONS", 32 + }, 33 + body: JSON.stringify(body), 34 + }; 35 + } 36 + 37 + function sanitizeFilename(raw) { 38 + return String(raw || "") 39 + .replace(/[^a-zA-Z0-9._-]/g, "_") 40 + .slice(0, 200) || "crash.ips"; 41 + } 42 + 43 + export async function handler(event) { 44 + if (event.httpMethod === "OPTIONS") return respond(200, { ok: true }); 45 + if (event.httpMethod !== "POST") return respond(405, { error: "POST only" }); 46 + if (!MONGODB_CONNECTION_STRING) return respond(500, { error: "MongoDB not configured" }); 47 + 48 + const body = String(event.body || ""); 49 + if (body.length === 0) return respond(400, { error: "empty body" }); 50 + if (body.length > MAX_BODY_BYTES) return respond(413, { error: "too large" }); 51 + 52 + const headers = event.headers || {}; 53 + const filename = sanitizeFilename(headers["x-menuband-filename"] || headers["X-Menuband-Filename"]); 54 + const version = String(headers["x-menuband-version"] || headers["X-Menuband-Version"] || "?") 55 + .replace(/[^0-9.A-Za-z_-]/g, "") 56 + .slice(0, 32); 57 + 58 + const client = new MongoClient(MONGODB_CONNECTION_STRING); 59 + try { 60 + await client.connect(); 61 + const coll = client.db(MONGODB_NAME).collection("menuband-logs"); 62 + const doc = { 63 + receivedAt: new Date(), 64 + filename, 65 + version, 66 + bytes: body.length, 67 + log: body, 68 + meta: { 69 + ip: headers["x-forwarded-for"] || headers["client-ip"] || "unknown", 70 + userAgent: headers["user-agent"] || "unknown", 71 + }, 72 + }; 73 + const { insertedId } = await coll.insertOne(doc); 74 + return respond(200, { ok: true, id: insertedId?.toString() }); 75 + } catch (e) { 76 + return respond(500, { error: "Database error" }); 77 + } finally { 78 + await client.close(); 79 + } 80 + }
+1 -1
system/public/menuband/index.html
··· 556 556 <p class="tagline">Built-in macOS instruments, in the menu bar.</p> 557 557 558 558 <div class="button-row"> 559 - <a class="aqua" href="https://assets.aesthetic.computer/menuband/Menu-Band-0.1.dmg?v=10d62c3" download> 559 + <a class="aqua" href="https://assets.aesthetic.computer/menuband/Menu-Band-0.1.dmg?v=2b9d32b" download> 560 560 Download 561 561 <small>0.1 · Apple Silicon · 1.1 MB</small> 562 562 </a>