From 97c9905ae1005507b014db6ca80262309c0be8c3 Mon Sep 17 00:00:00 2001 From: Samuel Zielke Date: Wed, 21 Oct 2026 15:59:32 +0200 Subject: [PATCH] feat(scenes): control/node remote control, scenes and live-state restore - Coordinator: restores live state on launch, role-based Bonjour server/browser, node adopts commands, control keeps desired config per node and re-sends on reconnect - Scene menu (load, save, save as, export, import), settings (role, interface, login item) - Remote screens with status on the control; node banner - Developer ID signing with hardened runtime and audio-input entitlement - Retry and clear message when macOS blocks Bonjour Co-Authored-By: Claude Opus 5.5 --- App/Musubi.entitlements | 9 + App/Sources/AppSettings.swift | 36 +++ App/Sources/AudioController.swift | 81 +++++++ App/Sources/ContentView.swift | 18 +- App/Sources/Coordinator.swift | 223 ++++++++++++++++++ App/Sources/MusubiApp.swift | 17 +- App/Sources/OutputController.swift | 92 +++++++- App/Sources/SceneMenu.swift | 93 ++++++++ App/Sources/SceneStore.swift | 92 ++++++++ App/Sources/ScreensView.swift | 201 +++++++++++++--- App/Sources/SettingsView.swift | 47 ++++ App/Sources/SoundView.swift | 13 +- .../Sources/Remote/NodeBrowser.swift | 37 ++- .../MusubiKit/Sources/Remote/NodeServer.swift | 29 ++- project.yml | 18 +- 15 files changed, 940 insertions(+), 66 deletions(-) create mode 100644 App/Musubi.entitlements create mode 100644 App/Sources/AppSettings.swift create mode 100644 App/Sources/Coordinator.swift create mode 100644 App/Sources/SceneMenu.swift create mode 100644 App/Sources/SceneStore.swift create mode 100644 App/Sources/SettingsView.swift diff --git a/App/Musubi.entitlements b/App/Musubi.entitlements new file mode 100644 index 0000000..d1afc91 --- /dev/null +++ b/App/Musubi.entitlements @@ -0,0 +1,9 @@ + + + + + + com.apple.security.device.audio-input + + + diff --git a/App/Sources/AppSettings.swift b/App/Sources/AppSettings.swift new file mode 100644 index 0000000..8d7e116 --- /dev/null +++ b/App/Sources/AppSettings.swift @@ -0,0 +1,36 @@ +import Foundation +import MusubiCore +import Observation +import ServiceManagement +import SystemConfiguration + +/// Persistent app settings (UserDefaults). +@MainActor @Observable +final class AppSettings { + var role: Role { + didSet { UserDefaults.standard.set(role.rawValue, forKey: "role") } + } + /// BSD name of the technical network interface, e.g. "en7". + var interfaceName: String { + didSet { UserDefaults.standard.set(interfaceName, forKey: "interfaceName") } + } + + /// Name other Macs see (the computer name, e.g. "Mac mini von Samuel"). + let machineName: String = (SCDynamicStoreCopyComputerName(nil, nil) as String?) ?? Host.current().localizedName ?? "Musubi" + + init() { + role = Role(rawValue: UserDefaults.standard.string(forKey: "role") ?? "") ?? .control + interfaceName = UserDefaults.standard.string(forKey: "interfaceName") ?? "en0" + } + + var launchAtLogin: Bool { + get { SMAppService.mainApp.status == .enabled } + set { + do { + if newValue { try SMAppService.mainApp.register() } else { try SMAppService.mainApp.unregister() } + } catch { + Log.ndi.error("Login item: \(error.localizedDescription, privacy: .public)") + } + } + } +} diff --git a/App/Sources/AudioController.swift b/App/Sources/AudioController.swift index 44440fd..02b6a22 100644 --- a/App/Sources/AudioController.swift +++ b/App/Sources/AudioController.swift @@ -18,6 +18,8 @@ final class AudioController { /// Highest peak per route since the last stats line. private(set) var minutePeaks: [UUID: Float] = [:] var selectedDeviceUID: String? + /// Called after routes or their settings changed (for autosave and scenes). + var onChange: (() -> Void)? private var ndiReceivers: [String: NDIReceiver] = [:] private var meterTimer: Timer? @@ -47,11 +49,90 @@ final class AudioController { } matrix.addRoute(source, deviceUID: device.uid, channel: channel) syncNDIReceivers() + onChange?() } func remove(_ id: UUID) { matrix.removeRoute(id) syncNDIReceivers() + onChange?() + } + + func setGain(_ id: UUID, db: Float) { + matrix.setGain(id, db: db) + onChange?() + } + + func setMuted(_ id: UUID, _ muted: Bool) { + matrix.setMuted(id, muted) + onChange?() + } + + func setSelection(_ id: UUID, _ selection: ChannelSelection) { + matrix.setSelection(id, selection) + onChange?() + } + + // MARK: - Scenes + + /// The current routes as a scene configuration. + func currentAudio() -> [AudioAssignment] { + matrix.routes.map(assignment(for:)) + } + + /// Replaces all routes: keeps identical ones (no gap), updates settings, adds and removes the rest. + /// Devices are matched by UID, then by name; missing devices start as soon as they are plugged in. + func apply(_ audio: [AudioAssignment]) { + let desired = Dictionary(audio.map(resolve).map { ($0.key, $0) }, uniquingKeysWith: { first, _ in first }) + let current = Dictionary(matrix.routes.map { (assignment(for: $0).key, $0) }, uniquingKeysWith: { first, _ in first }) + + for (key, route) in current where desired[key] == nil { + matrix.removeRoute(route.id) + } + for (key, assignment) in desired { + let id: UUID + if let existing = current[key] { + id = existing.id + } else { + let source: AudioMatrix.SourceRef = switch assignment.source { + case .ndi(let name): .ndi(name: name) + case .input(let uid, _): .input(deviceUID: uid) + } + id = matrix.addRoute(source, deviceUID: assignment.deviceUID, channel: assignment.channel, selection: assignment.selection) + } + matrix.setSelection(id, assignment.selection) + matrix.setGain(id, db: assignment.gainDB) + matrix.setMuted(id, assignment.muted) + } + if let first = audio.first { + selectedDeviceUID = resolve(first).deviceUID + } + syncNDIReceivers() + onChange?() + } + + private func assignment(for route: AudioMatrix.Route) -> AudioAssignment { + let source: AudioAssignment.SourceSpec = switch route.source { + case .ndi(let name): .ndi(name: name) + case .input(let uid): .input(uid: uid, name: matrix.devices.first { $0.uid == uid }?.name ?? uid) + } + let deviceName = matrix.devices.first { $0.uid == route.deviceUID }?.name ?? route.deviceUID + return AudioAssignment(source: source, deviceUID: route.deviceUID, deviceName: deviceName, channel: route.channel, + selection: route.selection, gainDB: route.gainDB, muted: route.muted) + } + + private func resolve(_ assignment: AudioAssignment) -> AudioAssignment { + var resolved = assignment + if !matrix.devices.contains(where: { $0.uid == assignment.deviceUID }), + let byName = matrix.outputDevices.first(where: { $0.name == assignment.deviceName }) { + resolved.deviceUID = byName.uid + } + if case .input(let uid, let name) = assignment.source, + !matrix.devices.contains(where: { $0.uid == uid }), + let byName = matrix.inputDevices.first(where: { $0.name == name }) { + resolved.source = .input(uid: byName.uid, name: name) + } + return resolved } func label(for source: AudioMatrix.SourceRef) -> String { diff --git a/App/Sources/ContentView.swift b/App/Sources/ContentView.swift index 21acecf..20e1254 100644 --- a/App/Sources/ContentView.swift +++ b/App/Sources/ContentView.swift @@ -4,25 +4,31 @@ import SwiftUI struct ContentView: View { let sourceList: SourceListModel - let outputs: OutputController - let audio: AudioController + let coordinator: Coordinator @AppStorage("detailTab") private var tab = DetailTab.screens enum DetailTab: String { case screens, sound } var body: some View { NavigationSplitView { - SourceListView(sourceList: sourceList, audio: audio) + SourceListView(sourceList: sourceList, audio: coordinator.audio) .navigationSplitViewColumnWidth(min: 220, ideal: 260) } detail: { switch tab { - case .screens: ScreensView(outputs: outputs) - case .sound: SoundView(audio: audio, sourceList: sourceList) + case .screens: ScreensView(coordinator: coordinator) + case .sound: SoundView(audio: coordinator.audio, sourceList: sourceList) } } .navigationTitle("Musubi") .navigationSubtitle("NDI rein. Bild, Ton und Stimme raus.") .toolbar { + ToolbarItem(placement: .navigation) { + if coordinator.settings.role == .control { + SceneMenu(coordinator: coordinator) + } else { + Text("Knoten").font(Theme.Font.caption).foregroundStyle(Theme.Color.nezumi) + } + } ToolbarItem(placement: .principal) { Picker("Ansicht", selection: $tab) { Text("Bildschirme").tag(DetailTab.screens) @@ -31,7 +37,7 @@ struct ContentView: View { .pickerStyle(.segmented) .fixedSize() } - if outputs.isSleepBlocked { + if coordinator.outputs.isSleepBlocked { ToolbarItem(placement: .primaryAction) { Text("Ruhezustand blockiert") .font(Theme.Font.caption) diff --git a/App/Sources/Coordinator.swift b/App/Sources/Coordinator.swift new file mode 100644 index 0000000..f6e56f2 --- /dev/null +++ b/App/Sources/Coordinator.swift @@ -0,0 +1,223 @@ +import Foundation +import MusubiCore +import Observation +import Remote + +/// Ties outputs, audio, scenes and remote control together according to the role. +@MainActor @Observable +final class Coordinator { + let settings: AppSettings + let outputs: OutputController + let audio: AudioController + let store = SceneStore() + + private(set) var live = LiveState() + private(set) var nodeServer: NodeServer? + private(set) var browser: NodeBrowser? + + private var statusTimer: Timer? + private var cpu = ProcessCPU() + private var restoring = false + + init(settings: AppSettings, outputs: OutputController, audio: AudioController) { + self.settings = settings + self.outputs = outputs + self.audio = audio + } + + /// Restores the last live state, starts remote control, then applies launch arguments. + func start() async { + live = store.loadLiveState() + restoring = true + outputs.apply(live.local.video) + audio.apply(live.local.audio) + restoring = false + Log.ndi.info("Restored \(self.live.sceneName ?? "live state", privacy: .public): \(self.live.local.video.count) video, \(self.live.local.audio.count) audio") + + outputs.onChange = { [weak self] in self?.localChanged() } + audio.onChange = { [weak self] in self?.localChanged() } + await startRemote() + + outputs.applyLaunchRoutes() + audio.applyLaunchRoutes() + + statusTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in + MainActor.assumeIsolated { self?.sendStatus() } + } + } + + /// (Re)starts the Bonjour side that fits the role; call after role or interface changes. + func startRemote() async { + nodeServer?.stop() + nodeServer = nil + browser?.stop() + browser = nil + + switch settings.role { + case .node: + let server = NodeServer(name: settings.machineName) + server.onCommand = { [weak self] in self?.handle($0) } + nodeServer = server + await server.start(interfaceName: settings.interfaceName) + case .control: + let browser = NodeBrowser(name: settings.machineName) + browser.onNodeConnected = { [weak self] in self?.nodeConnected($0) } + self.browser = browser + await browser.start(interfaceName: settings.interfaceName) + } + } + + var remoteError: String? { nodeServer?.error ?? browser?.error } + + // MARK: - Control: nodes + + /// Shows a source on a node's screen. + func show(_ assignment: VideoAssignment, onNode name: String) { + var config = desiredConfig(forNode: name) + config.video.removeAll { $0.screen == assignment.screen } + config.video.append(assignment) + live.nodes[name] = config + browser?.node(named: name)?.send(.show(assignment)) + markModified() + } + + func stop(_ screen: ScreenRef, onNode name: String) { + var config = desiredConfig(forNode: name) + config.video.removeAll { $0.screen == screen } + live.nodes[name] = config + browser?.node(named: name)?.send(.stop(screen)) + markModified() + } + + private func desiredConfig(forNode name: String) -> MachineConfig { + live.nodes[name] ?? browser?.node(named: name)?.status?.config ?? .empty + } + + /// A node (re)connected: it gets what the control wants, or the control adopts what the node shows. + private func nodeConnected(_ node: NodeBrowser.Node) { + if let desired = live.nodes[node.name] { + node.send(.apply(desired)) + Log.ndi.info("Sent configuration to \(node.name, privacy: .public)") + } else { + adoptWhenStatusArrives(node) + } + } + + private func adoptWhenStatusArrives(_ node: NodeBrowser.Node) { + Task { [weak self] in + for _ in 0..<10 { + try? await Task.sleep(for: .seconds(1)) + guard let self, live.nodes[node.name] == nil else { return } + if let config = node.status?.config { + live.nodes[node.name] = config + store.saveLiveState(live) + return + } + } + } + } + + // MARK: - Node: commands from the control + + private func handle(_ message: RemoteMessage) { + switch message { + case .apply(let config): + outputs.apply(config.video) + audio.apply(config.audio) + case .show(let assignment): + outputs.show(assignment) + case .stop(let screen): + outputs.stop(screen) + case .hello, .status: + break + } + } + + private func sendStatus() { + let cpuPercent = cpu.sample() + guard let nodeServer else { return } + nodeServer.send(NodeStatus( + screens: outputs.screenStatuses(), + config: MachineConfig(video: outputs.desired, audio: audio.currentAudio()), + cpuPercent: cpuPercent, + sleepBlocked: outputs.isSleepBlocked + )) + } + + // MARK: - Scenes (control) + + var currentSceneName: String? { live.sceneName } + var isModified: Bool { live.modified } + + func saveScene(as name: String? = nil) { + var scene = Scene( + id: name == nil ? (live.sceneID ?? UUID()) : UUID(), + name: name ?? live.sceneName ?? "Szene", + control: live.local, + nodes: currentNodeConfigs() + ) + if let name, let existing = store.scenes.first(where: { $0.name == name }) { scene.id = existing.id } + store.save(scene) + live.sceneID = scene.id + live.sceneName = scene.name + live.modified = false + store.saveLiveState(live) + } + + func loadScene(_ scene: Scene) { + restoring = true + outputs.apply(scene.control.video) + audio.apply(scene.control.audio) + restoring = false + live = LiveState(local: scene.control, nodes: scene.nodes, sceneID: scene.id, sceneName: scene.name, modified: false) + for (name, config) in scene.nodes { + browser?.node(named: name)?.send(.apply(config)) + } + store.saveLiveState(live) + Log.ndi.info("Scene \(scene.name, privacy: .public) loaded") + } + + func exportableScene() -> Scene { + Scene(id: live.sceneID ?? UUID(), name: live.sceneName ?? "Szene", control: live.local, nodes: currentNodeConfigs()) + } + + private func currentNodeConfigs() -> [String: MachineConfig] { + var nodes = live.nodes + for node in browser?.nodes ?? [] where nodes[node.name] == nil { + if let config = node.status?.config { nodes[node.name] = config } + } + return nodes + } + + // MARK: - Live state + + private func localChanged() { + live.local = MachineConfig(video: outputs.desired, audio: audio.currentAudio()) + guard !restoring else { return } + markModified() + } + + private func markModified() { + if live.sceneID != nil { live.modified = true } + store.saveLiveState(live) + } +} + +/// CPU usage of this process since the last sample, in percent of one core. +struct ProcessCPU { + private var lastCPU = ProcessCPU.cpuSeconds() + private var lastWall = ProcessInfo.processInfo.systemUptime + + mutating func sample() -> Double { + let cpu = Self.cpuSeconds(), wall = ProcessInfo.processInfo.systemUptime + defer { lastCPU = cpu; lastWall = wall } + return wall > lastWall ? (cpu - lastCPU) / (wall - lastWall) * 100 : 0 + } + + static func cpuSeconds() -> Double { + var usage = rusage() + getrusage(RUSAGE_SELF, &usage) + func seconds(_ t: timeval) -> Double { Double(t.tv_sec) + Double(t.tv_usec) / 1_000_000 } + return seconds(usage.ru_utime) + seconds(usage.ru_stime) + } +} diff --git a/App/Sources/MusubiApp.swift b/App/Sources/MusubiApp.swift index 996fd66..72fc566 100644 --- a/App/Sources/MusubiApp.swift +++ b/App/Sources/MusubiApp.swift @@ -6,20 +6,27 @@ import SwiftUI @main struct MusubiApp: App { @State private var sourceList = SourceListModel() - @State private var outputs = OutputController() - @State private var audio = AudioController() + @State private var coordinator: Coordinator = { + MainActor.assumeIsolated { + Coordinator(settings: AppSettings(), outputs: OutputController(), audio: AudioController()) + } + }() var body: some SwiftUI.Scene { Window("Musubi", id: "main") { - ContentView(sourceList: sourceList, outputs: outputs, audio: audio) + ContentView(sourceList: sourceList, coordinator: coordinator) .preferredColorScheme(.dark) .task { sourceList.start() - outputs.applyLaunchRoutes() - audio.applyLaunchRoutes() + await coordinator.start() } } .defaultSize(width: 960, height: 600) + + Settings { + SettingsView(coordinator: coordinator) + .preferredColorScheme(.dark) + } } } diff --git a/App/Sources/OutputController.swift b/App/Sources/OutputController.swift index cce3c8b..8b7b4b5 100644 --- a/App/Sources/OutputController.swift +++ b/App/Sources/OutputController.swift @@ -1,6 +1,7 @@ import AppKit import MusubiCore import NDIKit +import Remote import Observation import VideoOutput @@ -39,6 +40,11 @@ final class OutputController { private(set) var screens: [ScreenInfo] = [] private(set) var outputs: [CGDirectDisplayID: ActiveOutput] = [:] + /// What should be shown. Assignments for screens that are not connected stay here and start + /// as soon as the screen appears (monitors often come up late after a reboot). + private(set) var desired: [VideoAssignment] = [] + /// Called after the desired configuration changed (for autosave and remote status). + var onChange: (() -> Void)? /// Bumped once per second so views re-read the receiver snapshots. private(set) var tick = 0 @@ -58,9 +64,79 @@ final class OutputController { } } + /// Shows `source` on a screen and remembers it. func show(_ source: Source, on displayID: CGDirectDisplayID) { + guard let ref = screenRef(for: displayID) else { return } + show(VideoAssignment(screen: ref, source: source)) + } + + func show(_ assignment: VideoAssignment) { + desired.removeAll { $0.screen == assignment.screen } + desired.append(assignment) + reconcile() + onChange?() + } + + /// Stops the output on a screen and forgets it. + func stop(_ displayID: CGDirectDisplayID) { + guard let ref = screenRef(for: displayID) else { return halt(displayID) } + stop(ref) + } + + func stop(_ ref: ScreenRef) { + let names = NSScreen.screens.map(\.localizedName) + let index = ScreenMatching.index(of: ref, in: names) + desired.removeAll { $0.screen == ref || (index != nil && ScreenMatching.index(of: $0.screen, in: names) == index) } + reconcile() + onChange?() + } + + /// Replaces the whole video configuration (scene load, remote apply, restore). + func apply(_ video: [VideoAssignment]) { + desired = video + reconcile() + onChange?() + } + + /// Starts and stops outputs so that connected screens match `desired`. + private func reconcile() { + let screens = NSScreen.screens + let resolved = ScreenMatching.resolve(desired, in: screens.map(\.localizedName)) + let wanted = Dictionary(uniqueKeysWithValues: resolved.map { (screens[$0.key].displayID, $0.value) }) + let current = outputs.mapValues(\.source) + let plan = Reconcile.plan(current: current, desired: wanted) + plan.stop.forEach(halt) + for (displayID, source) in plan.start { + start(source, on: displayID) + } + } + + func screenRef(for displayID: CGDirectDisplayID) -> ScreenRef? { + guard let index = NSScreen.screens.firstIndex(where: { $0.displayID == displayID }) else { return nil } + return ScreenRef(name: NSScreen.screens[index].localizedName, index: index) + } + + /// Screens with their outputs, for the remote status. + func screenStatuses() -> [ScreenStatus] { + screens.compactMap { screen in + guard let ref = screenRef(for: screen.id) else { return nil } + let output = outputs[screen.id].map { output -> OutputStatus in + let s = output.snapshot + let state: OutputStatus.State = switch s.state { + case .connecting: .connecting + case .live: .live + case .stalled: .stalled + } + return OutputStatus(source: output.source, state: state, width: s.width, height: s.height, frameRate: s.frameRate, + lostFrames: s.stats.missing + s.stats.droppedByReceiver, lossRate: s.stats.lossRate) + } + return ScreenStatus(screen: ref, width: screen.width, height: screen.height, refreshRate: screen.refreshRate, output: output) + } + } + + private func start(_ source: Source, on displayID: CGDirectDisplayID) { guard let screen = NSScreen.screens.first(where: { $0.displayID == displayID }) else { return } - stop(displayID) + halt(displayID) let window = OutputWindow(screen: screen) let sink = window.sink @@ -81,7 +157,7 @@ final class OutputController { refreshScreens() } - func stop(_ displayID: CGDirectDisplayID) { + private func halt(_ displayID: CGDirectDisplayID) { guard let output = outputs.removeValue(forKey: displayID) else { return } output.receiver.stop() output.window.orderOut(nil) @@ -121,17 +197,19 @@ final class OutputController { } private func screensChanged() { - let connected = Set(NSScreen.screens.map(\.displayID)) - for displayID in outputs.keys where !connected.contains(displayID) { - Log.ndi.notice("Display \(displayID) disconnected, stopping its output") - stop(displayID) - } // Keep windows covering their screen after resolution changes. for output in outputs.values { if let screen = NSScreen.screens.first(where: { $0.displayID == output.displayID }) { output.window.setFrame(screen.frame, display: true) } } + let connected = Set(NSScreen.screens.map(\.displayID)) + for displayID in outputs.keys where !connected.contains(displayID) { + Log.ndi.notice("Display \(displayID) disconnected, output paused until it returns") + halt(displayID) + } + // Screens that came (back) get their remembered output. + reconcile() refreshScreens() } diff --git a/App/Sources/SceneMenu.swift b/App/Sources/SceneMenu.swift new file mode 100644 index 0000000..e3ab0f4 --- /dev/null +++ b/App/Sources/SceneMenu.swift @@ -0,0 +1,93 @@ +import DesignSystem +import MusubiCore +import SwiftUI +import UniformTypeIdentifiers + +/// Toolbar menu: current scene name and scene actions (control only). +struct SceneMenu: View { + let coordinator: Coordinator + @State private var askingName = false + @State private var newName = "" + @State private var exporting = false + @State private var importing = false + @State private var importError: String? + + var body: some View { + Menu { + if !coordinator.store.scenes.isEmpty { + Section("Szene laden") { + ForEach(coordinator.store.scenes) { scene in + Button(scene.name) { coordinator.loadScene(scene) } + } + } + } + Button("Speichern") { + if coordinator.currentSceneName == nil { askName() } else { coordinator.saveScene() } + } + .keyboardShortcut("s") + Button("Speichern als …") { askName() } + .keyboardShortcut("s", modifiers: [.command, .shift]) + Divider() + Button("Exportieren …") { exporting = true } + .disabled(coordinator.currentSceneName == nil) + Button("Importieren …") { importing = true } + } label: { + HStack(spacing: Theme.Space.unit / 2) { + Text(verbatim: coordinator.currentSceneName ?? String(localized: "Keine Szene")) + if coordinator.isModified { + Text("· geändert").foregroundStyle(Theme.Color.nezumi) + } + } + } + .fixedSize() + .help("Szene") + .alert("Szene speichern", isPresented: $askingName) { + TextField("Name", text: $newName) + Button("Speichern") { + let name = newName.trimmingCharacters(in: .whitespaces) + if !name.isEmpty { coordinator.saveScene(as: name) } + } + Button("Abbrechen", role: .cancel) {} + } message: { + Text("Speichert, was Regie und Knoten gerade zeigen und wohin der Ton geht.") + } + .fileExporter(isPresented: $exporting, document: SceneDocument(scene: coordinator.exportableScene()), + contentType: .json, defaultFilename: coordinator.currentSceneName ?? "Szene") { _ in } + .fileImporter(isPresented: $importing, allowedContentTypes: [.json]) { result in + do { + let scene = try coordinator.store.importScene(from: result.get()) + coordinator.loadScene(scene) + } catch { + importError = error.localizedDescription + } + } + .alert("Import fehlgeschlagen", isPresented: Binding(get: { importError != nil }, set: { if !$0 { importError = nil } })) { + Button("OK") {} + } message: { + Text("Die Datei ist keine Musubi-Szene.") + } + } + + private func askName() { + newName = coordinator.currentSceneName ?? "" + askingName = true + } +} + +struct SceneDocument: FileDocument { + static let readableContentTypes: [UTType] = [.json] + var scene: MusubiCore.Scene + + init(scene: MusubiCore.Scene) { self.scene = scene } + + init(configuration: ReadConfiguration) throws { + guard let data = configuration.file.regularFileContents else { throw CocoaError(.fileReadCorruptFile) } + scene = try JSONDecoder().decode(MusubiCore.Scene.self, from: data) + } + + func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + return FileWrapper(regularFileWithContents: try encoder.encode(scene)) + } +} diff --git a/App/Sources/SceneStore.swift b/App/Sources/SceneStore.swift new file mode 100644 index 0000000..473680e --- /dev/null +++ b/App/Sources/SceneStore.swift @@ -0,0 +1,92 @@ +import Foundation +import MusubiCore +import Observation + +/// What is running right now, saved continuously so a restart (or power loss) brings it back. +struct LiveState: Codable, Equatable { + var local = MachineConfig.empty + /// Control only: what each node should show. + var nodes: [String: MachineConfig] = [:] + var sceneID: UUID? + var sceneName: String? + /// The live state differs from the saved scene. + var modified = false +} + +/// Saved scenes and the live state in ~/Library/Application Support/Musubi. +@MainActor @Observable +final class SceneStore { + private(set) var scenes: [Scene] = [] + private let directory: URL + private var saveTask: Task? + + init() { + directory = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + .appending(path: "Musubi", directoryHint: .isDirectory) + try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + scenes = (try? load([Scene].self, from: "scenes.json")) ?? [] + } + + // MARK: Live state + + func loadLiveState() -> LiveState { + (try? load(LiveState.self, from: "live-state.json")) ?? LiveState() + } + + /// Debounced: many small changes (gain slider) end up as one write. + func saveLiveState(_ state: LiveState) { + saveTask?.cancel() + saveTask = Task { [weak self] in + try? await Task.sleep(for: .milliseconds(500)) + guard !Task.isCancelled else { return } + self?.write(state, to: "live-state.json") + } + } + + // MARK: Scenes + + func save(_ scene: Scene) { + if let index = scenes.firstIndex(where: { $0.id == scene.id }) { + scenes[index] = scene + } else { + scenes.append(scene) + scenes.sort { $0.name.localizedStandardCompare($1.name) == .orderedAscending } + } + write(scenes, to: "scenes.json") + } + + func delete(_ id: UUID) { + scenes.removeAll { $0.id == id } + write(scenes, to: "scenes.json") + } + + func scene(_ id: UUID?) -> Scene? { + scenes.first { $0.id == id } + } + + /// Imports a scene file; a scene with the same name is replaced. + func importScene(from url: URL) throws -> Scene { + let accessing = url.startAccessingSecurityScopedResource() + defer { if accessing { url.stopAccessingSecurityScopedResource() } } + var scene = try JSONDecoder().decode(Scene.self, from: Data(contentsOf: url)) + if let existing = scenes.first(where: { $0.name == scene.name }) { scene.id = existing.id } + save(scene) + return scene + } + + // MARK: Files + + private func load(_ type: T.Type, from name: String) throws -> T { + try JSONDecoder().decode(type, from: Data(contentsOf: directory.appending(path: name))) + } + + private func write(_ value: T, to name: String) { + do { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + try encoder.encode(value).write(to: directory.appending(path: name), options: .atomic) + } catch { + Log.ndi.error("Saving \(name, privacy: .public) failed: \(error.localizedDescription, privacy: .public)") + } + } +} diff --git a/App/Sources/ScreensView.swift b/App/Sources/ScreensView.swift index 7969a93..2fac0a0 100644 --- a/App/Sources/ScreensView.swift +++ b/App/Sources/ScreensView.swift @@ -1,60 +1,198 @@ import DesignSystem import MusubiCore +import Remote import SwiftUI -/// Cards for all connected screens; drop a source on a card to show it fullscreen there. +/// Local screens and, on the control, the screens of every node. Drop a source on a card to show it there. struct ScreensView: View { - let outputs: OutputController + let coordinator: Coordinator + private var outputs: OutputController { coordinator.outputs } var body: some View { ScrollView { VStack(alignment: .leading, spacing: Theme.Space.card) { - Text("Bildschirme") - .font(Theme.Font.title) - .foregroundStyle(Theme.Color.washi) - if outputs.outputs.isEmpty { + RoleBanner(coordinator: coordinator) + + SectionTitle(text: coordinator.settings.machineName, detail: coordinator.settings.role == .control ? "Regie" : "Knoten") + if outputs.outputs.isEmpty && coordinator.settings.role == .control && (coordinator.browser?.nodes.isEmpty ?? true) { Text("Noch keine Szene. Zieh eine Quelle auf einen Bildschirm.") .font(Theme.Font.body) .foregroundStyle(Theme.Color.nezumi) } - LazyVGrid(columns: [GridItem(.adaptive(minimum: 260), spacing: Theme.Space.card)], spacing: Theme.Space.card) { + CardGrid { ForEach(outputs.screens) { screen in - ScreenCard(screen: screen, output: outputs.outputs[screen.id], tick: outputs.tick) { - outputs.stop(screen.id) - } + ScreenCard( + title: screen.name, + detail: Self.format(width: screen.width, height: screen.height, hz: screen.refreshRate), + output: outputs.outputs[screen.id].map { OutputCardModel(local: $0) }, + tick: outputs.tick, + onStop: { outputs.stop(screen.id) } + ) .dropDestination(for: String.self) { names, _ in - guard let name = names.first else { return false } + guard let name = names.first, !name.hasPrefix("input:") else { return false } outputs.show(Source(ndiName: name), on: screen.id) return true } } } + + if let browser = coordinator.browser { + ForEach(browser.nodes) { node in + NodeSection(coordinator: coordinator, node: node) + } + } } .padding(Theme.Space.card) } .background(Theme.Color.sumi) } + + static func format(width: Int, height: Int, hz: Double) -> String { + "\(width) × \(height) · \(hz.formatted(.number.precision(.fractionLength(0...2)))) Hz" + } +} + +private struct RoleBanner: View { + let coordinator: Coordinator + + var body: some View { + if let error = coordinator.remoteError { + Text(error).font(Theme.Font.body).foregroundStyle(Theme.Color.kohaku) + } else if coordinator.settings.role == .node, let server = coordinator.nodeServer { + Text(server.controlName.map { "Knoten · gesteuert von \($0)" } ?? "Knoten · wartet auf die Regie") + .font(Theme.Font.body) + .foregroundStyle(Theme.Color.nezumi) + } + } +} + +private struct NodeSection: View { + let coordinator: Coordinator + let node: NodeBrowser.Node + + var body: some View { + VStack(alignment: .leading, spacing: Theme.Space.card) { + HStack(spacing: Theme.Space.unit) { + Circle() + .fill(node.isConnected ? Theme.Color.moegi : Theme.Color.kohaku) + .frame(width: Theme.Stroke.knot, height: Theme.Stroke.knot) + SectionTitle(text: node.name, detail: "Knoten") + Spacer() + if let status = node.status, node.isConnected { + Text(verbatim: "CPU \(Int(status.cpuPercent.rounded())) %") + .font(Theme.Font.number) + .foregroundStyle(Theme.Color.nezumi) + if status.sleepBlocked { + Text("Ruhezustand blockiert").font(Theme.Font.caption).foregroundStyle(Theme.Color.nezumi) + } + } + } + if !node.isConnected { + Text("\(node.name) antwortet nicht. Neuer Versuch läuft.") + .font(Theme.Font.body) + .foregroundStyle(Theme.Color.kohaku) + } + CardGrid { + ForEach(node.status?.screens ?? []) { screen in + ScreenCard( + title: screen.screen.name, + detail: ScreensView.format(width: screen.width, height: screen.height, hz: screen.refreshRate), + output: screen.output.map { OutputCardModel(remote: $0) }, + tick: 0, + onStop: { coordinator.stop(screen.screen, onNode: node.name) } + ) + .opacity(node.isConnected ? 1 : 0.5) + .dropDestination(for: String.self) { names, _ in + guard let name = names.first, !name.hasPrefix("input:") else { return false } + coordinator.show(VideoAssignment(screen: screen.screen, source: Source(ndiName: name)), onNode: node.name) + return true + } + } + } + } + .padding(.top, Theme.Space.card) + } +} + +private struct SectionTitle: View { + let text: String + let detail: LocalizedStringKey + + var body: some View { + HStack(alignment: .firstTextBaseline, spacing: Theme.Space.unit) { + Text(verbatim: text).font(Theme.Font.title).foregroundStyle(Theme.Color.washi) + Text(detail).font(Theme.Font.caption).foregroundStyle(Theme.Color.nezumi) + } + } +} + +private struct CardGrid: View { + @ViewBuilder let content: Content + + var body: some View { + LazyVGrid(columns: [GridItem(.adaptive(minimum: 260), spacing: Theme.Space.card)], spacing: Theme.Space.card) { + content + } + } +} + +/// What a screen card shows about an output, for local and remote screens alike. +struct OutputCardModel { + var label: String + var state: OutputStatus.State + var retryIn: Double? + var width: Int + var height: Int + var frameRate: Double + var lostFrames: Int64 + var lossRate: Double + + @MainActor init(local output: ActiveOutput) { + let s = output.snapshot + label = output.source.label + switch s.state { + case .connecting: state = .connecting + case .live: state = .live + case .stalled(let retry): state = .stalled; retryIn = retry + } + width = s.width + height = s.height + frameRate = s.frameRate + lostFrames = s.stats.missing + s.stats.droppedByReceiver + lossRate = s.stats.lossRate + } + + init(remote status: OutputStatus) { + label = status.source.label + state = status.state + width = status.width + height = status.height + frameRate = status.frameRate + lostFrames = status.lostFrames + lossRate = status.lossRate + } } private struct ScreenCard: View { - let screen: ScreenInfo - let output: ActiveOutput? + let title: String + let detail: String + let output: OutputCardModel? let tick: Int let onStop: () -> Void var body: some View { VStack(alignment: .leading, spacing: Theme.Space.unit) { - Text(verbatim: screen.name) + Text(verbatim: title) .font(Theme.Font.title) .foregroundStyle(Theme.Color.washi) - Text(verbatim: "\(screen.width) × \(screen.height) · \(screen.refreshRate.formatted(.number.precision(.fractionLength(0...2)))) Hz") + Text(verbatim: detail) .font(Theme.Font.number) .foregroundStyle(Theme.Color.nezumi) Divider().overlay(Theme.Color.usuzumi) if let output { - OutputStatus(output: output, tick: tick) + OutputStatusView(output: output) Button("Ausgabe beenden", action: onStop) .buttonStyle(.bordered) } else { @@ -73,18 +211,16 @@ private struct ScreenCard: View { } } -private struct OutputStatus: View { - let output: ActiveOutput - let tick: Int +private struct OutputStatusView: View { + let output: OutputCardModel var body: some View { - let snapshot = output.snapshot VStack(alignment: .leading, spacing: Theme.Space.unit / 2) { - Text(verbatim: output.source.label) + Text(verbatim: output.label) .font(Theme.Font.body) .foregroundStyle(Theme.Color.washi) - switch snapshot.state { + switch output.state { case .connecting: Text("Verbinde …") .font(Theme.Font.caption) @@ -92,20 +228,25 @@ private struct OutputStatus: View { case .live: HStack(spacing: Theme.Space.unit / 2) { Circle().fill(Theme.Color.moegi).frame(width: Theme.Stroke.knot, height: Theme.Stroke.knot) - Text(verbatim: "\(snapshot.width) × \(snapshot.height) · \(snapshot.frameRate.formatted(.number.precision(.fractionLength(0...2)))) fps") + Text(verbatim: "\(output.width) × \(output.height) · \(output.frameRate.formatted(.number.precision(.fractionLength(0...2)))) fps") .font(Theme.Font.number) .foregroundStyle(Theme.Color.nezumi) } - case .stalled(let retryIn): - Text("\(output.source.label) antwortet nicht mehr. Neuer Versuch in \(Int(retryIn.rounded(.up))) s.") - .font(Theme.Font.caption) - .foregroundStyle(Theme.Color.kohaku) + case .stalled: + if let retry = output.retryIn { + Text("\(output.label) antwortet nicht mehr. Neuer Versuch in \(Int(retry.rounded(.up))) s.") + .font(Theme.Font.caption) + .foregroundStyle(Theme.Color.kohaku) + } else { + Text("\(output.label) antwortet nicht mehr.") + .font(Theme.Font.caption) + .foregroundStyle(Theme.Color.kohaku) + } } - let lost = snapshot.stats.missing + snapshot.stats.droppedByReceiver - Text("\(lost) Frames verloren (\(snapshot.stats.lossRate.formatted(.percent.precision(.fractionLength(2)))))") + Text("\(output.lostFrames) Frames verloren (\(output.lossRate.formatted(.percent.precision(.fractionLength(2)))))") .font(Theme.Font.number) - .foregroundStyle(snapshot.stats.lossRate >= 0.001 ? Theme.Color.kohaku : Theme.Color.nezumi) + .foregroundStyle(output.lossRate >= 0.001 ? Theme.Color.kohaku : Theme.Color.nezumi) } } } diff --git a/App/Sources/SettingsView.swift b/App/Sources/SettingsView.swift new file mode 100644 index 0000000..c21602c --- /dev/null +++ b/App/Sources/SettingsView.swift @@ -0,0 +1,47 @@ +import DesignSystem +import MusubiCore +import Remote +import SwiftUI + +struct SettingsView: View { + let coordinator: Coordinator + @State private var interfaces: [NetworkInterfaces.Info] = [] + @State private var launchAtLogin = false + + private var settings: AppSettings { coordinator.settings } + + var body: some View { + Form { + Picker("Rolle", selection: Binding(get: { settings.role }, set: { settings.role = $0; restart() })) { + Text("Regie – steuert Knoten, verwaltet Szenen").tag(Role.control) + Text("Knoten – folgt der Regie").tag(Role.node) + } + Picker("Technik-Netz", selection: Binding(get: { settings.interfaceName }, set: { settings.interfaceName = $0; restart() })) { + ForEach(interfaces) { interface in + Text(verbatim: "\(interface.name) · \(interface.kind)").tag(interface.name) + } + if !interfaces.contains(where: { $0.name == settings.interfaceName }) { + Text(verbatim: "\(settings.interfaceName) · nicht verbunden").tag(settings.interfaceName) + } + } + Text("Regie und Knoten finden sich nur über diese Schnittstelle.") + .font(Theme.Font.caption) + .foregroundStyle(Theme.Color.nezumi) + Toggle("Beim Anmelden starten", isOn: Binding(get: { launchAtLogin }, set: { + settings.launchAtLogin = $0 + launchAtLogin = settings.launchAtLogin + })) + LabeledContent("Name im Netz") { Text(verbatim: settings.machineName) } + } + .formStyle(.grouped) + .frame(width: 460) + .task { + interfaces = await NetworkInterfaces.available() + launchAtLogin = settings.launchAtLogin + } + } + + private func restart() { + Task { await coordinator.startRemote() } + } +} diff --git a/App/Sources/SoundView.swift b/App/Sources/SoundView.swift index a197e3c..e14c0d5 100644 --- a/App/Sources/SoundView.swift +++ b/App/Sources/SoundView.swift @@ -1,5 +1,6 @@ import AudioMatrix import DesignSystem +import MusubiCore import SwiftUI /// Channel strips of the selected output device; drop an NDI source or a microphone onto a strip. @@ -145,7 +146,7 @@ private struct RouteRow: View { .foregroundStyle(Theme.Color.washi) .lineLimit(1) Spacer() - Picker("Kanal", selection: Binding(get: { route.selection }, set: { audio.matrix.setSelection(route.id, $0) })) { + Picker("Kanal", selection: Binding(get: { route.selection }, set: { audio.setSelection(route.id, $0) })) { Text("L").tag(ChannelSelection.left) Text("R").tag(ChannelSelection.right) Text("L+R").tag(ChannelSelection.mono) @@ -154,7 +155,7 @@ private struct RouteRow: View { .pickerStyle(.segmented) .fixedSize() Button { - audio.matrix.setMuted(route.id, !route.muted) + audio.setMuted(route.id, !route.muted) } label: { Text("M").font(Theme.Font.caption.bold()) } @@ -170,7 +171,7 @@ private struct RouteRow: View { .help("Route entfernen") } HStack(spacing: Theme.Space.unit) { - Slider(value: Binding(get: { Double(route.gainDB) }, set: { audio.matrix.setGain(route.id, db: Float($0)) }), + Slider(value: Binding(get: { Double(route.gainDB) }, set: { audio.setGain(route.id, db: Float($0)) }), in: -60...12) .controlSize(.small) Text(verbatim: route.gainDB <= Level.floorDB ? "−∞ dB" : String(format: "%+.1f dB", route.gainDB)) @@ -178,7 +179,7 @@ private struct RouteRow: View { .foregroundStyle(route.gainDB == 0 ? Theme.Color.nezumi : Theme.Color.washi) .frame(width: 70, alignment: .trailing) .contentShape(Rectangle()) - .onTapGesture(count: 2) { audio.matrix.setGain(route.id, db: 0) } + .onTapGesture(count: 2) { audio.setGain(route.id, db: 0) } .help("Doppelklick: Pegel auf 0 dB") } LevelMeter(db: audio.meters[route.id] ?? Level.floorDB) @@ -188,8 +189,8 @@ private struct RouteRow: View { } .padding(.vertical, Theme.Space.unit / 2) .contextMenu { - Button("Pegel auf 0 dB") { audio.matrix.setGain(route.id, db: 0) } - Button(route.muted ? "Stummschaltung aufheben" : "Stummschalten") { audio.matrix.setMuted(route.id, !route.muted) } + Button("Pegel auf 0 dB") { audio.setGain(route.id, db: 0) } + Button(route.muted ? "Stummschaltung aufheben" : "Stummschalten") { audio.setMuted(route.id, !route.muted) } Divider() Button("Route entfernen", role: .destructive) { audio.remove(route.id) } } diff --git a/Packages/MusubiKit/Sources/Remote/NodeBrowser.swift b/Packages/MusubiKit/Sources/Remote/NodeBrowser.swift index 69c6d10..f67cd18 100644 --- a/Packages/MusubiKit/Sources/Remote/NodeBrowser.swift +++ b/Packages/MusubiKit/Sources/Remote/NodeBrowser.swift @@ -34,6 +34,8 @@ public final class NodeBrowser { private let name: String private var browser: NWBrowser? private var interface: NWInterface? + private var interfaceName: String? + private var retryTask: Task? private let queue = DispatchQueue(label: "studio.zielke.musubi.remote.control") private let log = Logger(subsystem: "studio.zielke.musubi", category: "remote") @@ -43,8 +45,10 @@ public final class NodeBrowser { public func start(interfaceName: String) async { stop() + self.interfaceName = interfaceName guard let interface = await NetworkInterfaces.interface(named: interfaceName) else { error = "Technik-Schnittstelle \(interfaceName) nicht gefunden." + scheduleRestart() return } self.interface = interface @@ -57,17 +61,42 @@ public final class NodeBrowser { Task { @MainActor in self?.update(found) } } browser.stateUpdateHandler = { [weak self] state in - if case .failed(let failure) = state { - Task { @MainActor in self?.error = "Knotensuche gestört: \(failure.localizedDescription)" } - } + Task { @MainActor in self?.browserChanged(state) } } browser.start(queue: queue) self.browser = browser - error = nil log.info("Control: browsing on \(interfaceName, privacy: .public)") } + private func browserChanged(_ state: NWBrowser.State) { + switch state { + case .ready: + error = nil + case .failed(let failure), .waiting(let failure): + if case .dns(let code) = failure, code == -65555 { + error = "macOS blockiert das lokale Netzwerk. Erlaube Musubi unter Systemeinstellungen › Datenschutz & Sicherheit › Lokales Netzwerk." + } else { + error = "Knotensuche gestört: \(failure.localizedDescription)" + } + log.error("Control: browser \(String(describing: failure), privacy: .public)") + if case .failed = state { scheduleRestart() } + default: + break + } + } + + private func scheduleRestart() { + retryTask?.cancel() + retryTask = Task { [weak self] in + try? await Task.sleep(for: .seconds(5)) + guard !Task.isCancelled, let self, let interfaceName else { return } + await start(interfaceName: interfaceName) + } + } + public func stop() { + retryTask?.cancel() + retryTask = nil browser?.cancel() browser = nil for node in nodes { diff --git a/Packages/MusubiKit/Sources/Remote/NodeServer.swift b/Packages/MusubiKit/Sources/Remote/NodeServer.swift index 17b86cc..58f278b 100644 --- a/Packages/MusubiKit/Sources/Remote/NodeServer.swift +++ b/Packages/MusubiKit/Sources/Remote/NodeServer.swift @@ -15,6 +15,8 @@ public final class NodeServer { public var onCommand: ((RemoteMessage) -> Void)? private let name: String + private var interfaceName: String? + private var retryTask: Task? private var listener: NWListener? private var link: Link? private let queue = DispatchQueue(label: "studio.zielke.musubi.remote.node") @@ -26,9 +28,11 @@ public final class NodeServer { public func start(interfaceName: String) async { stop() + self.interfaceName = interfaceName guard let interface = await NetworkInterfaces.interface(named: interfaceName) else { error = "Technik-Schnittstelle \(interfaceName) nicht gefunden." log.error("Node: interface \(interfaceName, privacy: .public) not found") + scheduleRetry() return } do { @@ -42,7 +46,6 @@ public final class NodeServer { } listener.start(queue: queue) self.listener = listener - error = nil log.info("Node: advertising \(self.name, privacy: .public) on \(interfaceName, privacy: .public)") } catch { self.error = "Fernsteuerung lässt sich nicht starten." @@ -51,6 +54,8 @@ public final class NodeServer { } public func stop() { + retryTask?.cancel() + retryTask = nil link?.cancel() link = nil listener?.cancel() @@ -66,16 +71,34 @@ public final class NodeServer { private func listenerChanged(_ state: NWListener.State) { switch state { - case .ready: isListening = true + case .ready: + isListening = true + error = nil case .failed(let failure): isListening = false - error = "Fernsteuerung gestört: \(failure.localizedDescription)" + if case .dns(let code) = failure, code == -65555 { + // kDNSServiceErr_NoAuth: macOS has not (yet) allowed local network access. + error = "macOS blockiert das lokale Netzwerk. Erlaube Musubi unter Systemeinstellungen › Datenschutz & Sicherheit › Lokales Netzwerk." + } else { + error = "Fernsteuerung gestört: \(failure.localizedDescription)" + } log.error("Node: listener failed: \(failure.localizedDescription, privacy: .public)") + scheduleRetry() case .cancelled: isListening = false default: break } } + /// Tries again every 5 s, e.g. until local network access is granted or the cable is plugged in. + private func scheduleRetry() { + retryTask?.cancel() + retryTask = Task { [weak self] in + try? await Task.sleep(for: .seconds(5)) + guard !Task.isCancelled, let self, let interfaceName else { return } + await start(interfaceName: interfaceName) + } + } + /// The newest control wins; the previous connection is closed. private func accept(_ connection: NWConnection) { link?.cancel() diff --git a/project.yml b/project.yml index 1d04754..0c8acfa 100644 --- a/project.yml +++ b/project.yml @@ -39,14 +39,18 @@ targets: LSApplicationCategoryType: public.app-category.video NSLocalNetworkUsageDescription: Musubi sucht NDI-Quellen im Technik-Netz. NSMicrophoneUsageDescription: Musubi legt dein Headset-Mikro per Sprechtaste auf einen Ausgangskanal. - NSBonjourServices: [_ndi._tcp] + NSBonjourServices: [_ndi._tcp, _musubi._tcp] settings: base: PRODUCT_BUNDLE_IDENTIFIER: studio.zielke.musubi - # Ad-hoc signing for now. Hardened runtime needs a real Team ID, otherwise - # library validation rejects the embedded libndi.dylib. Enable for notarization. - CODE_SIGN_IDENTITY: "-" - ENABLE_HARDENED_RUNTIME: NO + # Stable signing identity: macOS keys privacy grants (local network, microphone) to it. + # libndi.dylib is re-signed on copy with the same team, so library validation passes. + CODE_SIGN_STYLE: Manual + CODE_SIGN_IDENTITY: "Developer ID Application" + DEVELOPMENT_TEAM: XY4C2RG3L5 + ENABLE_HARDENED_RUNTIME: YES + CODE_SIGN_ENTITLEMENTS: App/Musubi.entitlements + OTHER_CODE_SIGN_FLAGS: --timestamp=none LIBRARY_SEARCH_PATHS: $(SRCROOT)/Vendor/NDI/lib LD_RUNPATH_SEARCH_PATHS: "@executable_path/../Frameworks" preBuildScripts: @@ -68,6 +72,8 @@ targets: product: AudioMatrix - package: MusubiKit product: DesignSystem + - package: MusubiKit + product: Remote - framework: Vendor/NDI/lib/libndi.dylib embed: true codeSign: true @@ -80,6 +86,8 @@ schemes: test: targets: - package: MusubiKit/MusubiCoreTests + - package: MusubiKit/AudioMatrixTests + - package: MusubiKit/RemoteTests run: config: Debug archive: