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: