- 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
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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in new issue