From 8dab7236d4cebdc8e89b18ed9515826f3ac10ec0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=27=C3=A9lectron=20rare?= <108685187+electron-rare@users.noreply.github.com> Date: Thu, 7 May 2026 12:04:59 +0200 Subject: [PATCH] feat(launcher): SwiftUI menubar app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Context: AV-Live needs a single-click way to boot its two heavy components (the SuperCollider sound engine and the openFrameworks oscope-of visualizer) together, with their logs aggregated in one place. Running them by hand from terminal during a live set is fragile. Approach: Swift Package Manager + a small build.sh that wraps the release binary into a real .app bundle (Info.plist with LSUIElement=true to keep it out of the Dock). The UI uses NSStatusItem + NSPopover so it works back to macOS 11 (Big Sur) — MenuBarExtra would require 13+ which the current target MBP can't run. Changes: - launcher/Package.swift : SwiftPM manifest, single executable target, macOS 11 minimum - AVLiveLauncherApp.swift : @main App + AppDelegate that owns the status item, popover and lazily-created log window - ProcessManager.swift : ObservableObject spawning sclang and oscope-of via Foundation Process, capturing stdout/stderr through Pipe + readabilityHandler, ring-buffered log array (2000 lines), termination handlers that flip @Published flags back. Paths persisted in UserDefaults with sane defaults - MenuBarContent.swift : popover view with two ProcessRow toggles, Logs / Paths / Quit, Settings sheet using NSOpenPanel - LogView.swift : separate window with filter, auto-scroll, color-coded source column, monospaced font. #available guard for textSelection - Info.plist : bundle id cc.saillant.AVLiveLauncher, GPL-3 copyright, LSUIElement true, min macOS 11 - build.sh : swift build -c release, copies binary + Info.plist into AVLiveLauncher.app, ad-hoc codesigns - .gitignore + README Impact: 'cd launcher && ./build.sh && open build/AVLiveLauncher.app' gives a working .app that brings up the AV stack from the menubar. Smoke-tested on arm64 — Intel MBP needs a local build or a future universal -arch x86_64 flag in build.sh. --- launcher/.gitignore | 5 + launcher/Package.swift | 13 ++ launcher/README.md | 38 +++++ launcher/Resources/Info.plist | 34 ++++ .../AVLiveLauncher/AVLiveLauncherApp.swift | 87 ++++++++++ launcher/Sources/AVLiveLauncher/LogView.swift | 80 +++++++++ .../AVLiveLauncher/MenuBarContent.swift | 144 ++++++++++++++++ .../AVLiveLauncher/ProcessManager.swift | 155 ++++++++++++++++++ launcher/build.sh | 28 ++++ 9 files changed, 584 insertions(+) create mode 100644 launcher/.gitignore create mode 100644 launcher/Package.swift create mode 100644 launcher/README.md create mode 100644 launcher/Resources/Info.plist create mode 100644 launcher/Sources/AVLiveLauncher/AVLiveLauncherApp.swift create mode 100644 launcher/Sources/AVLiveLauncher/LogView.swift create mode 100644 launcher/Sources/AVLiveLauncher/MenuBarContent.swift create mode 100644 launcher/Sources/AVLiveLauncher/ProcessManager.swift create mode 100755 launcher/build.sh diff --git a/launcher/.gitignore b/launcher/.gitignore new file mode 100644 index 0000000..9629e8d --- /dev/null +++ b/launcher/.gitignore @@ -0,0 +1,5 @@ +.build/ +build/ +.swiftpm/ +Package.resolved +DerivedData/ diff --git a/launcher/Package.swift b/launcher/Package.swift new file mode 100644 index 0000000..6cbe3f9 --- /dev/null +++ b/launcher/Package.swift @@ -0,0 +1,13 @@ +// swift-tools-version:5.7 +import PackageDescription + +let package = Package( + name: "AVLiveLauncher", + platforms: [.macOS(.v11)], + targets: [ + .executableTarget( + name: "AVLiveLauncher", + path: "Sources/AVLiveLauncher" + ) + ] +) diff --git a/launcher/README.md b/launcher/README.md new file mode 100644 index 0000000..4a54ee7 --- /dev/null +++ b/launcher/README.md @@ -0,0 +1,38 @@ +# AVLiveLauncher + +macOS menubar launcher for AV-Live. Starts and stops `sclang` (which +auto-loads `sound_algo/00_load.scd` and serves the web UI) and the +`oscope-of` visualizer with one click each, and aggregates their logs in +one window. + +## Build + +Requires Swift 5.7+ and Xcode Command Line Tools. + +```sh +cd launcher +./build.sh +open build/AVLiveLauncher.app +``` + +The first run shows a `waveform.path.ecg` icon in the menubar. Click it +to open the popover. + +## Configuration + +Three paths are stored in `UserDefaults` (`~/Library/Preferences/cc.saillant.AVLiveLauncher.plist`) : + +| Default | Override | +|---------|----------| +| `/Applications/SuperCollider.app/Contents/MacOS/sclang` | Paths… → sclang binary | +| `~/Documents/Projets/AV-Live/sound_algo/00_load.scd` | Paths… → load file | +| `~/Documents/Projets/AV-Live/oscope-of/bin/oscope-of` | Paths… → oscope binary | + +## What it does + +- Spawns `sclang ` with stdout/stderr captured into the log + window. `sclang` itself boots `scsynth`, loads the 1099-SynthDef + palette, and serves the web bridge on `:8080`. +- Spawns the `oscope-of` binary with cwd set to its parent directory + (so oF can find `bin/data/`). +- Quitting the menubar app sends `SIGTERM` to both children. diff --git a/launcher/Resources/Info.plist b/launcher/Resources/Info.plist new file mode 100644 index 0000000..e95ddab --- /dev/null +++ b/launcher/Resources/Info.plist @@ -0,0 +1,34 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + AVLiveLauncher + CFBundleIdentifier + cc.saillant.AVLiveLauncher + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + AV-Live + CFBundleDisplayName + AV-Live + CFBundlePackageType + APPL + CFBundleShortVersionString + 0.1.0 + CFBundleVersion + 1 + LSMinimumSystemVersion + 11.0 + LSUIElement + + NSHighResolutionCapable + + NSHumanReadableCopyright + GPL-3.0-or-later © L'Electron Rare + NSPrincipalClass + NSApplication + + diff --git a/launcher/Sources/AVLiveLauncher/AVLiveLauncherApp.swift b/launcher/Sources/AVLiveLauncher/AVLiveLauncherApp.swift new file mode 100644 index 0000000..df0112b --- /dev/null +++ b/launcher/Sources/AVLiveLauncher/AVLiveLauncherApp.swift @@ -0,0 +1,87 @@ +import AppKit +import SwiftUI + +// MenuBar-only app : NSStatusItem + NSPopover. macOS 11+ compatible. +// LSUIElement = true (set in Info.plist) hides the dock icon. + +@main +struct AVLiveLauncherApp: App { + @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate + + var body: some Scene { + // Empty Settings scene so SwiftUI is happy. The real UI is the popover. + Settings { EmptyView() } + } +} + +final class AppDelegate: NSObject, NSApplicationDelegate { + private var statusItem: NSStatusItem! + private var popover: NSPopover! + private var logWindow: NSWindow? + private let processManager = ProcessManager() + + func applicationDidFinishLaunching(_ notification: Notification) { + // Status bar icon + statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) + if let button = statusItem.button { + if #available(macOS 11.0, *) { + button.image = NSImage( + systemSymbolName: "waveform.path.ecg", + accessibilityDescription: "AV-Live" + ) + } else { + button.title = "AV" + } + button.action = #selector(togglePopover(_:)) + button.target = self + } + + // Popover with the SwiftUI menu content + popover = NSPopover() + popover.contentSize = NSSize(width: 360, height: 320) + popover.behavior = .transient + popover.contentViewController = NSHostingController( + rootView: MenuBarContent( + processManager: processManager, + openLogs: { [weak self] in self?.showLogs() } + ) + ) + } + + func applicationWillTerminate(_ notification: Notification) { + processManager.stopAll() + } + + @objc private func togglePopover(_ sender: Any?) { + guard let button = statusItem.button else { return } + if popover.isShown { + popover.performClose(sender) + } else { + popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY) + popover.contentViewController?.view.window?.makeKey() + } + } + + private func showLogs() { + if let w = logWindow { + w.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + return + } + let w = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 720, height: 480), + styleMask: [.titled, .closable, .resizable], + backing: .buffered, + defer: false + ) + w.title = "AV-Live Logs" + w.center() + w.contentViewController = NSHostingController( + rootView: LogView(processManager: processManager) + ) + w.isReleasedWhenClosed = false + logWindow = w + w.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + } +} diff --git a/launcher/Sources/AVLiveLauncher/LogView.swift b/launcher/Sources/AVLiveLauncher/LogView.swift new file mode 100644 index 0000000..09dac2f --- /dev/null +++ b/launcher/Sources/AVLiveLauncher/LogView.swift @@ -0,0 +1,80 @@ +import SwiftUI + +struct LogView: View { + @ObservedObject var processManager: ProcessManager + @State private var filter: String = "" + @State private var autoScroll = true + + private static let timeFormatter: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "HH:mm:ss" + return f + }() + + private var filtered: [LogLine] { + guard !filter.isEmpty else { return processManager.logs } + return processManager.logs.filter { + $0.text.localizedCaseInsensitiveContains(filter) + || $0.source.localizedCaseInsensitiveContains(filter) + } + } + + var body: some View { + VStack(spacing: 0) { + HStack { + TextField("Filter", text: $filter) + .textFieldStyle(.roundedBorder) + Toggle("Auto-scroll", isOn: $autoScroll) + Button("Clear") { processManager.clearLogs() } + } + .padding(8) + + Divider() + + ScrollViewReader { proxy in + ScrollView { + LazyVStack(alignment: .leading, spacing: 1) { + ForEach(filtered) { line in + HStack(alignment: .firstTextBaseline, spacing: 6) { + Text(Self.timeFormatter.string(from: line.timestamp)) + .font(.system(.caption, design: .monospaced)) + .foregroundColor(.secondary) + Text(line.source) + .font(.system(.caption, design: .monospaced)) + .foregroundColor(color(for: line.source)) + .frame(width: 70, alignment: .leading) + selectableText(line.text) + .font(.system(.caption, design: .monospaced)) + } + .id(line.id) + .padding(.horizontal, 8) + } + } + .padding(.vertical, 4) + } + .onChange(of: filtered.count) { _ in + if autoScroll, let last = filtered.last { + withAnimation(.linear(duration: 0.05)) { + proxy.scrollTo(last.id, anchor: .bottom) + } + } + } + } + } + } + + private func color(for source: String) -> Color { + if source.hasPrefix("sclang") { return .blue } + if source.hasPrefix("oscope") { return .purple } + return .secondary + } + + @ViewBuilder + private func selectableText(_ s: String) -> some View { + if #available(macOS 12.0, *) { + Text(s).textSelection(.enabled) + } else { + Text(s) + } + } +} diff --git a/launcher/Sources/AVLiveLauncher/MenuBarContent.swift b/launcher/Sources/AVLiveLauncher/MenuBarContent.swift new file mode 100644 index 0000000..2ad5dbc --- /dev/null +++ b/launcher/Sources/AVLiveLauncher/MenuBarContent.swift @@ -0,0 +1,144 @@ +import SwiftUI + +struct MenuBarContent: View { + @ObservedObject var processManager: ProcessManager + let openLogs: () -> Void + @State private var showSettings = false + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + Text("AV-Live") + .font(.headline) + + Divider() + + ProcessRow( + title: "SuperCollider", + subtitle: "sclang + scsynth + web bridge", + isRunning: processManager.sclangRunning, + start: processManager.startSclang, + stop: processManager.stopSclang + ) + + ProcessRow( + title: "Oscilloscope", + subtitle: "oscope-of visualizer", + isRunning: processManager.oscopeRunning, + start: processManager.startOscope, + stop: processManager.stopOscope + ) + + Divider() + + HStack { + Button(action: openLogs) { + Label("Logs", systemImage: "text.alignleft") + } + Button(action: { showSettings = true }) { + Label("Paths…", systemImage: "gearshape") + } + Spacer() + Button("Quit") { NSApp.terminate(nil) } + .keyboardShortcut("q") + } + } + .padding(14) + .frame(width: 360) + .sheet(isPresented: $showSettings) { + SettingsView(processManager: processManager, + dismiss: { showSettings = false }) + } + } +} + +private struct ProcessRow: View { + let title: String + let subtitle: String + let isRunning: Bool + let start: () -> Void + let stop: () -> Void + + var body: some View { + HStack(alignment: .center, spacing: 10) { + Circle() + .fill(isRunning ? Color.green : Color.secondary.opacity(0.4)) + .frame(width: 10, height: 10) + VStack(alignment: .leading, spacing: 1) { + Text(title).font(.body) + Text(subtitle).font(.caption).foregroundColor(.secondary) + } + Spacer() + Button(isRunning ? "Stop" : "Start") { + isRunning ? stop() : start() + } + .frame(width: 60) + } + } +} + +private struct SettingsView: View { + @ObservedObject var processManager: ProcessManager + let dismiss: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text("Paths").font(.headline) + PathField( + label: "sclang binary", + path: Binding( + get: { processManager.sclangPath }, + set: { processManager.sclangPath = $0 } + ), + isDirectory: false + ) + PathField( + label: "Load file (00_load.scd)", + path: Binding( + get: { processManager.soundAlgoLoadFile }, + set: { processManager.soundAlgoLoadFile = $0 } + ), + isDirectory: false + ) + PathField( + label: "oscope-of binary", + path: Binding( + get: { processManager.oscopePath }, + set: { processManager.oscopePath = $0 } + ), + isDirectory: false + ) + HStack { + Spacer() + Button("Done", action: dismiss).keyboardShortcut(.defaultAction) + } + } + .padding(20) + .frame(width: 520) + } +} + +private struct PathField: View { + let label: String + @Binding var path: String + let isDirectory: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Text(label).font(.caption).foregroundColor(.secondary) + HStack { + TextField("", text: $path) + .textFieldStyle(.roundedBorder) + Button("Choose…") { + let panel = NSOpenPanel() + panel.canChooseFiles = !isDirectory + panel.canChooseDirectories = isDirectory + panel.allowsMultipleSelection = false + panel.directoryURL = URL(fileURLWithPath: path).deletingLastPathComponent() + if panel.runModal() == .OK, let url = panel.url { + path = url.path + } + } + } + } + } +} diff --git a/launcher/Sources/AVLiveLauncher/ProcessManager.swift b/launcher/Sources/AVLiveLauncher/ProcessManager.swift new file mode 100644 index 0000000..e9536a4 --- /dev/null +++ b/launcher/Sources/AVLiveLauncher/ProcessManager.swift @@ -0,0 +1,155 @@ +import Combine +import Foundation + +struct LogLine: Identifiable, Equatable { + let id = UUID() + let timestamp = Date() + let source: String + let text: String +} + +final class ProcessManager: ObservableObject { + // Observable state + @Published var sclangRunning = false + @Published var oscopeRunning = false + @Published private(set) var logs: [LogLine] = [] + + // Persisted paths (UserDefaults, key/value) + @Published var sclangPath: String { didSet { defaults.set(sclangPath, forKey: "sclangPath") } } + @Published var soundAlgoLoadFile: String { didSet { defaults.set(soundAlgoLoadFile, forKey: "soundAlgoLoadFile") } } + @Published var oscopePath: String { didSet { defaults.set(oscopePath, forKey: "oscopePath") } } + + private let defaults = UserDefaults.standard + private var sclangProc: Process? + private var oscopeProc: Process? + private let logQueue = DispatchQueue(label: "cc.saillant.avlive.log") + private let maxLogLines = 2000 + + init() { + let home = FileManager.default.homeDirectoryForCurrentUser.path + sclangPath = defaults.string(forKey: "sclangPath") + ?? "/Applications/SuperCollider.app/Contents/MacOS/sclang" + soundAlgoLoadFile = defaults.string(forKey: "soundAlgoLoadFile") + ?? "\(home)/Documents/Projets/AV-Live/sound_algo/00_load.scd" + oscopePath = defaults.string(forKey: "oscopePath") + ?? "\(home)/Documents/Projets/AV-Live/oscope-of/bin/oscope-of" + } + + // MARK: - sclang + + func startSclang() { + guard sclangProc == nil else { return } + guard FileManager.default.fileExists(atPath: sclangPath) else { + append(source: "launcher", text: "sclang not found at \(sclangPath)") + return + } + guard FileManager.default.fileExists(atPath: soundAlgoLoadFile) else { + append(source: "launcher", text: "load file not found at \(soundAlgoLoadFile)") + return + } + let p = Process() + p.executableURL = URL(fileURLWithPath: sclangPath) + p.arguments = [soundAlgoLoadFile] + // sclang resolves relative paths from the current working dir; cd to the load file's dir + p.currentDirectoryURL = URL(fileURLWithPath: soundAlgoLoadFile).deletingLastPathComponent() + attach(process: p, label: "sclang") + do { + try p.run() + sclangProc = p + DispatchQueue.main.async { self.sclangRunning = true } + p.terminationHandler = { [weak self] proc in + self?.append(source: "sclang", text: "exited with status \(proc.terminationStatus)") + DispatchQueue.main.async { + self?.sclangProc = nil + self?.sclangRunning = false + } + } + append(source: "launcher", text: "started sclang \(sclangPath) \(soundAlgoLoadFile)") + } catch { + append(source: "launcher", text: "failed to start sclang: \(error)") + } + } + + func stopSclang() { + sclangProc?.terminate() + } + + // MARK: - oscope-of + + func startOscope() { + guard oscopeProc == nil else { return } + guard FileManager.default.fileExists(atPath: oscopePath) else { + append(source: "launcher", text: "oscope-of binary not found at \(oscopePath)") + return + } + let p = Process() + p.executableURL = URL(fileURLWithPath: oscopePath) + p.currentDirectoryURL = URL(fileURLWithPath: oscopePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + attach(process: p, label: "oscope") + do { + try p.run() + oscopeProc = p + DispatchQueue.main.async { self.oscopeRunning = true } + p.terminationHandler = { [weak self] proc in + self?.append(source: "oscope", text: "exited with status \(proc.terminationStatus)") + DispatchQueue.main.async { + self?.oscopeProc = nil + self?.oscopeRunning = false + } + } + append(source: "launcher", text: "started oscope-of \(oscopePath)") + } catch { + append(source: "launcher", text: "failed to start oscope-of: \(error)") + } + } + + func stopOscope() { + oscopeProc?.terminate() + } + + // MARK: - utilities + + func stopAll() { + sclangProc?.terminate() + oscopeProc?.terminate() + } + + func clearLogs() { + DispatchQueue.main.async { self.logs.removeAll() } + } + + private func attach(process: Process, label: String) { + let outPipe = Pipe() + let errPipe = Pipe() + process.standardOutput = outPipe + process.standardError = errPipe + attachReader(outPipe.fileHandleForReading, source: label) + attachReader(errPipe.fileHandleForReading, source: label + "!") + } + + private func attachReader(_ fh: FileHandle, source: String) { + fh.readabilityHandler = { [weak self] handle in + let data = handle.availableData + guard !data.isEmpty, + let str = String(data: data, encoding: .utf8) else { return } + for raw in str.split(separator: "\n", omittingEmptySubsequences: false) { + let line = String(raw) + if !line.isEmpty { + self?.append(source: source, text: line) + } + } + } + } + + private func append(source: String, text: String) { + let entry = LogLine(source: source, text: text) + DispatchQueue.main.async { + self.logs.append(entry) + if self.logs.count > self.maxLogLines { + self.logs.removeFirst(self.logs.count - self.maxLogLines) + } + } + } +} diff --git a/launcher/build.sh b/launcher/build.sh new file mode 100755 index 0000000..a7b2114 --- /dev/null +++ b/launcher/build.sh @@ -0,0 +1,28 @@ +#!/bin/sh +# Build AVLiveLauncher and bundle it into a .app +set -eu +cd "$(dirname "$0")" + +echo "==> swift build (release)" +swift build -c release + +BIN_NAME="AVLiveLauncher" +BIN_PATH=".build/release/${BIN_NAME}" +APP_PATH="build/${BIN_NAME}.app" + +if [ ! -x "${BIN_PATH}" ]; then + echo "Build did not produce ${BIN_PATH}" >&2 + exit 1 +fi + +echo "==> bundling ${APP_PATH}" +rm -rf "${APP_PATH}" +mkdir -p "${APP_PATH}/Contents/MacOS" "${APP_PATH}/Contents/Resources" +cp "${BIN_PATH}" "${APP_PATH}/Contents/MacOS/${BIN_NAME}" +cp Resources/Info.plist "${APP_PATH}/Contents/Info.plist" + +# Ad-hoc sign so Gatekeeper at least lets the user open it manually +codesign --force --sign - "${APP_PATH}" 2>/dev/null || true + +echo "==> done" +echo " open ${APP_PATH}"