Compare commits
10 Commits
2d6d0236d5
...
439e2505eb
| Author | SHA1 | Date |
|---|---|---|
|
|
439e2505eb | 3 days ago |
|
|
27494fe1a1 | 3 days ago |
|
|
c28f061799 | 3 days ago |
|
|
d9b8e46335 | 3 days ago |
|
|
dc10a13e5c | 3 days ago |
|
|
5bc031d358 | 3 days ago |
|
|
97c9905ae1 | 3 days ago |
|
|
02c06f7105 | 3 days ago |
|
|
cf561d410c | 3 days ago |
|
|
db71c81d53 | 3 days ago |
@ -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,242 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@ -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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,91 @@
|
||||
import Foundation
|
||||
|
||||
/// A screen identified across restarts and machines: by name first, position as fallback.
|
||||
/// (CGDirectDisplayIDs differ between Macs and can change after a reboot.)
|
||||
public struct ScreenRef: Codable, Hashable, Sendable {
|
||||
public var name: String
|
||||
/// Position in the system's screen list (0 = main screen).
|
||||
public var index: Int
|
||||
|
||||
public init(name: String, index: Int) {
|
||||
self.name = name
|
||||
self.index = index
|
||||
}
|
||||
}
|
||||
|
||||
/// One NDI source fullscreen on one screen.
|
||||
public struct VideoAssignment: Codable, Hashable, Sendable {
|
||||
public var screen: ScreenRef
|
||||
public var source: Source
|
||||
|
||||
public init(screen: ScreenRef, source: Source) {
|
||||
self.screen = screen
|
||||
self.source = source
|
||||
}
|
||||
}
|
||||
|
||||
/// One audio source on one output channel.
|
||||
public struct AudioAssignment: Codable, Hashable, Sendable {
|
||||
public enum SourceSpec: Codable, Hashable, Sendable {
|
||||
case ndi(name: String)
|
||||
/// Input device by UID; the name is the fallback when the UID changes (e.g. other USB port).
|
||||
case input(uid: String, name: String)
|
||||
}
|
||||
|
||||
public var source: SourceSpec
|
||||
public var deviceUID: String
|
||||
public var deviceName: String
|
||||
public var channel: Int // 0-based
|
||||
public var selection: ChannelSelection
|
||||
public var gainDB: Float
|
||||
public var muted: Bool
|
||||
|
||||
public init(source: SourceSpec, deviceUID: String, deviceName: String, channel: Int,
|
||||
selection: ChannelSelection = .mono, gainDB: Float = 0, muted: Bool = false) {
|
||||
self.source = source
|
||||
self.deviceUID = deviceUID
|
||||
self.deviceName = deviceName
|
||||
self.channel = channel
|
||||
self.selection = selection
|
||||
self.gainDB = gainDB
|
||||
self.muted = muted
|
||||
}
|
||||
|
||||
/// Identity of the route (what goes where), without its settings.
|
||||
public var key: String {
|
||||
let sourceKey = switch source {
|
||||
case .ndi(let name): "ndi:\(name)"
|
||||
case .input(let uid, _): "input:\(uid)"
|
||||
}
|
||||
return "\(sourceKey)→\(deviceUID)#\(channel)"
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything one Mac outputs.
|
||||
public struct MachineConfig: Codable, Hashable, Sendable {
|
||||
public var video: [VideoAssignment]
|
||||
public var audio: [AudioAssignment]
|
||||
|
||||
public init(video: [VideoAssignment] = [], audio: [AudioAssignment] = []) {
|
||||
self.video = video
|
||||
self.audio = audio
|
||||
}
|
||||
|
||||
public static let empty = MachineConfig()
|
||||
}
|
||||
|
||||
/// A complete setup of the control Mac and its nodes, e.g. "OP-Live".
|
||||
public struct Scene: Codable, Identifiable, Hashable, Sendable {
|
||||
public var id: UUID
|
||||
public var name: String
|
||||
public var control: MachineConfig
|
||||
/// Node name (computer name) → what that node shows.
|
||||
public var nodes: [String: MachineConfig]
|
||||
|
||||
public init(id: UUID = UUID(), name: String, control: MachineConfig = .empty, nodes: [String: MachineConfig] = [:]) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.control = control
|
||||
self.nodes = nodes
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,42 @@
|
||||
/// Finds the connected screen a `ScreenRef` means.
|
||||
public enum ScreenMatching {
|
||||
/// - Parameter names: names of the connected screens in system order.
|
||||
/// - Returns: index into `names`, or nil if the screen is not connected.
|
||||
public static func index(of ref: ScreenRef, in names: [String]) -> Int? {
|
||||
let sameName = names.indices.filter { names[$0] == ref.name }
|
||||
switch sameName.count {
|
||||
case 1:
|
||||
return sameName[0]
|
||||
case 0:
|
||||
// Renamed or different monitor model: only trust the position if the count still fits.
|
||||
return nil
|
||||
default:
|
||||
// Identical monitors: prefer the remembered position, else the first free match.
|
||||
return sameName.contains(ref.index) ? ref.index : sameName.first
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves all assignments; screens claimed twice keep the first assignment.
|
||||
public static func resolve(_ assignments: [VideoAssignment], in names: [String]) -> [Int: Source] {
|
||||
var result: [Int: Source] = [:]
|
||||
for assignment in assignments {
|
||||
guard let index = index(of: assignment.screen, in: names), result[index] == nil else { continue }
|
||||
result[index] = assignment.source
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
/// What to stop and start to get from the current state to the desired one.
|
||||
public enum Reconcile {
|
||||
public struct Plan<Key: Hashable, Value: Equatable> {
|
||||
public var stop: [Key]
|
||||
public var start: [Key: Value]
|
||||
}
|
||||
|
||||
public static func plan<Key: Hashable, Value: Equatable>(current: [Key: Value], desired: [Key: Value]) -> Plan<Key, Value> {
|
||||
let stop = current.keys.filter { desired[$0] == nil }
|
||||
let start = desired.filter { current[$0.key] != $0.value }
|
||||
return Plan(stop: stop, start: start)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,54 @@
|
||||
import Foundation
|
||||
import Network
|
||||
|
||||
/// Network interfaces that services can be bound to (e.g. en7 = technical network).
|
||||
public enum NetworkInterfaces {
|
||||
public struct Info: Hashable, Sendable, Identifiable {
|
||||
public var id: String { name }
|
||||
public let name: String
|
||||
public let kind: String
|
||||
}
|
||||
|
||||
/// Currently available interfaces (wired, Wi-Fi, other), excluding loopback.
|
||||
public static func available() async -> [Info] {
|
||||
await withCheckedContinuation { continuation in
|
||||
let monitor = NWPathMonitor()
|
||||
let once = OnceFlag()
|
||||
monitor.pathUpdateHandler = { path in
|
||||
guard once.claim() else { return }
|
||||
monitor.cancel()
|
||||
continuation.resume(returning: path.availableInterfaces
|
||||
.filter { $0.type != .loopback }
|
||||
.map { Info(name: $0.name, kind: describe($0.type)) })
|
||||
}
|
||||
monitor.start(queue: .global(qos: .utility))
|
||||
}
|
||||
}
|
||||
|
||||
static func interface(named name: String) async -> NWInterface? {
|
||||
await withCheckedContinuation { continuation in
|
||||
let monitor = NWPathMonitor()
|
||||
let once = OnceFlag()
|
||||
monitor.pathUpdateHandler = { path in
|
||||
guard once.claim() else { return }
|
||||
monitor.cancel()
|
||||
continuation.resume(returning: path.availableInterfaces.first { $0.name == name })
|
||||
}
|
||||
monitor.start(queue: .global(qos: .utility))
|
||||
}
|
||||
}
|
||||
|
||||
private static func describe(_ type: NWInterface.InterfaceType) -> String {
|
||||
switch type {
|
||||
case .wiredEthernet: "Ethernet"
|
||||
case .wifi: "WLAN"
|
||||
default: "Andere"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final class OnceFlag: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var done = false
|
||||
func claim() -> Bool { lock.withLock { defer { done = true }; return !done } }
|
||||
}
|
||||
@ -0,0 +1,85 @@
|
||||
import Foundation
|
||||
import Network
|
||||
import OSLog
|
||||
|
||||
/// One framed message connection (either side). Callbacks arrive on the main actor.
|
||||
final class Link: @unchecked Sendable {
|
||||
let connection: NWConnection
|
||||
private let queue: DispatchQueue
|
||||
private var framer = MessageFramer() // queue only
|
||||
private let log = Logger(subsystem: "studio.zielke.musubi", category: "remote")
|
||||
|
||||
var onMessage: (@MainActor (RemoteMessage) -> Void)?
|
||||
var onState: (@MainActor (NWConnection.State) -> Void)?
|
||||
|
||||
init(connection: NWConnection, queue: DispatchQueue) {
|
||||
self.connection = connection
|
||||
self.queue = queue
|
||||
}
|
||||
|
||||
func start() {
|
||||
connection.stateUpdateHandler = { [weak self] state in
|
||||
guard let self else { return }
|
||||
let handler = onState
|
||||
Task { @MainActor in handler?(state) }
|
||||
}
|
||||
connection.start(queue: queue)
|
||||
receive()
|
||||
}
|
||||
|
||||
func cancel() {
|
||||
connection.cancel()
|
||||
}
|
||||
|
||||
func send(_ message: RemoteMessage) {
|
||||
do {
|
||||
let data = try MessageFramer.encode(message)
|
||||
connection.send(content: data, completion: .contentProcessed { [log] error in
|
||||
if let error { log.error("Send failed: \(error.localizedDescription, privacy: .public)") }
|
||||
})
|
||||
} catch {
|
||||
log.error("Encode failed: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
private func receive() {
|
||||
connection.receive(minimumIncompleteLength: 1, maximumLength: 65_536) { [weak self] data, _, isComplete, error in
|
||||
guard let self else { return }
|
||||
if let data, !data.isEmpty {
|
||||
do {
|
||||
let messages = try framer.append(data)
|
||||
let handler = onMessage
|
||||
if !messages.isEmpty {
|
||||
Task { @MainActor in messages.forEach { handler?($0) } }
|
||||
}
|
||||
} catch {
|
||||
log.error("Framing error, closing: \(String(describing: error), privacy: .public)")
|
||||
connection.cancel()
|
||||
return
|
||||
}
|
||||
}
|
||||
if isComplete || error != nil {
|
||||
connection.cancel()
|
||||
return
|
||||
}
|
||||
receive()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension NWParameters {
|
||||
/// TCP bound to one interface, with Bonjour peer-to-peer off (technical network only).
|
||||
static func musubi(interface: NWInterface?) -> NWParameters {
|
||||
let parameters = NWParameters.tcp
|
||||
parameters.includePeerToPeer = false
|
||||
if let interface { parameters.requiredInterface = interface }
|
||||
if let tcp = parameters.defaultProtocolStack.transportProtocol as? NWProtocolTCP.Options {
|
||||
tcp.enableKeepalive = true
|
||||
tcp.keepaliveIdle = 2
|
||||
tcp.keepaliveInterval = 1
|
||||
tcp.keepaliveCount = 3
|
||||
tcp.noDelay = true
|
||||
}
|
||||
return parameters
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,108 @@
|
||||
import Foundation
|
||||
import MusubiCore
|
||||
|
||||
public enum RemoteProtocol {
|
||||
public static let serviceType = "_musubi._tcp"
|
||||
public static let version = 1
|
||||
}
|
||||
|
||||
/// State of one screen on a node, as reported to the control.
|
||||
public struct ScreenStatus: Codable, Hashable, Sendable, Identifiable {
|
||||
public var id: ScreenRef { screen }
|
||||
public var screen: ScreenRef
|
||||
public var width: Int
|
||||
public var height: Int
|
||||
public var refreshRate: Double
|
||||
public var output: OutputStatus?
|
||||
|
||||
public init(screen: ScreenRef, width: Int, height: Int, refreshRate: Double, output: OutputStatus?) {
|
||||
self.screen = screen
|
||||
self.width = width
|
||||
self.height = height
|
||||
self.refreshRate = refreshRate
|
||||
self.output = output
|
||||
}
|
||||
}
|
||||
|
||||
public struct OutputStatus: Codable, Hashable, Sendable {
|
||||
public enum State: String, Codable, Sendable { case connecting, live, stalled }
|
||||
|
||||
public var source: Source
|
||||
public var state: State
|
||||
public var width: Int
|
||||
public var height: Int
|
||||
public var frameRate: Double
|
||||
public var lostFrames: Int64
|
||||
public var lossRate: Double
|
||||
|
||||
public init(source: Source, state: State, width: Int, height: Int, frameRate: Double, lostFrames: Int64, lossRate: Double) {
|
||||
self.source = source
|
||||
self.state = state
|
||||
self.width = width
|
||||
self.height = height
|
||||
self.frameRate = frameRate
|
||||
self.lostFrames = lostFrames
|
||||
self.lossRate = lossRate
|
||||
}
|
||||
}
|
||||
|
||||
public struct NodeStatus: Codable, Hashable, Sendable {
|
||||
public var screens: [ScreenStatus]
|
||||
/// What the node currently outputs (so a control can adopt it, e.g. when saving a scene).
|
||||
public var config: MachineConfig
|
||||
public var cpuPercent: Double
|
||||
public var sleepBlocked: Bool
|
||||
|
||||
public init(screens: [ScreenStatus], config: MachineConfig, cpuPercent: Double, sleepBlocked: Bool) {
|
||||
self.screens = screens
|
||||
self.config = config
|
||||
self.cpuPercent = cpuPercent
|
||||
self.sleepBlocked = sleepBlocked
|
||||
}
|
||||
}
|
||||
|
||||
public enum RemoteMessage: Codable, Hashable, Sendable {
|
||||
/// First message in both directions.
|
||||
case hello(name: String, role: Role, version: Int)
|
||||
/// Node → control, once per second.
|
||||
case status(NodeStatus)
|
||||
/// Control → node: replace the whole configuration.
|
||||
case apply(MachineConfig)
|
||||
/// Control → node: show one source on one screen.
|
||||
case show(VideoAssignment)
|
||||
/// Control → node: stop the output on one screen.
|
||||
case stop(ScreenRef)
|
||||
}
|
||||
|
||||
/// Length-prefixed JSON framing: 4-byte big-endian length, then the JSON body.
|
||||
public struct MessageFramer: Sendable {
|
||||
public static let maxMessageSize = 4 * 1024 * 1024
|
||||
private var buffer = Data()
|
||||
|
||||
public init() {}
|
||||
|
||||
public static func encode(_ message: RemoteMessage) throws -> Data {
|
||||
let body = try JSONEncoder().encode(message)
|
||||
var length = UInt32(body.count).bigEndian
|
||||
return Data(bytes: &length, count: 4) + body
|
||||
}
|
||||
|
||||
public enum FramingError: Error { case tooLarge(Int) }
|
||||
|
||||
/// Appends received bytes and returns all complete messages. Malformed JSON is skipped.
|
||||
public mutating func append(_ data: Data) throws -> [RemoteMessage] {
|
||||
buffer.append(data)
|
||||
var messages: [RemoteMessage] = []
|
||||
while buffer.count >= 4 {
|
||||
let length = buffer.prefix(4).reduce(0) { Int($0) << 8 | Int($1) }
|
||||
guard length <= Self.maxMessageSize else { throw FramingError.tooLarge(length) }
|
||||
guard buffer.count >= 4 + length else { break }
|
||||
let body = buffer.subdata(in: buffer.startIndex + 4 ..< buffer.startIndex + 4 + length)
|
||||
buffer.removeFirst(4 + length)
|
||||
if let message = try? JSONDecoder().decode(RemoteMessage.self, from: body) {
|
||||
messages.append(message)
|
||||
}
|
||||
}
|
||||
return messages
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,43 @@
|
||||
import Testing
|
||||
@testable import MusubiCore
|
||||
|
||||
@Suite struct ScreenMatchingTests {
|
||||
@Test func matchesByNameRegardlessOfOrder() {
|
||||
let ref = ScreenRef(name: "Field Monitor", index: 1)
|
||||
#expect(ScreenMatching.index(of: ref, in: ["Field Monitor", "HDMI TO USB"]) == 0)
|
||||
}
|
||||
|
||||
@Test func identicalMonitorsUsePosition() {
|
||||
let names = ["LG", "LG", "Built-in"]
|
||||
#expect(ScreenMatching.index(of: ScreenRef(name: "LG", index: 1), in: names) == 1)
|
||||
#expect(ScreenMatching.index(of: ScreenRef(name: "LG", index: 2), in: names) == 0)
|
||||
}
|
||||
|
||||
@Test func missingScreenIsNil() {
|
||||
#expect(ScreenMatching.index(of: ScreenRef(name: "Beamer", index: 0), in: ["Field Monitor"]) == nil)
|
||||
}
|
||||
|
||||
@Test func resolveKeepsFirstClaim() {
|
||||
let a = Source(ndiName: "A (a)"), b = Source(ndiName: "B (b)")
|
||||
let resolved = ScreenMatching.resolve([
|
||||
VideoAssignment(screen: ScreenRef(name: "X", index: 0), source: a),
|
||||
VideoAssignment(screen: ScreenRef(name: "X", index: 0), source: b),
|
||||
VideoAssignment(screen: ScreenRef(name: "Gone", index: 3), source: b),
|
||||
], in: ["X"])
|
||||
#expect(resolved == [0: a])
|
||||
}
|
||||
}
|
||||
|
||||
@Suite struct ReconcileTests {
|
||||
@Test func stopsRemovedStartsNewAndChanged() {
|
||||
let plan = Reconcile.plan(current: [1: "A", 2: "B", 3: "C"], desired: [2: "B", 3: "X", 4: "D"])
|
||||
#expect(Set(plan.stop) == [1])
|
||||
#expect(plan.start == [3: "X", 4: "D"])
|
||||
}
|
||||
|
||||
@Test func nothingToDoWhenEqual() {
|
||||
let plan = Reconcile.plan(current: [1: "A"], desired: [1: "A"])
|
||||
#expect(plan.stop.isEmpty)
|
||||
#expect(plan.start.isEmpty)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,47 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
import MusubiCore
|
||||
@testable import Remote
|
||||
|
||||
@Suite struct FramingTests {
|
||||
let show = RemoteMessage.show(VideoAssignment(screen: ScreenRef(name: "Field Monitor", index: 1), source: Source(ndiName: "PGM (pgm)")))
|
||||
|
||||
@Test func roundTripsMessages() throws {
|
||||
var framer = MessageFramer()
|
||||
let data = try MessageFramer.encode(.hello(name: "MacBook", role: .control, version: 1)) + MessageFramer.encode(show)
|
||||
#expect(try framer.append(data) == [.hello(name: "MacBook", role: .control, version: 1), show])
|
||||
}
|
||||
|
||||
@Test func reassemblesSplitData() throws {
|
||||
var framer = MessageFramer()
|
||||
let data = try MessageFramer.encode(show)
|
||||
var received: [RemoteMessage] = []
|
||||
for byte in data { received += try framer.append(Data([byte])) }
|
||||
#expect(received == [show])
|
||||
}
|
||||
|
||||
@Test func skipsMalformedBody() throws {
|
||||
var framer = MessageFramer()
|
||||
var bad = Data([0, 0, 0, 3]) + Data("{x}".utf8)
|
||||
bad += try MessageFramer.encode(.stop(ScreenRef(name: "A", index: 0)))
|
||||
#expect(try framer.append(bad) == [.stop(ScreenRef(name: "A", index: 0))])
|
||||
}
|
||||
|
||||
@Test func rejectsHugeLength() {
|
||||
var framer = MessageFramer()
|
||||
#expect(throws: MessageFramer.FramingError.self) {
|
||||
try framer.append(Data([0xFF, 0xFF, 0xFF, 0xFF]))
|
||||
}
|
||||
}
|
||||
|
||||
@Test func statusRoundTrips() throws {
|
||||
let status = NodeStatus(
|
||||
screens: [ScreenStatus(screen: ScreenRef(name: "Field Monitor", index: 1), width: 1920, height: 1080, refreshRate: 50,
|
||||
output: OutputStatus(source: Source(ndiName: "PGM (pgm)"), state: .live, width: 1920, height: 1080,
|
||||
frameRate: 50, lostFrames: 3, lossRate: 0.0001))],
|
||||
config: MachineConfig(video: [VideoAssignment(screen: ScreenRef(name: "Field Monitor", index: 1), source: Source(ndiName: "PGM (pgm)"))]),
|
||||
cpuPercent: 51.2, sleepBlocked: true)
|
||||
var framer = MessageFramer()
|
||||
#expect(try framer.append(MessageFramer.encode(.status(status))) == [.status(status)])
|
||||
}
|
||||
}
|
||||
Loading…
Reference in new issue