- 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