You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
Musubi/App/Sources/Coordinator.swift

247 lines
9.4 KiB

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()
let intercom: IntercomController
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
intercom = IntercomController(settings: settings, audio: audio)
audio.onTalkChange = { [weak intercom] active in intercom?.setTalkActive(active) }
}
/// 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()
await intercom.restart()
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)
}
}

Powered by TurnKey Linux.