Files
AV-Live/launcher/Sources/AVLiveLauncher/ProcessManager.swift
T
L'électron rare 0ee7ddddcd fix(launcher): disable body spawn post-archive
Legacy SwiftPM target archived to launcher/_archive-AV-Live-Body/.
New native Xcode app lives at avlivebody-mac/; no swift run path.
startBodyApp now logs + no-ops with FIXME(rewrite-2026-05-18).
2026-05-18 22:05:21 +02:00

831 lines
35 KiB
Swift

import AppKit
import Combine
import Foundation
struct LogLine: Identifiable, Equatable {
let id = UUID()
let timestamp = Date()
let source: String
let text: String
}
enum LaunchMode: String, CaseIterable, Identifiable {
case full // sclang + oscope + web + data_feeds
case dataOnly // uniquement data_feeds (open data + pose YOLO)
case bodyMesh // Multi-HMR worker + AV-Live-Body (mesh seul)
var id: String { rawValue }
var displayName: String {
switch self {
case .full: return "Full AV-Live"
case .dataOnly: return "Data-only (SC + Metal viz + feeds + web)"
case .bodyMesh: return "Body Mesh (Multi-HMR + RealityKit)"
}
}
}
final class ProcessManager: ObservableObject {
// Observable state
@Published var sclangRunning = false
@Published var oscopeRunning = false
@Published var webRunning = false
@Published var dataFeedsRunning = 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") } }
@Published var nodePath: String { didSet { defaults.set(nodePath, forKey: "nodePath") } }
@Published var webServerScript: String { didSet { defaults.set(webServerScript, forKey: "webServerScript") } }
@Published var webPort: Int { didSet { defaults.set(webPort, forKey: "webPort") } }
@Published var uvPath: String { didSet { defaults.set(uvPath, forKey: "uvPath") } }
@Published var dataFeedsDir: String { didSet { defaults.set(dataFeedsDir, forKey: "dataFeedsDir") } }
@Published var metalVizDir: String { didSet { defaults.set(metalVizDir, forKey: "metalVizDir") } }
@Published var autoStart: Bool { didSet { defaults.set(autoStart, forKey: "autoStart") } }
@Published var autoOpenBrowser: Bool { didSet { defaults.set(autoOpenBrowser, forKey: "autoOpenBrowser") } }
@Published var autoStartDataFeeds: Bool { didSet { defaults.set(autoStartDataFeeds, forKey: "autoStartDataFeeds") } }
@Published var useMultiHMR: Bool { didSet { defaults.set(useMultiHMR, forKey: "useMultiHMR") } }
/// Process distinct du visualizer oF : en data-only on lance le
/// visualizer Python+Metal (data_only_viz) au lieu de oscope-of.
private var metalVizProc: Process?
@Published var metalVizRunning = false
/// Nom de la source open-data du preset actif (USGS, Blitz, Wind,
/// Kp/Bz, X-ray, OpenSky, Bsky, Pose, Cosmos). Affiche en surbrillance
/// le bouton correspondant dans le panel data-only.
@Published var activePreset: String = ""
private var metalVizWantsRestart = false
/// "full" (sclang + oscope + web + data_feeds) ou "data-only"
/// (uniquement data_feeds avec config.data-only.toml). Persiste sur disque.
/// Au changement de mode, on coupe les process incompatibles pour
/// eviter qu'ils restent zombies hors UI (ex: web tournant en .dataOnly).
@Published var mode: LaunchMode {
didSet {
defaults.set(mode.rawValue, forKey: "mode")
if oldValue != mode {
applyModeTransition(from: oldValue, to: mode)
}
}
}
private func applyModeTransition(from old: LaunchMode, to new: LaunchMode) {
append(source: "launcher",
text: "mode switched: \(old.rawValue)\(new.rawValue)")
switch new {
case .dataOnly:
// Data-only = SC + Metal viz + feeds + web (pas d'oscope-of)
if oscopeRunning { stopOscope() }
if dataFeedsRunning { restartDataFeeds() }
case .full:
break
case .bodyMesh:
// Mode bodyMesh : Multi-HMR headless + AV-Live-Body, MAIS on
// garde SC + data_feeds (audio + sonification des feeds).
// On coupe seulement oscope-of et le web UI.
if oscopeRunning { stopOscope() }
if webRunning { stopWeb() }
if dataFeedsRunning { restartDataFeeds() } // re-load config data-only
useMultiHMR = true
}
}
private let defaults = UserDefaults.standard
private var sclangProc: Process?
private var oscopeProc: Process?
private var webProc: Process?
private var dataFeedsProc: Process?
private var dataFeedsWantsRestart = false
private var sclangWantsRestart = false
let osc = OSCSender(host: "127.0.0.1", port: 57121)
/// OSC sender dedie au visualizer Metal (data_only_viz) sur 57123,
/// pour les /control/vizMode et autres commandes visuelles directes.
let oscViz = OSCSender(host: "127.0.0.1", port: 57123)
private let logQueue = DispatchQueue(label: "cc.saillant.avlive.log")
private let maxLogLines = 2000
init() {
let fm = FileManager.default
let home = fm.homeDirectoryForCurrentUser.path
// Discover the AV-Live tree by trying a list of likely locations.
// First match wins. Includes the path derived from the .app bundle
// when the launcher is installed inside an AV-Live checkout.
let avLiveCandidates: [String] = {
var c: [String] = []
// Walk up from the bundle: <root>/launcher/build/AVLiveLauncher.app
let bundleParents = (0..<5).reduce(into: [URL]()) { acc, _ in
let last = acc.last ?? Bundle.main.bundleURL
acc.append(last.deletingLastPathComponent())
}
for url in bundleParents {
let candidate = url.path
if fm.fileExists(atPath: candidate + "/sound_algo/00_load.scd") {
c.append(candidate)
}
}
// Plus the well-known paths
c.append("\(home)/AV-Live")
c.append("\(home)/Documents/Projets/AV-Live")
return c
}()
let avLive = avLiveCandidates.first(where: {
fm.fileExists(atPath: $0 + "/sound_algo/00_load.scd")
}) ?? "\(home)/AV-Live"
sclangPath = defaults.string(forKey: "sclangPath")
?? "/Applications/SuperCollider.app/Contents/MacOS/sclang"
// Prefer boot.scd (single-block, auto-executable by sclang CLI) over
// 00_load.scd which is split into ~32 IDE-only Cmd+Enter blocks.
let bootCandidate = "\(avLive)/sound_algo/boot.scd"
let legacyCandidate = "\(avLive)/sound_algo/00_load.scd"
soundAlgoLoadFile = defaults.string(forKey: "soundAlgoLoadFile")
?? (fm.fileExists(atPath: bootCandidate) ? bootCandidate : legacyCandidate)
// openFrameworks Release produces a .app bundle in bin/. Default to the
// executable inside the bundle, with fallback to the bare binary if
// the user built with a custom Makefile target.
let bundled = "\(avLive)/oscope-of/bin/oscope-of.app/Contents/MacOS/oscope-of"
let bare = "\(avLive)/oscope-of/bin/oscope-of"
let stored = defaults.string(forKey: "oscopePath")
if let s = stored, !s.isEmpty {
oscopePath = s
} else if fm.fileExists(atPath: bundled) {
oscopePath = bundled
} else {
oscopePath = bare
}
webServerScript = defaults.string(forKey: "webServerScript")
?? "\(avLive)/sound_algo/web/server.js"
// Discover node : try /usr/local/bin (Intel Homebrew or stock pkg),
// /opt/homebrew/bin (Apple Silicon Homebrew), then `which node`
// resolved against an extended PATH.
let nodeCandidates = ["/usr/local/bin/node", "/opt/homebrew/bin/node",
"/usr/bin/node"]
nodePath = defaults.string(forKey: "nodePath")
?? (nodeCandidates.first(where: { fm.isExecutableFile(atPath: $0) })
?? "/usr/local/bin/node")
webPort = (defaults.object(forKey: "webPort") as? Int) ?? 3000
autoStart = (defaults.object(forKey: "autoStart") as? Bool) ?? true
autoOpenBrowser = (defaults.object(forKey: "autoOpenBrowser") as? Bool) ?? true
// uv (Astral) usually installed via curl|sh or brew. Use ~/.local/bin
// for the curl installer, /opt/homebrew/bin for Apple Silicon brew.
let uvCandidates = ["/opt/homebrew/bin/uv", "/usr/local/bin/uv",
"\(home)/.local/bin/uv", "/usr/bin/uv"]
uvPath = defaults.string(forKey: "uvPath")
?? (uvCandidates.first(where: { fm.isExecutableFile(atPath: $0) })
?? "/opt/homebrew/bin/uv")
dataFeedsDir = defaults.string(forKey: "dataFeedsDir")
?? "\(avLive)/data_feeds"
metalVizDir = defaults.string(forKey: "metalVizDir")
?? "\(avLive)/data_only_viz"
autoStartDataFeeds = (defaults.object(forKey: "autoStartDataFeeds") as? Bool) ?? false
useMultiHMR = defaults.bool(forKey: "useMultiHMR")
mode = LaunchMode(rawValue: defaults.string(forKey: "mode") ?? "")
?? .full
}
/// Start everything that's currently stopped. Used by AppDelegate on
/// launch when autoStart is enabled.
/// - .full : sclang + oscope + web + (optionnel) data_feeds
/// - .dataOnly : sclang + oscope + data_feeds (pas de web/album)
func startAll() {
if mode == .dataOnly {
// Data-only : SC + Python Metal viz + data_feeds bridge.
// PAS de oscope-of (oF) remplace par data_only_viz Python.
if !sclangRunning { startSclang() }
DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { [weak self] in
guard let self = self else { return }
if !self.metalVizRunning { self.startMetalViz() }
}
DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) { [weak self] in
guard let self = self else { return }
if !self.dataFeedsRunning { self.startDataFeeds() }
}
return
}
if !sclangRunning { startSclang() }
// Stagger so sclang has a head start booting scsynth before
// oscope-of opens its OSC listener
DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { [weak self] in
guard let self = self else { return }
if !self.oscopeRunning { self.startOscope() }
}
DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) { [weak self] in
guard let self = self else { return }
if !self.webRunning { self.startWeb() }
}
if autoStartDataFeeds {
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { [weak self] in
guard let self = self else { return }
if !self.dataFeedsRunning { self.startDataFeeds() }
}
}
if autoOpenBrowser {
DispatchQueue.main.asyncAfter(deadline: .now() + 3.5) { [weak self] in
guard let self = self else { return }
if let url = URL(string: "http://localhost:\(self.webPort)/control/") {
NSWorkspace.shared.open(url)
}
}
}
}
// MARK: - web server
func startWeb() {
guard webProc == nil else { return }
guard FileManager.default.isExecutableFile(atPath: nodePath) else {
append(source: "launcher", text: "node not found at \(nodePath)")
return
}
guard FileManager.default.fileExists(atPath: webServerScript) else {
append(source: "launcher", text: "server.js not found at \(webServerScript)")
return
}
let p = Process()
p.executableURL = URL(fileURLWithPath: nodePath)
p.arguments = [webServerScript]
p.currentDirectoryURL = URL(fileURLWithPath: webServerScript)
.deletingLastPathComponent()
// node needs PATH to resolve helper subprocesses + npm dep binaries
var env = ProcessInfo.processInfo.environment
let extraPath = "/usr/local/bin:/opt/homebrew/bin"
env["PATH"] = (env["PATH"] ?? "/usr/bin:/bin") + ":" + extraPath
env["HTTP_PORT"] = String(webPort)
p.environment = env
attach(process: p, label: "web")
do {
try p.run()
webProc = p
DispatchQueue.main.async { self.webRunning = true }
p.terminationHandler = { [weak self] proc in
let status = proc.terminationStatus
// Toutes les lectures/ecritures des flags d'etat passent par
// le main thread : pas de data race avec restartWeb().
DispatchQueue.main.async {
guard let self = self else { return }
self.append(source: "web", text: "exited with status \(status)")
self.webProc = nil
self.webRunning = false
if self.webWantsRestart {
self.webWantsRestart = false
DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) {
self.startWeb()
}
}
}
}
append(source: "launcher", text: "started web server on :\(webPort)")
} catch {
append(source: "launcher", text: "failed to start web server: \(error)")
}
}
func stopWeb() {
webWantsRestart = false
webProc?.terminate()
}
private var webWantsRestart = false
// ----------------- Web data-only dashboard ------------------------
private var dataWebProc: Process?
@Published var dataWebRunning = false
let dataWebPort: Int = 3211
/// Lance le dashboard web data-only (Express + WS bridge sur :3211,
/// ecoute OSC UDP :57124). Necessite node + le dossier
/// `data_only_viz/web/`.
func startDataWeb() {
guard dataWebProc == nil else { return }
guard FileManager.default.isExecutableFile(atPath: nodePath) else {
append(source: "launcher", text: "node not found at \(nodePath)")
return
}
let script = URL(fileURLWithPath: metalVizDir)
.appendingPathComponent("web/server.js").path
guard FileManager.default.fileExists(atPath: script) else {
append(source: "launcher",
text: "data-only web server.js manquant a \(script)")
return
}
let p = Process()
p.executableURL = URL(fileURLWithPath: nodePath)
p.arguments = [script]
p.currentDirectoryURL = URL(fileURLWithPath: script)
.deletingLastPathComponent()
var env = ProcessInfo.processInfo.environment
env["PATH"] = (env["PATH"] ?? "/usr/bin:/bin")
+ ":/usr/local/bin:/opt/homebrew/bin"
env["HTTP_PORT"] = String(dataWebPort)
p.environment = env
attach(process: p, label: "dataweb")
do {
try p.run()
dataWebProc = p
DispatchQueue.main.async { self.dataWebRunning = true }
p.terminationHandler = { [weak self] _ in
DispatchQueue.main.async {
self?.dataWebProc = nil
self?.dataWebRunning = false
}
}
append(source: "launcher",
text: "started data-only web on :\(dataWebPort)")
} catch {
append(source: "launcher",
text: "data-only web start failed: \(error)")
}
}
func stopDataWeb() {
dataWebProc?.terminate()
}
func openDataDashboard() {
if let url = URL(string: "http://127.0.0.1:\(dataWebPort)/dashboard.html") {
NSWorkspace.shared.open(url)
}
}
func openDataMap() {
if let url = URL(string: "http://127.0.0.1:\(dataWebPort)/map.html") {
NSWorkspace.shared.open(url)
}
}
func restartWeb() {
guard webProc != nil else { startWeb(); return }
append(source: "launcher", text: "restarting web server…")
webWantsRestart = true
webProc?.terminate()
webWantsRestart = true
}
func openBrowser() {
if let url = URL(string: "http://localhost:\(webPort)/control/") {
NSWorkspace.shared.open(url)
}
}
func openHydra() {
if let url = URL(string: "http://localhost:\(webPort)/hydra/") {
NSWorkspace.shared.open(url)
}
}
// MARK: - Album catalog (filesystem-derived)
struct Album: Identifiable {
let letter: String
let slug: String // e.g. "acid_journey"
let displayTitle: String // e.g. "Acid Journey"
var id: String { letter }
}
/// Scans <avLive>/sound_algo/tracks/ for X_slug subdirectories and
/// returns an Album entry per letter, sorted alphabetically.
func discoverAlbums() -> [Album] {
let fm = FileManager.default
let tracksRoot = URL(fileURLWithPath: soundAlgoLoadFile)
.deletingLastPathComponent()
.appendingPathComponent("tracks", isDirectory: true)
guard let entries = try? fm.contentsOfDirectory(
atPath: tracksRoot.path) else {
return []
}
var albums: [Album] = []
for name in entries {
// X_slug pattern with X = single uppercase letter
guard name.count >= 3,
name[name.index(name.startIndex, offsetBy: 1)] == "_",
let first = name.first, first.isUppercase, first.isLetter else {
continue
}
let dir = tracksRoot.appendingPathComponent(name).path
var isDir: ObjCBool = false
guard fm.fileExists(atPath: dir, isDirectory: &isDir),
isDir.boolValue else { continue }
let letter = String(first)
let slug = String(name.dropFirst(2))
let title = slug.split(separator: "_")
.map { $0.prefix(1).uppercased() + $0.dropFirst() }
.joined(separator: " ")
albums.append(Album(letter: letter, slug: slug, displayTitle: title))
}
return albums.sorted { $0.letter < $1.letter }
}
func playAlbum(_ letter: String, gap: Int = 8) {
osc.send("/control/playAlbum", letter, gap)
}
func playTrack(_ letter: String, _ n: Int) {
osc.send("/control/playTrack", letter, n)
}
func stopAlbum() {
osc.send("/control/stopAlbum")
}
// 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
}
// En mode data-only on lance le patch dedie data_only/boot.scd :
// engine minimaliste (FX rack), 10 SynthDefs \\do_*, 9 scenes, et
// demarrage automatique de oscope-of. AUCUNE dependance vers la
// palette live principale.
var loadFile = soundAlgoLoadFile
if mode == .dataOnly {
let dataOnlyBoot = URL(fileURLWithPath: soundAlgoLoadFile)
.deletingLastPathComponent()
.appendingPathComponent("data_only/boot.scd").path
if FileManager.default.fileExists(atPath: dataOnlyBoot) {
loadFile = dataOnlyBoot
} else {
// Fallback : ancien boot.data-only.scd si encore present
let legacy = URL(fileURLWithPath: soundAlgoLoadFile)
.deletingLastPathComponent()
.appendingPathComponent("boot.data-only.scd").path
if FileManager.default.fileExists(atPath: legacy) {
loadFile = legacy
}
}
}
guard FileManager.default.fileExists(atPath: loadFile) else {
append(source: "launcher", text: "load file not found at \(loadFile)")
return
}
let p = Process()
p.executableURL = URL(fileURLWithPath: sclangPath)
p.arguments = [loadFile]
// sclang resolves relative paths from the current working dir; cd to the load file's dir
p.currentDirectoryURL = URL(fileURLWithPath: loadFile).deletingLastPathComponent()
attach(process: p, label: "sclang")
do {
try p.run()
sclangProc = p
DispatchQueue.main.async { self.sclangRunning = true }
p.terminationHandler = { [weak self] proc in
let status = proc.terminationStatus
DispatchQueue.main.async {
guard let self = self else { return }
self.append(source: "sclang", text: "exited with status \(status)")
self.sclangProc = nil
self.sclangRunning = false
self.maybeRestartSclang()
}
}
append(source: "launcher", text: "started sclang \(sclangPath) \(loadFile)")
} catch {
append(source: "launcher", text: "failed to start sclang: \(error)")
}
}
func stopSclang() {
sclangWantsRestart = false
sclangProc?.terminate()
// sclang's terminate doesn't always tear scsynth down cleanly
// run a belt-and-braces pkill so the next boot can grab :57110.
let kill = Process()
kill.executableURL = URL(fileURLWithPath: "/usr/bin/pkill")
kill.arguments = ["-f", "scsynth"]
try? kill.run()
}
/// Restart sclang : fire and forget. Sets the auto-restart flag,
/// terminates the current process. terminationHandler observes the
/// flag and re-spawns after scsynth is reaped.
func restartSclang() {
guard sclangProc != nil else { startSclang(); return }
append(source: "launcher", text: "restarting sclang…")
sclangWantsRestart = true
stopSclang()
// stopSclang resets the flag re-set it because we want restart
sclangWantsRestart = true
}
/// Watches both .av-live-restart-sclang and .av-live-restart-web
/// sentinel files. Triggered by the bridge's reboot handlers.
private var sentinelTimer: Timer?
func startSentinelWatcher() {
let home = FileManager.default.homeDirectoryForCurrentUser
let sclangSentinel = home.appendingPathComponent(".av-live-restart-sclang").path
let webSentinel = home.appendingPathComponent(".av-live-restart-web").path
sentinelTimer?.invalidate()
sentinelTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
if FileManager.default.fileExists(atPath: sclangSentinel) {
try? FileManager.default.removeItem(atPath: sclangSentinel)
self?.append(source: "launcher", text: "sentinel detected — restarting sclang")
self?.restartSclang()
}
if FileManager.default.fileExists(atPath: webSentinel) {
try? FileManager.default.removeItem(atPath: webSentinel)
self?.append(source: "launcher", text: "sentinel detected — restarting web")
self?.restartWeb()
}
}
}
private func maybeRestartSclang() {
guard sclangWantsRestart else { return }
sclangWantsRestart = false
// Small delay so scsynth pkill finishes and the audio device is free
DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { [weak self] in
self?.startSclang()
}
}
// 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 = oscopeProjectRoot(from: oscopePath)
attach(process: p, label: "oscope")
do {
try p.run()
oscopeProc = p
DispatchQueue.main.async { self.oscopeRunning = true }
p.terminationHandler = { [weak self] proc in
let status = proc.terminationStatus
DispatchQueue.main.async {
guard let self = self else { return }
self.append(source: "oscope", text: "exited with status \(status)")
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()
}
/// openFrameworks expects the working dir to be the project root (the
/// directory containing `bin/`) so it can find `bin/data/`. The binary
/// path can be either `<root>/bin/oscope-of` (bare) or
/// `<root>/bin/oscope-of.app/Contents/MacOS/oscope-of` (release bundle)
/// walk up to the parent of `bin/` either way.
private func oscopeProjectRoot(from binaryPath: String) -> URL {
var url = URL(fileURLWithPath: binaryPath).deletingLastPathComponent()
while url.path != "/" {
if url.lastPathComponent == "bin" {
return url.deletingLastPathComponent()
}
url.deleteLastPathComponent()
}
return URL(fileURLWithPath: binaryPath).deletingLastPathComponent()
}
// MARK: - data_feeds bridge (Python OSC)
/// Lance `uv run python bridge.py -v` depuis `dataFeedsDir`. uv s'occupe
/// de creer/sync le venv au premier run. Le pont diffuse sur :57121
/// (sclang) et :57123 (oF) selon config.toml.
func startDataFeeds() {
guard dataFeedsProc == nil else { return }
guard FileManager.default.isExecutableFile(atPath: uvPath) else {
append(source: "launcher", text: "uv not found at \(uvPath) — install with: curl -LsSf https://astral.sh/uv/install.sh | sh")
return
}
let bridgePy = dataFeedsDir + "/bridge.py"
guard FileManager.default.fileExists(atPath: bridgePy) else {
append(source: "launcher", text: "bridge.py not found at \(bridgePy)")
return
}
// Selectionne le profil de config : data-only utilise un toml dedie
// qui n'active QUE les flux opendata + pose YOLO. Si le fichier
// n'existe pas, fallback silencieux sur config.toml standard.
let dataOnlyCfg = dataFeedsDir + "/config.data-only.toml"
let useDataOnly = (mode == .dataOnly)
&& FileManager.default.fileExists(atPath: dataOnlyCfg)
var args = ["run", "python", "bridge.py", "-v"]
if useDataOnly { args.append(contentsOf: ["-c", "config.data-only.toml"]) }
let p = Process()
p.executableURL = URL(fileURLWithPath: uvPath)
p.arguments = args
p.currentDirectoryURL = URL(fileURLWithPath: dataFeedsDir)
// uv resout les binaries depuis ~/.local/bin et /opt/homebrew/bin
var env = ProcessInfo.processInfo.environment
env["PATH"] = (env["PATH"] ?? "/usr/bin:/bin")
+ ":/usr/local/bin:/opt/homebrew/bin:\(env["HOME"] ?? "")/.local/bin"
// Force la couleur off pour des logs propres dans la TextView
env["NO_COLOR"] = "1"
p.environment = env
attach(process: p, label: "feeds")
do {
try p.run()
dataFeedsProc = p
DispatchQueue.main.async { self.dataFeedsRunning = true }
p.terminationHandler = { [weak self] proc in
let status = proc.terminationStatus
DispatchQueue.main.async {
guard let self = self else { return }
self.append(source: "feeds", text: "exited with status \(status)")
self.dataFeedsProc = nil
self.dataFeedsRunning = false
if self.dataFeedsWantsRestart {
self.dataFeedsWantsRestart = false
DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) {
self.startDataFeeds()
}
}
}
}
append(source: "launcher", text: "started data_feeds bridge (\(dataFeedsDir))")
} catch {
append(source: "launcher", text: "failed to start data_feeds: \(error)")
}
}
func stopDataFeeds() {
dataFeedsWantsRestart = false
dataFeedsProc?.terminate()
}
func restartDataFeeds() {
guard dataFeedsProc != nil else { startDataFeeds(); return }
append(source: "launcher", text: "restarting data_feeds…")
dataFeedsWantsRestart = true
dataFeedsProc?.terminate()
dataFeedsWantsRestart = true
}
// MARK: - Metal viz (Python+pyobjc, mode data-only)
/// Lance `uv run python -m data_only_viz.main` dans data_only_viz/.
/// uv synchronise le venv (pyobjc-* + python-osc) au premier run.
func startMetalViz() {
guard metalVizProc == nil else { return }
guard FileManager.default.isExecutableFile(atPath: uvPath) else {
append(source: "launcher", text: "uv not found at \(uvPath)")
return
}
let main = metalVizDir + "/main.py"
guard FileManager.default.fileExists(atPath: main) else {
append(source: "launcher", text: "data_only_viz/main.py not found at \(main)")
return
}
let p = Process()
p.executableURL = URL(fileURLWithPath: uvPath)
// `--project` pointe uv sur le pyproject.toml de data_only_viz/.
// cwd au parent pour que `-m data_only_viz.main` resolve les
// imports relatifs `from .osc_listener import ...`.
// `--pose` active la captation webcam + YOLOv8-pose dans le meme
// process (le bundle launcher fournit le contexte TCC camera).
var args = ["--project", metalVizDir,
"run", "python", "-m", "data_only_viz.main",
"-v", "--pose", "--fullscreen"]
if useMultiHMR {
args.append("--multi-hmr")
}
p.arguments = args
p.currentDirectoryURL = URL(fileURLWithPath: metalVizDir).deletingLastPathComponent()
var env = ProcessInfo.processInfo.environment
env["PATH"] = (env["PATH"] ?? "/usr/bin:/bin")
+ ":/usr/local/bin:/opt/homebrew/bin:\(env["HOME"] ?? "")/.local/bin"
env["NO_COLOR"] = "1"
p.environment = env
attach(process: p, label: "metalviz")
do {
try p.run()
metalVizProc = p
DispatchQueue.main.async { self.metalVizRunning = true }
p.terminationHandler = { [weak self] proc in
let status = proc.terminationStatus
DispatchQueue.main.async {
guard let self = self else { return }
self.append(source: "metalviz", text: "exited with status \(status)")
self.metalVizProc = nil
self.metalVizRunning = false
if self.metalVizWantsRestart {
self.metalVizWantsRestart = false
DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) {
self.startMetalViz()
}
}
}
}
append(source: "launcher", text: "started metal viz (\(metalVizDir))")
if useMultiHMR {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) {
[weak self] in self?.startBodyApp()
}
}
} catch {
append(source: "launcher", text: "failed to start metal viz: \(error)")
}
}
func stopMetalViz() {
metalVizWantsRestart = false
metalVizProc?.terminate()
}
func restartMetalViz() {
guard metalVizProc != nil else { startMetalViz(); return }
append(source: "launcher", text: "restarting metal viz…")
metalVizWantsRestart = true
metalVizProc?.terminate()
metalVizWantsRestart = true
}
// MARK: - AV-Live-Body (Multi-HMR mesh renderer)
private var bodyAppProc: Process?
@Published var bodyAppRunning = false
/// Lance l'app SwiftPM AV-Live-Body (RealityKit) qui ecoute les
/// vertices SMPL-X sur :57130. Necessite useMultiHMR=true.
func startBodyApp() {
// FIXME(rewrite-2026-05-18): wire to avlivebody-mac/AVLiveBody.app
// once installation path is decided. The legacy SwiftPM target at
// launcher/AV-Live-Body has been archived to
// launcher/_archive-AV-Live-Body/ and superseded by the native
// Xcode app at avlivebody-mac/ (xcodegen + xcodebuild). There is
// no `swift run` equivalent for an Xcode app target, so the
// spawn path is disabled until we settle on a post-build .app
// install location (DerivedData vs ~/Applications vs bundled).
NSLog("AVLiveBody is now standalone at avlivebody-mac/ — open from Xcode (build target AVLiveBody)")
append(source: "launcher",
text: "AVLiveBody spawn disabled — open avlivebody-mac/ in Xcode")
}
func stopBodyApp() {
bodyAppProc?.terminate()
}
// MARK: - utilities
func stopAll() {
sclangProc?.terminate()
oscopeProc?.terminate()
webProc?.terminate()
dataFeedsProc?.terminate()
metalVizProc?.terminate()
bodyAppProc?.terminate()
dataWebProc?.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)
}
}
}
deinit {
sentinelTimer?.invalidate()
sentinelTimer = nil
}
}