diff --git a/lith/server.mjs b/lith/server.mjs index 1944c2bda..766525b43 100644 --- a/lith/server.mjs +++ b/lith/server.mjs @@ -1007,36 +1007,12 @@ app.all("/slash", directFn("slash")); app.all("/sotce-blog/*rest", directFn("sotce-blog")); app.all("/profile/*rest", directFn("profile")); -// Menu Band crash-log intake. The macOS app reads its own crash reports -// from ~/Library/Logs/DiagnosticReports/ and POSTs them here when the user -// clicks "Send" in the popover. We never auto-receive โ€” uploads are -// always user-initiated. Logs are saved to /var/log/menuband-logs/ on the -// VPS for offline triage. 5MB body cap to keep abuse manageable; a real -// .ips is ~80โ€“200 KB. -app.post("/menuband-logs", express.text({ type: "*/*", limit: "5mb" }), (req, res) => { - const body = typeof req.body === "string" ? req.body - : Buffer.isBuffer(req.body) ? req.body.toString("utf-8") - : ""; - if (!body || body.length === 0) { - return res.status(400).json({ ok: false, error: "empty body" }); - } - const filenameHeader = String(req.headers["x-menuband-filename"] || ""); - const versionHeader = String(req.headers["x-menuband-version"] || "?"); - // Strip path separators + length-cap so the user-supplied name can't - // escape the logs dir or blow up disk inodes. - const safeName = filenameHeader.replace(/[^a-zA-Z0-9._-]/g, "_").slice(0, 200) || "crash.ips"; - const stamp = `${Date.now()}-v${versionHeader.replace(/[^0-9.]/g, "")}-${safeName}`; - const dir = "/var/log/menuband-logs"; - try { - mkdirSync(dir, { recursive: true }); - writeFileSync(join(dir, stamp), body, { mode: 0o644 }); - console.log(`๐Ÿ“ menuband crash log saved: ${stamp} (${body.length} bytes)`); - res.json({ ok: true, file: stamp }); - } catch (err) { - console.error("menuband-logs write failed:", err); - res.status(500).json({ ok: false, error: "write failed" }); - } -}); +// Menu Band crash-log intake โ†’ MongoDB collection "menuband-logs". Body is +// the raw .ips text; metadata comes from headers. The text-body parser +// runs only for this route so other routes' JSON parsing stays untouched. +app.post("/menuband-logs", + express.text({ type: "*/*", limit: "5mb" }), + directFn("menuband-logs")); // Static files app.use(express.static(PUBLIC, { extensions: ["html"], dotfiles: "allow" })); diff --git a/slab/menuband/Sources/MenuBand/MenuBandController.swift b/slab/menuband/Sources/MenuBand/MenuBandController.swift index 27b62762a..2ccf3de94 100644 --- a/slab/menuband/Sources/MenuBand/MenuBandController.swift +++ b/slab/menuband/Sources/MenuBand/MenuBandController.swift @@ -164,11 +164,14 @@ final class MenuBandController { // Built-in synth is always live. TYPE mode and MIDI mode are now // independent toggles: TYPE controls global keyboard capture + key // letter overlays; MIDI controls the virtual MIDI port output to - // external DAWs. MIDI mode defaults to ON for fresh installs. + // external DAWs. MIDI mode defaults to *off* for fresh installs โ€” + // most users hear the synth first; DAW routing is the opt-in path + // they pick after they know they want it. Existing users' choice + // is preserved (only the default for first-launch changes). synth.start() synth.setMelodicProgram(melodicProgram) if UserDefaults.standard.object(forKey: midiModeKey) == nil { - UserDefaults.standard.set(true, forKey: midiModeKey) + UserDefaults.standard.set(false, forKey: midiModeKey) } if typeMode { enableTypeMode(promptForPermission: false) } if midiMode { enableMIDIMode() } diff --git a/slab/menuband/Sources/MenuBand/MenuBandPopover.swift b/slab/menuband/Sources/MenuBand/MenuBandPopover.swift index ac15085bc..8a9cc6a93 100644 --- a/slab/menuband/Sources/MenuBand/MenuBandPopover.swift +++ b/slab/menuband/Sources/MenuBand/MenuBandPopover.swift @@ -67,6 +67,8 @@ final class MenuBandPopoverViewController: NSViewController { private var crashStatusLabel: NSTextField! private var crashHintLabel: NSTextField! private var crashSendButton: NSButton! + private var updateBanner: NSView! + private var updateLabel: NSTextField! private var keyMonitor: Any? override func loadView() { @@ -99,12 +101,47 @@ final class MenuBandPopoverViewController: NSViewController { subtitle.textColor = .secondaryLabelColor stack.addArrangedSubview(subtitle) + // Update banner โ€” hidden until UpdateChecker reports a newer + // release. Tinted accent so the user notices it without it feeling + // like an alert. + updateBanner = NSView() + updateBanner.wantsLayer = true + updateBanner.layer?.backgroundColor = NSColor.controlAccentColor + .withAlphaComponent(0.14).cgColor + updateBanner.layer?.cornerRadius = 6 + updateBanner.translatesAutoresizingMaskIntoConstraints = false + updateLabel = NSTextField(labelWithString: "") + updateLabel.font = NSFont.systemFont(ofSize: 11, weight: .semibold) + updateLabel.textColor = .labelColor + updateLabel.lineBreakMode = .byWordWrapping + updateLabel.maximumNumberOfLines = 0 + updateLabel.translatesAutoresizingMaskIntoConstraints = false + let updateLink = NSButton(title: "Open menuband.com", + target: self, + action: #selector(openMenuBandSite)) + updateLink.bezelStyle = .recessed + updateLink.controlSize = .small + updateLink.translatesAutoresizingMaskIntoConstraints = false + updateBanner.addSubview(updateLabel) + updateBanner.addSubview(updateLink) + NSLayoutConstraint.activate([ + updateLabel.leadingAnchor.constraint(equalTo: updateBanner.leadingAnchor, constant: 10), + updateLabel.topAnchor.constraint(equalTo: updateBanner.topAnchor, constant: 7), + updateLabel.trailingAnchor.constraint(equalTo: updateBanner.trailingAnchor, constant: -10), + updateLink.leadingAnchor.constraint(equalTo: updateBanner.leadingAnchor, constant: 10), + updateLink.topAnchor.constraint(equalTo: updateLabel.bottomAnchor, constant: 4), + updateLink.bottomAnchor.constraint(equalTo: updateBanner.bottomAnchor, constant: -7), + ]) + stack.addArrangedSubview(updateBanner) + updateBanner.widthAnchor.constraint(equalToConstant: 248).isActive = true + updateBanner.isHidden = true + stack.addArrangedSubview(makeSeparator()) // Input mode picker. Three states: - // Pointer โ€” mouse only, two octaves (Notepat range) - // Notepat โ€” global keystroke capture, two octaves - // Ableton โ€” global keystroke capture, one octave (Live's M-mode) + // Pointer โ€” mouse only, two octaves (Notepat range) + // Notepat.com โ€” global keystroke capture, two octaves + // Ableton โ€” global keystroke capture, one octave (Live's M-mode) // Hovering a segment previews that mode in the menubar piano (range // shrinks/grows, letter labels appear) and lets you tap keys for a // quick demo without committing. @@ -114,7 +151,7 @@ final class MenuBandPopoverViewController: NSViewController { stack.addArrangedSubview(inputLabel) inputSegmented = HoverSegmentedControl( - labels: ["Pointer", "Notepat", "Ableton"], + labels: ["Pointer", "Notepat.com", "Ableton"], trackingMode: .selectOne, target: self, action: #selector(inputModeChanged(_:)) @@ -329,6 +366,7 @@ final class MenuBandPopoverViewController: NSViewController { instrumentList.scrollProgramIntoView(n.melodicProgram) updateSelfTestLabel(state: n.midiMode ? n.midiSelfTest : .unknown) refreshCrashStatus() + refreshUpdateBanner() // Wire up live updates so the label reflects loopback results as // they land (test runs ~50ms after toggle-on; result settles a moment // later). @@ -416,6 +454,30 @@ final class MenuBandPopoverViewController: NSViewController { } } + /// Hit the manifest at assets.aesthetic.computer/menuband/latest.json + /// and show the banner if there's a newer version available than the + /// one running. Cached for an hour inside UpdateChecker. + private func refreshUpdateBanner() { + let current = UpdateChecker.currentVersion() + UpdateChecker.fetchLatest { [weak self] info in + guard let self = self, let info = info else { return } + if UpdateChecker.isNewer(info.version, than: current) { + let notes = info.notes?.isEmpty == false ? " โ€” \(info.notes!)" : "" + self.updateLabel.stringValue = + "Update available: \(info.version)\(notes)" + self.updateBanner.isHidden = false + } else { + self.updateBanner.isHidden = true + } + } + } + + @objc private func openMenuBandSite() { + if let url = URL(string: "https://aesthetic.computer/menuband") { + NSWorkspace.shared.open(url) + } + } + @objc private func sendCrashLogs(_ sender: NSButton) { let logs = CrashLogReader.recentLogs() guard !logs.isEmpty else { return } diff --git a/slab/menuband/Sources/MenuBand/UpdateChecker.swift b/slab/menuband/Sources/MenuBand/UpdateChecker.swift new file mode 100644 index 000000000..67462a740 --- /dev/null +++ b/slab/menuband/Sources/MenuBand/UpdateChecker.swift @@ -0,0 +1,72 @@ +import Foundation + +/// Polls a small JSON manifest hosted on the AC CDN to decide whether +/// there's a newer Menu Band release than the one running. The manifest +/// lives at `assets.aesthetic.computer/menuband/latest.json` and is the +/// only side-effect of a release โ€” bumping its `version` field is what +/// triggers the in-popover update banner. +/// +/// Manifest schema (kept tiny so the file caches well on Cloudflare): +/// ```json +/// { "version": "0.1", "url": "https://aesthetic.computer/menuband", +/// "notes": "First release." } +/// ``` +enum UpdateChecker { + static let manifestURL = URL(string: "https://assets.aesthetic.computer/menuband/latest.json")! + + struct VersionInfo: Codable { + let version: String + let url: String? + let notes: String? + } + + /// In-memory cache. Refresh policy: bypass when older than 1h. The + /// request itself uses `reloadIgnoringLocalCacheData` so the OS HTTP + /// cache doesn't pin a stale manifest after a release. + private static var cached: VersionInfo? + private static var lastCheck: Date? + + static func currentVersion() -> String { + (Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String) ?? "0.0" + } + + /// Fetch the latest manifest. Calls back on the main thread. Returns + /// nil on network/parse failure (silent fallback โ€” we don't want to + /// pester the user when their wi-fi is down). + static func fetchLatest(completion: @escaping (VersionInfo?) -> Void) { + if let info = cached, let ts = lastCheck, Date().timeIntervalSince(ts) < 3600 { + completion(info) + return + } + var req = URLRequest(url: manifestURL) + req.cachePolicy = .reloadIgnoringLocalCacheData + req.timeoutInterval = 8 + URLSession.shared.dataTask(with: req) { data, _, _ in + var parsed: VersionInfo? + if let data = data { + parsed = try? JSONDecoder().decode(VersionInfo.self, from: data) + } + if let info = parsed { + cached = info + lastCheck = Date() + } + DispatchQueue.main.async { completion(parsed) } + }.resume() + } + + /// `true` iff `latest > current` under naive dotted-integer ordering. + /// "0.2" > "0.1", "1.0" > "0.99", "0.1.1" > "0.1". Missing components + /// count as 0 so "0.1" and "0.1.0" compare equal. + static func isNewer(_ latest: String, than current: String) -> Bool { + let l = latest.split(separator: ".").map { Int($0) ?? 0 } + let c = current.split(separator: ".").map { Int($0) ?? 0 } + let count = Swift.max(l.count, c.count) + for i in 0.. cv { return true } + if lv < cv { return false } + } + return false + } +} diff --git a/system/netlify/functions/menuband-logs.mjs b/system/netlify/functions/menuband-logs.mjs new file mode 100644 index 000000000..fb4ff852e --- /dev/null +++ b/system/netlify/functions/menuband-logs.mjs @@ -0,0 +1,80 @@ +// Menu Band crash-log intake. +// POST /menuband-logs +// +// The macOS Menu Band app reads its own crash reports from +// ~/Library/Logs/DiagnosticReports/MenuBand-*.ips and POSTs them here +// when the user clicks "Send" in the popover. We never auto-receive โ€” +// uploads are always user-initiated. Body is the raw .ips text; +// metadata comes from request headers so the body stays unmodified. +// +// Headers: +// Content-Type: text/plain; charset=utf-8 +// X-Menuband-Filename: "MenuBand-2026-04-28-211718.ips" +// X-Menuband-Version: "0.1" +// +// Stored in collection "menuband-logs" for offline triage. + +import { MongoClient } from "mongodb"; + +const MONGODB_CONNECTION_STRING = process.env.MONGODB_CONNECTION_STRING; +const MONGODB_NAME = process.env.MONGODB_NAME || "aesthetic"; + +const MAX_BODY_BYTES = 5 * 1024 * 1024; // 5 MB โ€” real .ips files are 80โ€“200 KB + +function respond(statusCode, body) { + return { + statusCode, + headers: { + "Content-Type": "application/json", + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Headers": "Content-Type, X-Menuband-Filename, X-Menuband-Version", + "Access-Control-Allow-Methods": "POST, OPTIONS", + }, + body: JSON.stringify(body), + }; +} + +function sanitizeFilename(raw) { + return String(raw || "") + .replace(/[^a-zA-Z0-9._-]/g, "_") + .slice(0, 200) || "crash.ips"; +} + +export async function handler(event) { + if (event.httpMethod === "OPTIONS") return respond(200, { ok: true }); + if (event.httpMethod !== "POST") return respond(405, { error: "POST only" }); + if (!MONGODB_CONNECTION_STRING) return respond(500, { error: "MongoDB not configured" }); + + const body = String(event.body || ""); + if (body.length === 0) return respond(400, { error: "empty body" }); + if (body.length > MAX_BODY_BYTES) return respond(413, { error: "too large" }); + + const headers = event.headers || {}; + const filename = sanitizeFilename(headers["x-menuband-filename"] || headers["X-Menuband-Filename"]); + const version = String(headers["x-menuband-version"] || headers["X-Menuband-Version"] || "?") + .replace(/[^0-9.A-Za-z_-]/g, "") + .slice(0, 32); + + const client = new MongoClient(MONGODB_CONNECTION_STRING); + try { + await client.connect(); + const coll = client.db(MONGODB_NAME).collection("menuband-logs"); + const doc = { + receivedAt: new Date(), + filename, + version, + bytes: body.length, + log: body, + meta: { + ip: headers["x-forwarded-for"] || headers["client-ip"] || "unknown", + userAgent: headers["user-agent"] || "unknown", + }, + }; + const { insertedId } = await coll.insertOne(doc); + return respond(200, { ok: true, id: insertedId?.toString() }); + } catch (e) { + return respond(500, { error: "Database error" }); + } finally { + await client.close(); + } +} diff --git a/system/public/menuband/index.html b/system/public/menuband/index.html index 8409ecad9..2432eff99 100644 --- a/system/public/menuband/index.html +++ b/system/public/menuband/index.html @@ -556,7 +556,7 @@

Built-in macOS instruments, in the menu bar.

- + Download 0.1 ยท Apple Silicon ยท 1.1 MB