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 <noreply@anthropic.com>
main
Samuel Zielke 3 days ago
parent 02c06f7105
commit 97c9905ae1

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- Hardened runtime: headset microphone for the talk key. -->
<key>com.apple.security.device.audio-input</key>
<true/>
</dict>
</plist>

@ -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)")
}
}
}
}

@ -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 {

@ -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)

@ -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)
}
}

@ -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)
}
}
}

@ -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()
}

@ -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))
}
}

@ -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<Void, Never>?
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<T: Decodable>(_ type: T.Type, from name: String) throws -> T {
try JSONDecoder().decode(type, from: Data(contentsOf: directory.appending(path: name)))
}
private func write<T: Encodable>(_ 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)")
}
}
}

@ -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<Content: View>: 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)
}
}
}

@ -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() }
}
}

@ -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) }
}

@ -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<Void, Never>?
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 {

@ -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<Void, Never>?
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()

@ -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:

Loading…
Cancel
Save

Powered by TurnKey Linux.