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() applyLoginItemArgument() if let index = ProcessInfo.processInfo.arguments.firstIndex(of: "--load-scene"), index + 1 < ProcessInfo.processInfo.arguments.count { let name = ProcessInfo.processInfo.arguments[index + 1] if let scene = store.scenes.first(where: { $0.name == name }) { loadScene(scene) } else { Log.remote.error("--load-scene: no scene named \(name, privacy: .public)") } } statusTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in MainActor.assumeIsolated { self?.sendStatus() } } } /// `--login-item on|off`: set up a headless node over SSH, where the settings window is hidden behind outputs. private func applyLoginItemArgument(_ arguments: [String] = ProcessInfo.processInfo.arguments) { guard let index = arguments.firstIndex(of: "--login-item"), index + 1 < arguments.count else { return } settings.launchAtLogin = arguments[index + 1] == "on" Log.ndi.info("Launch at login: \(self.settings.launchAtLogin ? "on" : "off", privacy: .public)") } /// (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) { Log.remote.info("UI: show \(assignment.source.ndiName, privacy: .public) on \(name, privacy: .public)/\(assignment.screen.name, privacy: .public)") 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) { Log.remote.info("UI: stop \(name, privacy: .public)/\(screen.name, privacy: .public)") 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.remote.info("Sent configuration to \(node.name, privacy: .public): \(desired.video.count) video") } 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 { Log.remote.info("Adopted configuration of \(node.name, privacy: .public): \(config.video.count) video") live.nodes[node.name] = config store.saveLiveState(live) return } } } } // MARK: - Node: commands from the control private func handle(_ message: RemoteMessage) { Log.remote.info("Command: \(String(describing: message).prefix(200), privacy: .public)") 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() { Log.remote.debug("Live state saved: local \(self.live.local.video.count) video, nodes \(self.live.nodes.mapValues(\.video.count), privacy: .public)") 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) } }