diff --git a/FineTune/Audio/AutoEQ/AutoEQProfileManager.swift b/FineTune/Audio/AutoEQ/AutoEQProfileManager.swift index f857619b..14623875 100644 --- a/FineTune/Audio/AutoEQ/AutoEQProfileManager.swift +++ b/FineTune/Audio/AutoEQ/AutoEQProfileManager.swift @@ -26,9 +26,10 @@ final class AutoEQProfileManager { /// Normalized names for fuzzy search (parallel array with sortedEntries). private var normalizedNames: [String] = [] - init(loader: AutoEQProfileLoader = AutoEQProfileLoader(), fetcher: AutoEQFetcher? = nil) { + init(loader: AutoEQProfileLoader = AutoEQProfileLoader(), fetcher: AutoEQFetcher? = nil, loadAutomatically: Bool = true) { self.loader = loader self.fetcher = fetcher ?? AutoEQFetcher() + guard loadAutomatically else { return } // Imported profiles are small — load synchronously let imported = loader.loadImportedProfiles() diff --git a/FineTune/Audio/Engine/AudioEngine.swift b/FineTune/Audio/Engine/AudioEngine.swift index cd3d07e6..d2aacec2 100644 --- a/FineTune/Audio/Engine/AudioEngine.swift +++ b/FineTune/Audio/Engine/AudioEngine.swift @@ -23,6 +23,39 @@ final class AudioEngine { private var taps: [pid_t: any ProcessTapControlling] = [:] + struct ControlRoutingState: Equatable { + var generation: UInt64 + var status: String + var error: ControlFailure? + } + + private(set) var controlRouting: [pid_t: ControlRoutingState] = [:] + private(set) var controlRoutingCompletion: UInt64 = 0 + private var nextControlRoutingGeneration: UInt64 = 0 + + @discardableResult + private func beginControlRouting(_ pid: pid_t) -> UInt64 { + nextControlRoutingGeneration &+= 1 + let generation = nextControlRoutingGeneration + controlRouting[pid] = ControlRoutingState(generation: generation, status: "pending", error: nil) + return generation + } + + private func finishControlRouting(_ pid: pid_t, generation: UInt64, failed: Bool = false) { + controlRoutingCompletion &+= 1 + guard controlRouting[pid]?.generation == generation else { return } + controlRouting[pid] = ControlRoutingState( + generation: generation, + status: failed ? "failed" : "applied", + error: failed ? ControlFailure(code: "routingFailed", message: "Unable to configure audio routing") : nil + ) + } + + private func removeControlRouting(_ pid: pid_t) { + controlRouting.removeValue(forKey: pid) + controlRoutingCompletion &+= 1 + } + /// Factory for creating tap controllers. Overridable for testing. private let tapFactory: @MainActor (AudioApp, [String], String?) throws -> any ProcessTapControlling @@ -51,6 +84,13 @@ final class AudioEngine { private var tapRecoveryCooldownUntil: [pid_t: Date] = [:] // Prevents tap recreation thrashing private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "FineTune", category: "AudioEngine") + // MARK: - External Control + + func controlAppliedDeviceUIDs(for pid: pid_t) -> [String]? { + _ = controlRoutingCompletion + return taps[pid]?.currentDeviceUIDs.sorted() + } + // MARK: - Priority State Machine /// Tracks whether we're waiting for macOS to potentially auto-switch after a device connect. @@ -468,6 +508,7 @@ final class AudioEngine { appListCoordinator.recordIgnore(app) if let tap = taps.removeValue(forKey: app.id) { + removeControlRouting(app.id) tap.invalidate() } appDeviceRouting.removeValue(forKey: app.id) @@ -591,6 +632,7 @@ final class AudioEngine { tap.invalidate() } taps.removeAll() + for pid in Array(controlRouting.keys) { removeControlRouting(pid) } logger.info("AudioEngine stopped") } @@ -908,12 +950,15 @@ final class AudioEngine { // If transitioning from follows-default to explicit and tap has a stream-specific // source, refresh to mixdown so it won't go stale when the default changes later. if let tap = taps[app.id], tap.tapSourceDeviceUID != nil { + let generation = beginControlRouting(app.id) Task { do { try await tap.refreshTapSource(nil) + self.finishControlRouting(app.id, generation: generation) self.applyTapOutputState(to: tap, for: app.id) } catch { self.logger.error("Failed to refresh tap source for \(app.name): \(error)") + self.finishControlRouting(app.id, generation: generation, failed: true) } } } @@ -940,14 +985,17 @@ final class AudioEngine { guard let targetUID = appDeviceRouting[app.id] else { return } let preferredTapSourceUID = preferredTapSourceDeviceUID(forOutputUIDs: [targetUID], isFollowsDefault: followsDefault.contains(app.id)) if let tap = taps[app.id] { + let generation = beginControlRouting(app.id) Task { do { try await tap.switchDevice(to: targetUID, preferredTapSourceDeviceUID: preferredTapSourceUID) + self.finishControlRouting(app.id, generation: generation) self.applyTapOutputState(to: tap, for: app.id, deviceUIDs: [targetUID]) self.applyAutoEQToTap(tap) self.logger.debug("Switched \(app.name) to device: \(targetUID)") } catch { self.logger.error("Failed to switch device for \(app.name): \(error.localizedDescription)") + self.finishControlRouting(app.id, generation: generation, failed: true) } } } else { @@ -1033,13 +1081,16 @@ final class AudioEngine { if let tap = taps[app.id] { // Tap exists - update devices if tap.currentDeviceUIDs != deviceUIDs { + let generation = beginControlRouting(app.id) do { let preferredTapSourceUID = preferredTapSourceDeviceUID(forOutputUIDs: deviceUIDs, isFollowsDefault: followsDefault.contains(app.id)) try await tap.updateDevices(to: deviceUIDs, preferredTapSourceDeviceUID: preferredTapSourceUID) + finishControlRouting(app.id, generation: generation) applyTapOutputState(to: tap, for: app.id, deviceUIDs: deviceUIDs) logger.debug("Updated \(app.name) to \(deviceUIDs.count) device(s)") } catch { logger.error("Failed to update devices for \(app.name): \(error.localizedDescription)") + finishControlRouting(app.id, generation: generation, failed: true) } } } else { @@ -1054,6 +1105,8 @@ final class AudioEngine { guard taps[app.id] == nil else { return } guard permission.status == .authorized else { return } + let generation = beginControlRouting(app.id) + let preferredTapSourceUID = preferredTapSourceDeviceUID(forOutputUIDs: deviceUIDs, isFollowsDefault: followsDefault.contains(app.id)) do { let tap = try tapFactory(app, deviceUIDs, preferredTapSourceUID) @@ -1066,6 +1119,7 @@ final class AudioEngine { ) try tap.activate(initial: initial) taps[app.id] = tap + finishControlRouting(app.id, generation: generation) // Catalog AutoEQ may not have been cached yet — kick off async resolve. if initial.autoEQProfile == nil { @@ -1074,6 +1128,7 @@ final class AudioEngine { logger.debug("Created tap for \(app.name) on \(deviceUIDs.count) device(s)") } catch { + finishControlRouting(app.id, generation: generation, failed: true) logger.error("Failed to create tap for \(app.name): \(error.localizedDescription)") } } @@ -1175,14 +1230,17 @@ final class AudioEngine { // If a tap already exists but is on the wrong device (e.g., app reappeared // after the default changed while it was absent), switch it. if let existingTap = taps[app.id], existingTap.currentDeviceUIDs != [deviceUID] { + let generation = beginControlRouting(app.id) let preferredSource = preferredTapSourceDeviceUID(forOutputUIDs: [deviceUID], isFollowsDefault: followsDefault.contains(app.id)) Task { do { try await existingTap.switchDevice(to: deviceUID, preferredTapSourceDeviceUID: preferredSource) + self.finishControlRouting(app.id, generation: generation) self.applyTapOutputState(to: existingTap, for: app.id, deviceUIDs: [deviceUID]) self.applyAutoEQToTap(existingTap) } catch { self.logger.error("Failed to re-route \(app.name) to \(deviceUID): \(error.localizedDescription)") + self.finishControlRouting(app.id, generation: generation, failed: true) } } appliedPIDs.insert(app.id) @@ -1215,6 +1273,8 @@ final class AudioEngine { guard taps[app.id] == nil else { return } guard permission.status == .authorized else { return } + let generation = beginControlRouting(app.id) + let preferredTapSourceUID = preferredTapSourceDeviceUID(forOutputUIDs: [deviceUID], isFollowsDefault: followsDefault.contains(app.id)) do { let tap = try tapFactory(app, [deviceUID], preferredTapSourceUID) @@ -1227,6 +1287,7 @@ final class AudioEngine { ) try tap.activate(initial: initial) taps[app.id] = tap + finishControlRouting(app.id, generation: generation) // Catalog AutoEQ may not have been cached yet — kick off async resolve. // Imported profiles always hit the synchronous path above. @@ -1236,6 +1297,7 @@ final class AudioEngine { logger.debug("Created tap for \(app.name)") } catch { + finishControlRouting(app.id, generation: generation, failed: true) logger.error("Failed to create tap for \(app.name): \(error.localizedDescription)") } } @@ -1312,22 +1374,24 @@ final class AudioEngine { appDeviceRouting[pid] = targetUID } - var tapsToSwitch: [(app: AudioApp, tap: any ProcessTapControlling)] = [] + var tapsToSwitch: [(app: AudioApp, tap: any ProcessTapControlling, generation: UInt64)] = [] for app in apps { guard followsDefault.contains(app.id), let tap = taps[app.id] else { continue } - tapsToSwitch.append((app, tap)) + tapsToSwitch.append((app, tap, beginControlRouting(app.id))) } guard !tapsToSwitch.isEmpty else { return } Task { - for (app, tap) in tapsToSwitch { + for (app, tap, generation) in tapsToSwitch { do { let preferredTapSourceUID = self.preferredTapSourceDeviceUID(forOutputUIDs: [targetUID], isFollowsDefault: true) try await tap.switchDevice(to: targetUID, preferredTapSourceDeviceUID: preferredTapSourceUID) + self.finishControlRouting(app.id, generation: generation) self.applyTapOutputState(to: tap, for: app.id, deviceUIDs: [targetUID]) self.applyAutoEQToTap(tap) } catch { self.logger.error("Failed to switch \(app.name) to \(targetUID): \(error.localizedDescription)") + self.finishControlRouting(app.id, generation: generation, failed: true) } } } @@ -1356,8 +1420,8 @@ final class AudioEngine { ) var affectedApps: [AudioApp] = [] - var singleModeTapsToSwitch: [(tap: any ProcessTapControlling, fallbackUID: String)] = [] - var multiModeTapsToUpdate: [(tap: any ProcessTapControlling, remainingUIDs: [String])] = [] + var singleModeTapsToSwitch: [(tap: any ProcessTapControlling, fallbackUID: String, generation: UInt64)] = [] + var multiModeTapsToUpdate: [(tap: any ProcessTapControlling, remainingUIDs: [String], generation: UInt64)] = [] // Iterate over taps instead of apps - apps list may be empty if disconnected device // was the system default (CoreAudio removes app from process list when output disappears) @@ -1374,7 +1438,7 @@ final class AudioEngine { // Multi-device mode: remove disconnected device, keep others let remainingUIDs = tap.currentDeviceUIDs.filter { $0 != deviceUID }.sorted() if !remainingUIDs.isEmpty { - multiModeTapsToUpdate.append((tap: tap, remainingUIDs: remainingUIDs)) + multiModeTapsToUpdate.append((tap: tap, remainingUIDs: remainingUIDs, generation: beginControlRouting(app.id))) // Update in-memory selection to remove disconnected device (don't persist) var currentSelection = volumeState.getSelectedDeviceUIDs(for: app.id) currentSelection.remove(deviceUID) @@ -1390,7 +1454,7 @@ final class AudioEngine { // Set to follow default in-memory (UI shows "System Audio") // Don't persist - original device preference stays in settings for reconnection followsDefault.insert(app.id) - singleModeTapsToSwitch.append((tap: tap, fallbackUID: fallback.uid)) + singleModeTapsToSwitch.append((tap: tap, fallbackUID: fallback.uid, generation: beginControlRouting(app.id))) } else { logger.error("No fallback device available for \(app.name)") } @@ -1400,27 +1464,31 @@ final class AudioEngine { if !singleModeTapsToSwitch.isEmpty || !multiModeTapsToUpdate.isEmpty { Task { // Handle single-mode switches — source device is dead, skip crossfade - for (tap, fallbackUID) in singleModeTapsToSwitch { + for (tap, fallbackUID, generation) in singleModeTapsToSwitch { do { let preferredTapSourceUID = self.preferredTapSourceDeviceUID(forOutputUIDs: [fallbackUID], isFollowsDefault: true) try await tap.switchDevice(to: fallbackUID, preferredTapSourceDeviceUID: preferredTapSourceUID, sourceDeviceDead: true) + self.finishControlRouting(tap.app.id, generation: generation) self.applyTapOutputState(to: tap, for: tap.app.id, deviceUIDs: [fallbackUID]) self.applyAutoEQToTap(tap) } catch { self.logger.error("Failed to switch \(tap.app.name) to fallback: \(error.localizedDescription)") + self.finishControlRouting(tap.app.id, generation: generation, failed: true) } } // Handle multi-mode updates (remove disconnected device from aggregate) // Source device is dead, skip crossfade - for (tap, remainingUIDs) in multiModeTapsToUpdate { + for (tap, remainingUIDs, generation) in multiModeTapsToUpdate { do { let preferredTapSourceUID = self.preferredTapSourceDeviceUID(forOutputUIDs: remainingUIDs, isFollowsDefault: self.followsDefault.contains(tap.app.id)) try await tap.updateDevices(to: remainingUIDs, preferredTapSourceDeviceUID: preferredTapSourceUID, sourceDeviceDead: true) + self.finishControlRouting(tap.app.id, generation: generation) self.applyTapOutputState(to: tap, for: tap.app.id, deviceUIDs: remainingUIDs) self.logger.debug("Removed \(deviceName) from \(tap.app.name) multi-device output") } catch { self.logger.error("Failed to update \(tap.app.name) devices: \(error.localizedDescription)") + self.finishControlRouting(tap.app.id, generation: generation, failed: true) } } } @@ -1446,7 +1514,7 @@ final class AudioEngine { settingsManager.ensureDeviceInPriority(deviceUID) var affectedApps: [AudioApp] = [] - var tapsToSwitch: [any ProcessTapControlling] = [] + var tapsToSwitch: [(tap: any ProcessTapControlling, generation: UInt64)] = [] // Iterate over taps for consistency with handleDeviceDisconnected for tap in taps.values { @@ -1467,19 +1535,21 @@ final class AudioEngine { appDeviceRouting[app.id] = deviceUID // Remove from followsDefault since we're restoring explicit routing followsDefault.remove(app.id) - tapsToSwitch.append(tap) + tapsToSwitch.append((tap, beginControlRouting(app.id))) } if !tapsToSwitch.isEmpty { Task { - for tap in tapsToSwitch { + for (tap, generation) in tapsToSwitch { do { let preferredTapSourceUID = self.preferredTapSourceDeviceUID(forOutputUIDs: [deviceUID], isFollowsDefault: false) try await tap.switchDevice(to: deviceUID, preferredTapSourceDeviceUID: preferredTapSourceUID) + self.finishControlRouting(tap.app.id, generation: generation) self.applyTapOutputState(to: tap, for: tap.app.id, deviceUIDs: [deviceUID]) self.applyAutoEQToTap(tap) } catch { self.logger.error("Failed to switch \(tap.app.name) back to \(deviceName): \(error.localizedDescription)") + self.finishControlRouting(tap.app.id, generation: generation, failed: true) } } } @@ -1849,6 +1919,7 @@ final class AudioEngine { // Now safe to cleanup if let tap = self.taps.removeValue(forKey: pid) { + self.removeControlRouting(pid) tap.invalidate() self.logger.debug("Cleaned up stale tap for PID \(pid)") } @@ -1943,6 +2014,7 @@ final class AudioEngine { /// to prevent orphaned IO procs from accumulating (issue #176). private func recreateTap(for pid: pid_t) async { guard let oldTap = taps.removeValue(forKey: pid) else { return } + removeControlRouting(pid) let deviceUIDs = oldTap.currentDeviceUIDs await oldTap.invalidateAsync() @@ -1987,10 +2059,13 @@ final class AudioEngine { logger.info("[RATE] BT output \(uid, privacy: .public) → \(newRate, format: .fixed(precision: 0)) Hz — recreating affected taps (clean dip)") let affected = taps.filter { $0.value.currentDeviceUIDs.contains(uid) } for (pid, tap) in affected { + let generation = beginControlRouting(pid) do { logger.info("[RATE] Recreating tap for PID \(pid)") try await tap.recreateForOutputRateChange() + finishControlRouting(pid, generation: generation) } catch { + finishControlRouting(pid, generation: generation, failed: true) logger.error("[RATE] Recreate failed for PID \(pid): \(error.localizedDescription) — falling back to full recreate") await recreateTap(for: pid) } @@ -2113,7 +2188,8 @@ final class AudioEngine { /// Called when user explicitly selects an input device (via FineTune UI). /// Persists the choice and applies the change. - func setLockedInputDevice(_ device: AudioDevice) { + @discardableResult + func setLockedInputDevice(_ device: AudioDevice) -> Bool { logger.info("User locked input device to: \(device.name)") // Persist the choice — both current lock and preferred (user intent) @@ -2123,7 +2199,9 @@ final class AudioEngine { // Apply the change if deviceVolumeMonitor.setDefaultInputDevice(device.id) { inputEchoTracker.increment(device.uid) + return true } + return false } /// Called when an input device connects — restores locked/preferred device and guards against auto-switch. diff --git a/FineTune/Audio/Monitors/DeviceVolumeMonitor.swift b/FineTune/Audio/Monitors/DeviceVolumeMonitor.swift index 3c11d97e..084cfc62 100644 --- a/FineTune/Audio/Monitors/DeviceVolumeMonitor.swift +++ b/FineTune/Audio/Monitors/DeviceVolumeMonitor.swift @@ -56,6 +56,57 @@ final class DeviceVolumeMonitor: DeviceVolumeProviding { /// The current default input device UID (cached to avoid redundant Core Audio calls) private(set) var defaultInputDeviceUID: String? + private struct ControlReadbackKey: Hashable { + let deviceID: AudioDeviceID + let input: Bool + } + private var controlReadability: [ControlReadbackKey: (volume: Bool, mute: Bool, backend: VolumeControlTier)] = [:] + private let hardwareVolumeAvailable: @MainActor (AudioDeviceID) -> Bool + private let hardwareReadability: @MainActor (AudioDeviceID, Bool) -> (volume: Bool, mute: Bool) + + /// Cached values only; helper fallbacks (unity/false for unsupported HAL properties) + /// are not readback. In particular, a pending DDC probe is not 50% volume. + func controlReadback(for deviceID: AudioDeviceID, input: Bool) -> (volume: Float?, muted: Bool?, source: String?) { + guard let readable = controlReadability[ControlReadbackKey(deviceID: deviceID, input: input)] else { + return (nil, nil, nil) + } + if !input { + let backend = readable.backend + if backend == .software { return (volumes[deviceID], muteStates[deviceID], "software") } + #if !APP_STORE + if backend == .ddc { + let volume = ddcController?.getVolume(for: deviceID).map { Float($0) / 100 } + return (volume, muteStates[deviceID], volume == nil ? nil : "ddc") + } + #endif + } + let volume = readable.volume ? (input ? inputVolumes[deviceID] : volumes[deviceID]) : nil + let muted = readable.mute ? (input ? inputMuteStates[deviceID] : muteStates[deviceID]) : nil + return (volume, muted, volume == nil && muted == nil ? nil : "hardware") + } + + func refreshControlReadability(for deviceID: AudioDeviceID, input: Bool, backend: VolumeControlTier? = nil) { + let selected = backend ?? (input ? .hardware : outputVolumeBackend(for: deviceID)) + let readable = selected == .hardware ? hardwareReadability(deviceID, input) : (volume: false, mute: false) + controlReadability[ControlReadbackKey(deviceID: deviceID, input: input)] = (readable.volume, readable.mute, selected) + } + + static func readControlHardwareMetadata(_ deviceID: AudioDeviceID, _ input: Bool) -> (volume: Bool, mute: Bool) { + let scope = input ? kAudioObjectPropertyScopeInput : kAudioObjectPropertyScopeOutput + func readable(_ selector: AudioObjectPropertySelector, element: AudioObjectPropertyElement = kAudioObjectPropertyElementMain) -> Bool { + var address = AudioObjectPropertyAddress(mSelector: selector, mScope: scope, mElement: element) + guard AudioObjectHasProperty(deviceID, &address) else { return false } + var value: UInt32 = 0 + var size = UInt32(MemoryLayout.size) + return AudioObjectGetPropertyData(deviceID, &address, 0, nil, &size, &value) == noErr + } + return ( + volume: readable(kAudioHardwareServiceDeviceProperty_VirtualMainVolume) || + readable(kAudioDevicePropertyVolumeScalar) || readable(kAudioDevicePropertyVolumeScalar, element: 1), + mute: readable(kAudioDevicePropertyMute) + ) + } + /// Called when any input device's volume changes (deviceID, newVolume) var onInputVolumeChanged: ((AudioDeviceID, Float) -> Void)? @@ -144,15 +195,27 @@ final class DeviceVolumeMonitor: DeviceVolumeProviding { ) #if !APP_STORE - init(deviceMonitor: AudioDeviceMonitor, settingsManager: SettingsManager, ddcController: DDCController? = nil) { + init( + deviceMonitor: AudioDeviceMonitor, settingsManager: SettingsManager, ddcController: DDCController? = nil, + hardwareVolumeAvailable: @escaping @MainActor (AudioDeviceID) -> Bool = { $0.hasOutputVolumeControl() }, + hardwareReadability: @escaping @MainActor (AudioDeviceID, Bool) -> (volume: Bool, mute: Bool) = DeviceVolumeMonitor.readControlHardwareMetadata + ) { self.deviceMonitor = deviceMonitor self.settingsManager = settingsManager self.ddcController = ddcController + self.hardwareVolumeAvailable = hardwareVolumeAvailable + self.hardwareReadability = hardwareReadability } #else - init(deviceMonitor: AudioDeviceMonitor, settingsManager: SettingsManager) { + init( + deviceMonitor: AudioDeviceMonitor, settingsManager: SettingsManager, + hardwareVolumeAvailable: @escaping @MainActor (AudioDeviceID) -> Bool = { $0.hasOutputVolumeControl() }, + hardwareReadability: @escaping @MainActor (AudioDeviceID, Bool) -> (volume: Bool, mute: Bool) = DeviceVolumeMonitor.readControlHardwareMetadata + ) { self.deviceMonitor = deviceMonitor self.settingsManager = settingsManager + self.hardwareVolumeAvailable = hardwareVolumeAvailable + self.hardwareReadability = hardwareReadability } #endif @@ -173,7 +236,7 @@ final class DeviceVolumeMonitor: DeviceVolumeProviding { } private func autoDetectBackend(for deviceID: AudioDeviceID) -> VolumeControlTier { - if deviceID.hasOutputVolumeControl() { + if hardwareVolumeAvailable(deviceID) { return .hardware } @@ -332,6 +395,7 @@ final class DeviceVolumeMonitor: DeviceVolumeProviding { cancelAllVolumeLogTasks() volumes.removeAll() + controlReadability.removeAll() muteStates.removeAll() systemDeviceID = .unknown systemDeviceUID = nil @@ -770,6 +834,7 @@ final class DeviceVolumeMonitor: DeviceVolumeProviding { for deviceID in staleVolumeIDs { removeVolumeListener(for: deviceID) volumes.removeValue(forKey: deviceID) + controlReadability.removeValue(forKey: ControlReadbackKey(deviceID: deviceID, input: false)) cancelBluetoothOutputConfirmation(for: deviceID) } @@ -879,6 +944,7 @@ final class DeviceVolumeMonitor: DeviceVolumeProviding { #endif let newVolume = clampedVolume(deviceID.readOutputVolumeScalar()) + refreshControlReadability(for: deviceID, input: false) // Deduplicate: HAL often fires L/R channel notifications for the same volume value. // Both values come from the same CoreAudio API (not computed), so == is safe for Float32. @@ -938,6 +1004,7 @@ final class DeviceVolumeMonitor: DeviceVolumeProviding { guard deviceID.isValid else { return } if outputVolumeBackend(for: deviceID) == .software { return } let newMuteState = deviceID.readMuteState() + refreshControlReadability(for: deviceID, input: false) muteStates[deviceID] = newMuteState onMuteChanged?(deviceID, newMuteState) logger.debug("Mute changed for device \(deviceID): \(newMuteState)") @@ -961,6 +1028,7 @@ final class DeviceVolumeMonitor: DeviceVolumeProviding { let backend = outputVolumeBackend(for: deviceID) + refreshControlReadability(for: deviceID, input: false, backend: backend) if backend == .software { let muted = settingsManager.getSoftwareDeviceMuteState(for: device.uid) let defaultVolume: Float = muted ? 0 : 1.0 @@ -1106,6 +1174,7 @@ final class DeviceVolumeMonitor: DeviceVolumeProviding { for deviceID in staleVolumeIDs { removeInputVolumeListener(for: deviceID) inputVolumes.removeValue(forKey: deviceID) + controlReadability.removeValue(forKey: ControlReadbackKey(deviceID: deviceID, input: true)) cancelBluetoothInputConfirmation(for: deviceID) } @@ -1158,6 +1227,7 @@ final class DeviceVolumeMonitor: DeviceVolumeProviding { private func handleInputVolumeChanged(for deviceID: AudioDeviceID) { guard deviceID.isValid else { return } let newVolume = deviceID.readInputVolumeScalar() + refreshControlReadability(for: deviceID, input: true) // Deduplicate: same logic as output volume if let currentVolume = inputVolumes[deviceID], currentVolume == newVolume { return } @@ -1206,6 +1276,7 @@ final class DeviceVolumeMonitor: DeviceVolumeProviding { private func handleInputMuteChanged(for deviceID: AudioDeviceID) { guard deviceID.isValid else { return } let newMuteState = deviceID.readInputMuteState() + refreshControlReadability(for: deviceID, input: true) inputMuteStates[deviceID] = newMuteState onInputMuteChanged?(deviceID, newMuteState) logger.debug("Input mute changed for device \(deviceID): \(newMuteState)") @@ -1218,6 +1289,7 @@ final class DeviceVolumeMonitor: DeviceVolumeProviding { guard device.id.isDeviceAlive() else { continue } let volume = device.id.readInputVolumeScalar() + refreshControlReadability(for: device.id, input: true) inputVolumes[device.id] = volume let muted = device.id.readInputMuteState() @@ -1263,6 +1335,7 @@ final class DeviceVolumeMonitor: DeviceVolumeProviding { try? await Task.sleep(for: .milliseconds(300)) guard let self, !Task.isCancelled, self.volumes.keys.contains(deviceID) else { return } let confirmedVolume = self.clampedVolume(deviceID.readOutputVolumeScalar()) + self.refreshControlReadability(for: deviceID, input: false) let confirmedMute = deviceID.readMuteState() self.volumes[deviceID] = confirmedVolume self.muteStates[deviceID] = confirmedMute @@ -1284,6 +1357,7 @@ final class DeviceVolumeMonitor: DeviceVolumeProviding { try? await Task.sleep(for: .milliseconds(300)) guard let self, !Task.isCancelled, self.inputVolumes.keys.contains(deviceID) else { return } let confirmedVolume = deviceID.readInputVolumeScalar() + self.refreshControlReadability(for: deviceID, input: true) let confirmedMute = deviceID.readInputMuteState() self.inputVolumes[deviceID] = confirmedVolume self.inputMuteStates[deviceID] = confirmedMute diff --git a/FineTune/Audio/Permission/AudioRecordingPermission.swift b/FineTune/Audio/Permission/AudioRecordingPermission.swift index 55b02dd3..b780326c 100644 --- a/FineTune/Audio/Permission/AudioRecordingPermission.swift +++ b/FineTune/Audio/Permission/AudioRecordingPermission.swift @@ -21,7 +21,12 @@ final class AudioRecordingPermission { var status: AudioCapturePermissionStatus = .unknown - init() { + init(status: AudioCapturePermissionStatus? = nil) { + // Tests can supply a status without querying TCC or registering app observers. + if let status { + self.status = status + return + } refreshStatus() registerForActivation() } diff --git a/FineTune/ControlAPI/AudioEngine+Control.swift b/FineTune/ControlAPI/AudioEngine+Control.swift new file mode 100644 index 00000000..8d395c33 --- /dev/null +++ b/FineTune/ControlAPI/AudioEngine+Control.swift @@ -0,0 +1,130 @@ +import Foundation + +extension AudioEngine: ControlEngine { + func controlState() -> ControlJSON { + let appRows = apps.sorted { + if $0.persistenceIdentifier == $1.persistenceIdentifier { return $0.id < $1.id } + return $0.persistenceIdentifier < $1.persistenceIdentifier + }.map { app -> ControlJSON in + let requested: ControlJSON + if getDeviceSelectionMode(for: app) == .multi { + requested = .object([ + "mode": .string("multi"), + "deviceUIDs": .array(getSelectedDeviceUIDs(for: app).sorted().map(ControlJSON.string)) + ]) + } else if isFollowingDefault(for: app) || getDeviceUID(for: app) == nil { + requested = .object(["mode": .string("followDefault")]) + } else { + requested = .object(["mode": .string("single"), "deviceUID": .optional(getDeviceUID(for: app))]) + } + let applied = controlAppliedDeviceUIDs(for: app.id) + let routing = controlRouting[app.id] + return .object([ + "appId": .string(app.persistenceIdentifier), "pids": .array([.number(Double(app.id))]), + "name": .string(app.name), "active": .bool(true), + "volume": .number(Double(getVolume(for: app))), "boost": .number(Double(getBoost(for: app).rawValue)), + "muted": .bool(getMute(for: app)), + "routing": .object([ + "requested": requested, + "appliedDeviceUIDs": applied.map { .array($0.map(ControlJSON.string)) } ?? .null, + "status": .string(routing?.status ?? (applied == nil ? "unavailable" : "applied")), + "error": routing?.error?.json ?? .null + ]) + ]) + } + var devices: [ControlJSON] = [] + for (direction, connected) in [("input", inputDevices), ("output", outputDevices)] { + for device in connected.sorted(by: { $0.uid < $1.uid }) { + let input = direction == "input" + let readback = (deviceVolumeMonitor as? DeviceVolumeMonitor)?.controlReadback(for: device.id, input: input) + devices.append(.object([ + "uid": .string(device.uid), "name": .string(device.name), "direction": .string(direction), + "isDefault": .bool(device.uid == (input ? deviceVolumeMonitor.defaultInputDeviceUID : deviceVolumeMonitor.defaultDeviceUID)), + "volume": .optional(readback?.volume), "muted": .optional(readback?.muted), + "volumeSource": .optional(readback?.source), + "capabilities": .object([ + "volumeReadable": .bool(readback?.volume != nil), + "muteReadable": .bool(readback?.muted != nil), "selectable": .bool(true) + ]) + ])) + } + } + let permissionStatus: String + switch permission.status { + case .unknown: permissionStatus = "unknown" + case .authorized: permissionStatus = "authorized" + case .denied: permissionStatus = "denied" + } + #if ENABLE_TCC_SPI + let permissionSource = "preflight" + #else + let permissionSource = "assumed" + #endif + return .object([ + "apps": .array(appRows), "devices": .array(devices), + "defaultOutput": .optional(deviceVolumeMonitor.defaultDeviceUID), + "defaultInput": .optional(deviceVolumeMonitor.defaultInputDeviceUID), + "inputPolicy": .object([ + "lockEnabled": .bool(settingsManager.appSettings.lockInputDevice), + "preferredDeviceUID": .optional(settingsManager.preferredInputDeviceUID), + "lockedDeviceUID": .optional(settingsManager.lockedInputDeviceUID) + ]), + "permission": .object(["audioCapture": .string(permissionStatus), "source": .string(permissionSource)]) + ]) + } + + func performControlCommand(_ command: ControlCommand) throws { + func resolve(_ appID: String, _ pid: Int32?) throws -> AudioApp { + let matches = apps.filter { $0.persistenceIdentifier == appID } + if let pid { + guard let app = matches.first(where: { $0.id == pid }) else { + throw ControlFailure(code: "staleApp", message: "The process no longer matches this app") + } + return app + } + guard !matches.isEmpty else { throw ControlFailure(code: "appNotFound", message: "App is not active") } + guard matches.count == 1 else { throw ControlFailure(code: "ambiguousApp", message: "Specify a current process ID") } + return matches[0] + } + func requireCapture() throws { + guard permission.status == .authorized else { + throw ControlFailure(code: "permissionDenied", message: "Audio capture permission is required") + } + } + switch command { + case .getState, .subscribe: break + case .volume(let appID, let pid, let volume): + let app = try resolve(appID, pid) + try requireCapture() + setVolume(for: app, to: volume) + case .mute(let appID, let pid, let muted): + let app = try resolve(appID, pid) + try requireCapture() + setMute(for: app, to: muted) + case .routing(let appID, let pid, let uid): + let app = try resolve(appID, pid) + try requireCapture() + guard getDeviceSelectionMode(for: app) != .multi else { + throw ControlFailure(code: "unsupportedRouting", message: "Multi-output routing is read-only") + } + if let uid, !outputDevices.contains(where: { $0.uid == uid }) { + throw ControlFailure(code: "deviceNotFound", message: "Output device is not connected") + } + setDevice(for: app, deviceUID: uid) + case .defaultOutput(let uid): + guard let device = outputDevices.first(where: { $0.uid == uid }) else { + throw ControlFailure(code: "deviceNotFound", message: "Output device is not connected") + } + guard setDefaultOutputDevice(device.id) else { + throw ControlFailure(code: "commandFailed", message: "Unable to select output device") + } + case .preferredInput(let uid): + guard let device = inputDevices.first(where: { $0.uid == uid }) else { + throw ControlFailure(code: "deviceNotFound", message: "Input device is not connected") + } + guard setLockedInputDevice(device) else { + throw ControlFailure(code: "commandFailed", message: "Unable to select input device") + } + } + } +} diff --git a/FineTune/ControlAPI/ControlProtocol.swift b/FineTune/ControlAPI/ControlProtocol.swift new file mode 100644 index 00000000..5fdf063f --- /dev/null +++ b/FineTune/ControlAPI/ControlProtocol.swift @@ -0,0 +1,128 @@ +import Foundation + +nonisolated enum ControlJSON: Codable, Equatable, Sendable { + case object([String: ControlJSON]), array([ControlJSON]), string(String), number(Double), bool(Bool), null + + init(from decoder: Decoder) throws { + let c = try decoder.singleValueContainer() + if c.decodeNil() { self = .null } + else if let value = try? c.decode(Bool.self) { self = .bool(value) } + else if let value = try? c.decode(Double.self) { self = .number(value) } + else if let value = try? c.decode(String.self) { self = .string(value) } + else if let value = try? c.decode([String: ControlJSON].self) { self = .object(value) } + else { self = .array(try c.decode([ControlJSON].self)) } + } + + func encode(to encoder: Encoder) throws { + var c = encoder.singleValueContainer() + switch self { + case .object(let value): try c.encode(value) + case .array(let value): try c.encode(value) + case .string(let value): try c.encode(value) + case .number(let value): try c.encode(value) + case .bool(let value): try c.encode(value) + case .null: try c.encodeNil() + } + } + + var string: String? { if case .string(let value) = self { value } else { nil } } + var object: [String: ControlJSON]? { if case .object(let value) = self { value } else { nil } } + static func optional(_ value: String?) -> Self { value.map(Self.string) ?? .null } + static func optional(_ value: Float?) -> Self { value.map { .number(Double($0)) } ?? .null } + static func optional(_ value: Bool?) -> Self { value.map(Self.bool) ?? .null } +} + +nonisolated struct ControlFailure: LocalizedError, Equatable, Sendable { + let code: String + let message: String + var errorDescription: String? { message } + var json: ControlJSON { .object(["code": .string(code), "message": .string(message)]) } +} + +nonisolated enum ControlCommand: Equatable, Sendable { + case getState, subscribe + case volume(String, Int32?, Float) + case mute(String, Int32?, Bool) + case routing(String, Int32?, String?) + case defaultOutput(String) + case preferredInput(String) + + static let capabilities = ["getState", "subscribe", "setAppVolume", "setAppMute", "setAppRouting", "setDefaultOutput", "setPreferredInput"] + + static func parse(method: String, params: [String: ControlJSON]) throws -> Self { + func string(_ key: String, in values: [String: ControlJSON]) throws -> String { + guard let value = values[key]?.string, !value.isEmpty else { + throw ControlFailure(code: "invalidParams", message: "\(key) must be a nonempty string") + } + return value + } + switch method { + case "getState": return .getState + case "subscribe": return .subscribe + case "setDefaultOutput": return .defaultOutput(try string("deviceUID", in: params)) + case "setPreferredInput": return .preferredInput(try string("deviceUID", in: params)) + case "setAppVolume", "setAppMute", "setAppRouting": + let appID = try string("appId", in: params) + var pid: Int32? + if let value = params["pid"] { + guard case .number(let number) = value, number > 0, + number <= Double(Int32.max), number.rounded() == number else { + throw ControlFailure(code: "invalidParams", message: "pid must be a positive integer") + } + pid = Int32(number) + } + if method == "setAppVolume" { + guard case .number(let volume) = params["volume"], volume.isFinite, (0...1).contains(volume) else { + throw ControlFailure(code: "invalidParams", message: "volume must be finite and between 0 and 1") + } + return .volume(appID, pid, Float(volume)) + } + if method == "setAppMute" { + guard case .bool(let muted) = params["muted"] else { + throw ControlFailure(code: "invalidParams", message: "muted must be a boolean") + } + return .mute(appID, pid, muted) + } + guard let routing = params["routing"]?.object else { + throw ControlFailure(code: "invalidParams", message: "routing must be an object") + } + switch try string("mode", in: routing) { + case "followDefault": return .routing(appID, pid, nil) + case "single": return .routing(appID, pid, try string("deviceUID", in: routing)) + case "multi": throw ControlFailure(code: "unsupportedRouting", message: "Multi-output routing is read-only") + default: throw ControlFailure(code: "invalidParams", message: "Unknown routing mode") + } + default: throw ControlFailure(code: "unknownMethod", message: "Unknown method: \(method)") + } + } +} + +nonisolated struct ControlRequest { + let id: String + let sessionID: String + let method: String + let params: [String: ControlJSON] + + init(data: Data) throws { + guard String(data: data, encoding: .utf8) != nil, + let object = try JSONDecoder().decode(ControlJSON.self, from: data).object, + object["type"] == .string("request"), + let id = object["id"]?.string, !id.isEmpty, id.utf8.count <= 128, + let sessionID = object["sessionId"]?.string, + let method = object["method"]?.string, + let params = object["params"]?.object else { + throw ControlFailure(code: "invalidEnvelope", message: "Malformed request envelope") + } + self.id = id + self.sessionID = sessionID + self.method = method + self.params = params + } +} + +@MainActor +protocol ControlEngine: AnyObject { + /// Must read observable dependencies, including asynchronous routing completion state. + func controlState() -> ControlJSON + func performControlCommand(_ command: ControlCommand) throws +} diff --git a/FineTune/ControlAPI/ControlService.swift b/FineTune/ControlAPI/ControlService.swift new file mode 100644 index 00000000..fad18daa --- /dev/null +++ b/FineTune/ControlAPI/ControlService.swift @@ -0,0 +1,237 @@ +import Foundation +import Observation +import os + +@Observable +@MainActor +final class ControlService { + private(set) var startupFailure: String? + private(set) var sessionID = "" + private(set) var revision: UInt64 = 0 + private let engine: any ControlEngine + private let settings: SettingsManager? + private let server: UnixSocketServer + private let send: @MainActor (Data, UUID, @escaping @MainActor () -> Void) -> Void + private let onReady: @MainActor () -> Void + private let disconnect: @MainActor (UUID) -> Void + private let schedule: @MainActor (@escaping @MainActor () -> Void) -> Task + private var clients: [UUID: Set] = [:] + private var subscribers: Set = [] + private var published: ControlJSON = .null + private var publication: Task? + private var running = false + private var observingSettings = false + private let logger = Logger(subsystem: "com.finetuneapp.FineTune", category: "ControlAPI") + + init( + engine: any ControlEngine, + settings: SettingsManager? = nil, + server: UnixSocketServer = UnixSocketServer(), + send: (@MainActor (Data, UUID, @escaping @MainActor () -> Void) -> Void)? = nil, + disconnect: (@MainActor (UUID) -> Void)? = nil, + onReady: @escaping @MainActor () -> Void = {}, + schedule: @escaping @MainActor (@escaping @MainActor () -> Void) -> Task = { action in + Task { @MainActor in + try? await Task.sleep(for: .milliseconds(40)) + guard !Task.isCancelled else { return } + action() + } + } + ) { + self.engine = engine + self.settings = settings + self.server = server + self.send = send ?? { data, id, completed in + server.send(data, to: id) { DispatchQueue.main.async { completed() } } + } + self.onReady = onReady + self.disconnect = disconnect ?? { server.disconnect($0) } + self.schedule = schedule + } + + /// Lives at app scope, not in the menu bar popup's view lifecycle. + func observeSetting() { + guard !observingSettings else { return } + observingSettings = true + trackSetting() + } + + private func trackSetting() { + guard observingSettings, let settings else { return } + let enabled = withObservationTracking { + settings.appSettings.externalControlEnabled + } onChange: { [weak self] in + DispatchQueue.main.async { self?.trackSetting() } + } + if enabled { start() } else { stopListener() } + } + + func start(listen: Bool = true) { + guard !running else { return } + running = true + startupFailure = nil + sessionID = UUID().uuidString + revision = 0 + published = engine.controlState() + trackState(session: sessionID) + guard listen else { return } + let session = sessionID + server.start(onConnect: { [weak self] id in + DispatchQueue.main.async { self?.connected(id, session: session) } + }, onFrame: { [weak self] id, data, completed in + DispatchQueue.main.async { + self?.received(data, from: id, session: session) + completed() + } + }, onDisconnect: { [weak self] id in + DispatchQueue.main.async { + guard self?.sessionID == session else { return } + self?.clients.removeValue(forKey: id) + self?.subscribers.remove(id) + } + }, onFailure: { [weak self] message in + DispatchQueue.main.async { + guard let self, self.sessionID == session else { return } + self.stopListener() + self.startupFailure = message + } + }, onReady: { [weak self] in + DispatchQueue.main.async { + guard let self, self.running, self.sessionID == session else { return } + self.onReady() + } + }) + } + + func stop(completion: @escaping @Sendable () -> Void = {}) { + observingSettings = false + stopListener(completion: completion) + } + + private func stopListener(completion: @escaping @Sendable () -> Void = {}) { + running = false + publication?.cancel() + publication = nil + clients.removeAll() + subscribers.removeAll() + server.stop(completion: completion) + } + + private func trackState(session: String) { + guard running, sessionID == session else { return } + _ = withObservationTracking { + engine.controlState() + } onChange: { [weak self] in + // onChange runs before mutation. Re-arm next turn independently of publication. + DispatchQueue.main.async { + guard let self, self.running, self.sessionID == session else { return } + self.trackState(session: session) + if self.publication == nil { + self.publication = self.schedule { [weak self] in + guard let self, self.sessionID == session else { return } + self.publication = nil + self.flush() + } + } + } + } + } + + func connected(_ id: UUID, session: String) { + guard running, sessionID == session else { disconnect(id); return } + clients[id] = [] + enqueue(.object([ + "type": .string("hello"), "protocol": .string("finetune-control"), "version": .number(1), + "appVersion": .string(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "unknown"), + "sessionId": .string(sessionID), "capabilities": .array(ControlCommand.capabilities.map(ControlJSON.string)) + ]), to: id) + } + + func received(_ data: Data, from id: UUID, session: String) { + guard running, sessionID == session, clients[id] != nil else { return } + let request: ControlRequest + do { + request = try ControlRequest(data: data) + guard clients[id]?.contains(request.id) == false else { close(id); return } + clients[id]?.insert(request.id) + } catch { close(id); return } + do { + guard request.sessionID == sessionID else { + throw ControlFailure(code: "staleSession", message: "Read the current hello before issuing commands") + } + let command = try ControlCommand.parse(method: request.method, params: request.params) + switch command { + case .getState, .subscribe: + if command == .subscribe { subscribers.remove(id) } + flush() + respond(request.id, result: snapshot(), to: id) + if command == .subscribe { subscribers.insert(id) } + default: + try engine.performControlCommand(command) + // No suspension between dispatch and enqueue: command-caused events follow this response. + respond(request.id, result: .object(["accepted": .bool(true)]), to: id) + } + } catch let failure as ControlFailure { + enqueue(.object(["type": .string("response"), "id": .string(request.id), "error": failure.json]), to: id, requestID: request.id) + } catch { + logger.error("External controller command failed: \(error.localizedDescription)") + let failure = ControlFailure(code: "internalError", message: "Unable to execute command") + enqueue(.object(["type": .string("response"), "id": .string(request.id), "error": failure.json]), to: id, requestID: request.id) + } + } + + func flush() { + guard running else { return } + publication?.cancel() + publication = nil + let state = engine.controlState() + guard state != published else { return } + guard revision < 9_007_199_254_740_991 else { + logger.error("External controller revision exhausted; closing listener") + stopListener() + return + } + published = state + revision += 1 + let event = ControlJSON.object([ + "type": .string("event"), "event": .string("stateChanged"), + "sessionId": .string(sessionID), "revision": .number(Double(revision)), "state": published + ]) + for id in subscribers { enqueue(event, to: id) } + } + + private func snapshot() -> ControlJSON { + .object(["sessionId": .string(sessionID), "revision": .number(Double(revision)), "state": published]) + } + + private func respond(_ requestID: String, result: ControlJSON, to id: UUID) { + enqueue(.object(["type": .string("response"), "id": .string(requestID), "result": result]), to: id, requestID: requestID) + } + + var pendingRequestCount: Int { clients.values.reduce(0) { $0 + $1.count } } + + private func enqueue(_ value: ControlJSON, to id: UUID, requestID: String? = nil) { + do { + let data = try JSONEncoder().encode(value) + guard data.count <= UnixSocketServer.maximumFrameBytes else { + logger.error("External controller snapshot exceeds frame limit") + close(id) + return + } + let session = sessionID + send(data, id) { [weak self] in + guard let self, self.sessionID == session, let requestID else { return } + self.clients[id]?.remove(requestID) + } + } catch { + logger.error("Cannot encode external controller snapshot: \(error.localizedDescription)") + close(id) + } + } + + private func close(_ id: UUID) { + clients.removeValue(forKey: id) + subscribers.remove(id) + disconnect(id) + } +} diff --git a/FineTune/ControlAPI/ControlTermination.swift b/FineTune/ControlAPI/ControlTermination.swift new file mode 100644 index 00000000..c5c8fd78 --- /dev/null +++ b/FineTune/ControlAPI/ControlTermination.swift @@ -0,0 +1,37 @@ +import Foundation + +@MainActor +final class ControlTermination { + private var started = false + private var reply: (@MainActor () -> Void)? + private var fallback: Task? + private let schedule: (@escaping @MainActor () -> Void) -> Task + + init(schedule: @escaping (@escaping @MainActor () -> Void) -> Task = { action in + Task { @MainActor in + try? await Task.sleep(for: .seconds(2)) + guard !Task.isCancelled else { return } + action() + } + }) { + self.schedule = schedule + } + + func begin(stop: (@escaping @Sendable () -> Void) -> Void, reply: @escaping @MainActor () -> Void) { + guard !started else { return } + started = true + self.reply = reply + fallback = schedule { [weak self] in self?.finish() } + stop { [weak self] in + DispatchQueue.main.async { self?.finish() } + } + } + + private func finish() { + guard let reply else { return } + self.reply = nil + fallback?.cancel() + fallback = nil + reply() + } +} diff --git a/FineTune/ControlAPI/UnixSocketServer.swift b/FineTune/ControlAPI/UnixSocketServer.swift new file mode 100644 index 00000000..9e53bc08 --- /dev/null +++ b/FineTune/ControlAPI/UnixSocketServer.swift @@ -0,0 +1,377 @@ +import Foundation +import Darwin +import os + +/// All descriptors and buffers are confined to `queue`; callbacks run on that queue. +final class UnixSocketServer: @unchecked Sendable { + static let maximumFrameBytes = 1_048_576 + static let maximumOutputBytes = 4 * 1_048_576 + static let maximumOutputFrames = 8 + + private final class Client { + let fd: Int32 + let reader: DispatchSourceRead + let writer: DispatchSourceWrite + var input = Data() + var output: [(data: Data, completed: @Sendable () -> Void)] = [] + var offset = 0 + var partialSince: TimeInterval? + var writeProgress: TimeInterval? + var writerResumed = false + var readerSuspended = false + + init(fd: Int32, queue: DispatchQueue) { + self.fd = fd + reader = DispatchSource.makeReadSource(fileDescriptor: fd, queue: queue) + writer = DispatchSource.makeWriteSource(fileDescriptor: fd, queue: queue) + } + } + + private let queue = DispatchQueue(label: "com.finetuneapp.control") + private let directory: URL + private let peerUID: @Sendable (Int32) -> uid_t? + private let now: @Sendable () -> TimeInterval + private let fileStatus: @Sendable (Int32, String?, UnsafeMutablePointer) -> Int32 + private let logger = Logger(subsystem: "com.finetuneapp.FineTune", category: "ControlAPI") + private var listener: Int32 = -1 + private var lockFD: Int32 = -1 + private var directoryFD: Int32 = -1 + private var socketIdentity: (dev_t, ino_t)? + private var boundSocketPath: String? + private var acceptSource: DispatchSourceRead? + private var timer: DispatchSourceTimer? + private var clients: [UUID: Client] = [:] + private var onConnect: (@Sendable (UUID) -> Void)? + private var onFrame: (@Sendable (UUID, Data, @escaping @Sendable () -> Void) -> Void)? + private var onDisconnect: (@Sendable (UUID) -> Void)? + + init( + directory: URL? = nil, + peerUID: @escaping @Sendable (Int32) -> uid_t? = { fd in + var uid: uid_t = 0 + var gid: gid_t = 0 + return getpeereid(fd, &uid, &gid) == 0 ? uid : nil + }, + now: @escaping @Sendable () -> TimeInterval = { ProcessInfo.processInfo.systemUptime }, + fileStatus: @escaping @Sendable (Int32, String?, UnsafeMutablePointer) -> Int32 = UnixSocketServer.readFileStatus + ) { + self.directory = directory ?? FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Library/Application Support/FineTune/Control", isDirectory: true) + self.peerUID = peerUID + self.now = now + self.fileStatus = fileStatus + } + + static func readFileStatus(_ fd: Int32, _ name: String?, _ info: UnsafeMutablePointer) -> Int32 { + if let name { return fstatat(fd, name, info, AT_SYMLINK_NOFOLLOW) } + return fstat(fd, info) + } + + func start( + onConnect: @escaping @Sendable (UUID) -> Void, + onFrame: @escaping @Sendable (UUID, Data, @escaping @Sendable () -> Void) -> Void, + onDisconnect: @escaping @Sendable (UUID) -> Void, + onFailure: @escaping @Sendable (String) -> Void, + onReady: @escaping @Sendable () -> Void = {} + ) { + queue.async { + guard self.listener == -1 else { return } + self.onConnect = onConnect + self.onFrame = onFrame + self.onDisconnect = onDisconnect + do { + try self.openListener() + onReady() + } + catch { + self.logger.error("External controller listener failed: \(error.localizedDescription)") + self.stopOnQueue() + onFailure(error.localizedDescription) + } + } + } + + func send(_ payload: Data, to id: UUID, completed: @escaping @Sendable () -> Void = {}) { + queue.async { + guard let client = self.clients[id] else { return } + let buffered = client.output.reduce(0) { $0 + $1.data.count } - client.offset + guard payload.count <= Self.maximumFrameBytes, + client.output.count < Self.maximumOutputFrames, + buffered + payload.count + 1 <= Self.maximumOutputBytes else { + self.logger.error("Disconnecting external controller: outbound limit exceeded") + self.closeClient(id) + return + } + client.output.append((payload + Data([10]), completed)) + if client.writeProgress == nil { client.writeProgress = self.now() } + self.writeClient(id) + } + } + + func disconnect(_ id: UUID) { queue.async { self.closeClient(id) } } + func stop(completion: @escaping @Sendable () -> Void = {}) { + queue.async { self.stopOnQueue(); completion() } + } + + private func failure(_ message: String) -> ControlFailure { + ControlFailure(code: "listenerFailed", message: "\(message) (errno \(errno))") + } + + private func openValidatedSupport(_ support: URL) throws -> Int32 { + var fd = open("/", O_RDONLY | O_DIRECTORY | O_CLOEXEC) + guard fd >= 0 else { throw failure("Cannot open filesystem root") } + do { + // Canonical ancestors may belong to root or this user, but none may + // let another user replace a component of the absolute bind path. + for component in ["/"] + support.pathComponents.dropFirst() { + if component != "/" { + let next = openat(fd, component, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC) + guard next >= 0 else { throw failure("Cannot open socket path ancestor") } + close(fd) + fd = next + } + var info = stat() + guard fileStatus(fd, nil, &info) == 0, + info.st_uid == 0 || info.st_uid == geteuid(), info.st_mode & 0o022 == 0 else { + throw failure("Socket path ancestors must have trusted ownership and not be writable by others") + } + } + var info = stat() + guard fileStatus(fd, nil, &info) == 0, info.st_uid == geteuid() else { + throw failure("Application Support must belong to this user") + } + return fd + } catch { + close(fd) + throw error + } + } + + private func openListener() throws { + // Open each API-owned path component without following symlinks. Never chmod parents. + let parent = directory.deletingLastPathComponent() + // Foundation path standardization can preserve /var's symlink alias on macOS. + // realpath supplies the actual components for the no-follow traversal/bind. + guard let resolved = realpath(parent.deletingLastPathComponent().path, nil) else { + throw failure("Cannot resolve Application Support") + } + defer { free(resolved) } + let support = URL(fileURLWithPath: String(cString: resolved), isDirectory: true) + let supportFD = try openValidatedSupport(support) + defer { close(supportFD) } + if mkdirat(supportFD, parent.lastPathComponent, 0o700) != 0 && errno != EEXIST { + throw failure("Cannot create FineTune directory") + } + let parentFD = openat(supportFD, parent.lastPathComponent, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC) + guard parentFD >= 0 else { throw failure("Unsafe FineTune directory") } + defer { close(parentFD) } + var info = stat() + guard fileStatus(parentFD, nil, &info) == 0, info.st_uid == geteuid(), info.st_mode & 0o022 == 0 else { + throw failure("FineTune directory must belong to this user and not be writable by others") + } + if mkdirat(parentFD, directory.lastPathComponent, 0o700) != 0 && errno != EEXIST { + throw failure("Cannot create Control directory") + } + directoryFD = openat(parentFD, directory.lastPathComponent, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC) + guard directoryFD >= 0, fileStatus(directoryFD, nil, &info) == 0, info.st_uid == geteuid(), + fchmod(directoryFD, 0o700) == 0 else { throw failure("Unsafe Control directory") } + lockFD = openat(directoryFD, "server.lock", O_RDWR | O_CREAT | O_NOFOLLOW | O_CLOEXEC, 0o600) + guard lockFD >= 0, fileStatus(lockFD, nil, &info) == 0, info.st_uid == geteuid(), + info.st_mode & S_IFMT == S_IFREG, flock(lockFD, LOCK_EX | LOCK_NB) == 0 else { + throw failure("Cannot acquire external controller lock") + } + if fileStatus(directoryFD, "control.sock", &info) == 0 { + guard info.st_uid == geteuid(), info.st_mode & S_IFMT == S_IFSOCK else { + throw failure("Refusing unsafe existing control.sock") + } + guard unlinkat(directoryFD, "control.sock", 0) == 0 else { throw failure("Cannot remove stale socket") } + } else if errno != ENOENT { throw failure("Cannot inspect control.sock") } + + var address = sockaddr_un() + address.sun_family = sa_family_t(AF_UNIX) + let socketPath = support.appendingPathComponent(parent.lastPathComponent) + .appendingPathComponent(directory.lastPathComponent).appendingPathComponent("control.sock").path + let path = Array(socketPath.utf8) + [0] + guard path.count <= MemoryLayout.size(ofValue: address.sun_path) else { throw failure("Socket path is too long") } + withUnsafeMutableBytes(of: &address.sun_path) { $0.copyBytes(from: path) } + address.sun_len = UInt8(MemoryLayout.size) + listener = socket(AF_UNIX, SOCK_STREAM, 0) + guard listener >= 0 else { throw failure("Cannot create socket") } + guard fcntl(listener, F_SETFL, O_NONBLOCK) == 0, + fcntl(listener, F_SETFD, FD_CLOEXEC) == 0 else { throw failure("Cannot configure socket") } + let result = withUnsafePointer(to: &address) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { + Darwin.bind(listener, $0, socklen_t(MemoryLayout.size)) + } + } + guard result == 0 else { throw failure("Cannot bind socket") } + // Record the actual absolute-path inode first, so a namespace mismatch + // can clean up what bind created rather than leave an uninspected socket. + guard lstat(socketPath, &info) == 0, info.st_uid == geteuid(), info.st_mode & S_IFMT == S_IFSOCK else { + throw failure("Cannot inspect absolute bound socket") + } + socketIdentity = (info.st_dev, info.st_ino) + boundSocketPath = socketPath + guard fileStatus(directoryFD, "control.sock", &info) == 0, + info.st_dev == socketIdentity?.0, info.st_ino == socketIdentity?.1 else { + throw failure("Cannot inspect bound socket") + } + guard fchmodat(directoryFD, "control.sock", 0o600, AT_SYMLINK_NOFOLLOW) == 0, + listen(listener, 8) == 0 else { throw failure("Cannot secure/listen on socket") } + let source = DispatchSource.makeReadSource(fileDescriptor: listener, queue: queue) + source.setEventHandler { [weak self] in self?.acceptClients() } + acceptSource = source + source.resume() + let timer = DispatchSource.makeTimerSource(queue: queue) + timer.schedule(deadline: .now() + 1, repeating: 1) + timer.setEventHandler { [weak self] in self?.expireClients() } + self.timer = timer + timer.resume() + } + + private func acceptClients() { + while true { + let fd = accept(listener, nil, nil) + if fd < 0 { + if errno == EINTR { continue } + return + } + // Authenticate before installing a reader or examining a byte. + guard clients.count < 8, peerUID(fd) == geteuid(), + fcntl(fd, F_SETFL, O_NONBLOCK) == 0, + fcntl(fd, F_SETFD, FD_CLOEXEC) == 0 else { close(fd); continue } + var one: Int32 = 1 + guard setsockopt(fd, SOL_SOCKET, SO_NOSIGPIPE, &one, socklen_t(MemoryLayout.size(ofValue: one))) == 0 else { + close(fd); continue + } + let id = UUID() + let client = Client(fd: fd, queue: queue) + clients[id] = client + client.reader.setEventHandler { [weak self] in self?.readClient(id) } + client.writer.setEventHandler { [weak self] in self?.writeClient(id) } + // Closing from the read cancellation handler avoids a descriptor reuse race. + client.reader.setCancelHandler { close(fd) } + client.reader.resume() + onConnect?(id) + } + } + + private func readClient(_ id: UUID) { + guard let client = clients[id], !client.readerSuspended else { return } + var bytes = [UInt8](repeating: 0, count: 16_384) + while true { + let count = Darwin.read(client.fd, &bytes, bytes.count) + if count == 0 { closeClient(id); return } + if count < 0 { + if errno == EINTR { continue } + if errno != EAGAIN && errno != EWOULDBLOCK { closeClient(id) } + return + } + client.partialSince = now() + client.input.append(contentsOf: bytes.prefix(count)) + _ = deliverBufferedFrame(id) + return + } + } + + /// One request in flight per client prevents unbounded work/data queued to MainActor. + private func deliverBufferedFrame(_ id: UUID) -> Bool { + guard let client = clients[id] else { return false } + guard let newline = client.input.firstIndex(of: 10) else { + if client.input.count > Self.maximumFrameBytes { closeClient(id) } + return false + } + guard client.input.distance(from: client.input.startIndex, to: newline) <= Self.maximumFrameBytes else { + closeClient(id) + return false + } + var frame = Data(client.input[.. 0 else { closeClient(id); return } + client.offset += count + client.writeProgress = now() + if client.offset == first.data.count { + client.output.removeFirst() + client.offset = 0 + first.completed() + } + } + client.writeProgress = nil + if client.writerResumed { client.writer.suspend(); client.writerResumed = false } + } + + private func expireClients() { + let time = now() + for (id, client) in clients { + if client.partialSince.map({ time - $0 >= 10 }) == true || + client.writeProgress.map({ time - $0 >= 10 }) == true { closeClient(id) } + } + } + + private func closeClient(_ id: UUID) { + guard let client = clients.removeValue(forKey: id) else { return } + client.reader.cancel() + if client.readerSuspended { client.reader.resume() } + client.writer.cancel() + if !client.writerResumed { client.writer.resume() } + onDisconnect?(id) + } + + private func stopOnQueue() { + acceptSource?.cancel() + acceptSource = nil + timer?.cancel() + timer = nil + for id in Array(clients.keys) { closeClient(id) } + if listener >= 0 { close(listener); listener = -1 } + if let identity = socketIdentity { + var info = stat() + if fileStatus(directoryFD, "control.sock", &info) == 0, + info.st_uid == geteuid(), info.st_mode & S_IFMT == S_IFSOCK, + info.st_dev == identity.0, info.st_ino == identity.1 { + unlinkat(directoryFD, "control.sock", 0) + } + if let path = boundSocketPath, lstat(path, &info) == 0, + info.st_uid == geteuid(), info.st_mode & S_IFMT == S_IFSOCK, + info.st_dev == identity.0, info.st_ino == identity.1 { + unlink(path) + } + } + socketIdentity = nil + boundSocketPath = nil + if directoryFD >= 0 { close(directoryFD); directoryFD = -1 } + if lockFD >= 0 { close(lockFD); lockFD = -1 } + onConnect = nil + onFrame = nil + onDisconnect = nil + } +} diff --git a/FineTune/FineTuneApp.swift b/FineTune/FineTuneApp.swift index df4db3ff..a3e4da01 100644 --- a/FineTune/FineTuneApp.swift +++ b/FineTune/FineTuneApp.swift @@ -10,6 +10,17 @@ private let logger = Logger(subsystem: "com.finetuneapp.FineTune", category: "Ap @MainActor final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDelegate { var audioEngine: AudioEngine? + var controlService: ControlService? + private let controlTermination = ControlTermination() + + func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { + guard let controlService else { return .terminateNow } + // Allow queue-confined socket cleanup to finish without blocking the main actor. + controlTermination.begin(stop: { controlService.stop(completion: $0) }, reply: { + NSApplication.shared.reply(toApplicationShouldTerminate: true) + }) + return .terminateLater + } func application(_ application: NSApplication, open urls: [URL]) { guard let audioEngine = audioEngine else { @@ -50,6 +61,7 @@ struct FineTuneApp: App { @State private var menuBarPopupController: MenuBarPopupController @State private var shortcutsRegistry: ShortcutsRegistry @State private var resolver: TargetAppResolver + @State private var controlService: ControlService @StateObject private var updateManager = UpdateManager() @State private var showMenuBarExtra = true @@ -70,7 +82,8 @@ struct FineTuneApp: App { mediaKeyStatus: mediaKeyStatus, mediaKeyMonitor: mediaKeyMonitor, shortcutsRegistry: shortcutsRegistry, - updateManager: updateManager + updateManager: updateManager, + controlService: controlService ) } FluidMenuBarExtra("FineTune", image: launchIconImage, isInserted: $showMenuBarExtra) { @@ -111,6 +124,9 @@ struct FineTuneApp: App { let permission = AudioRecordingPermission() let engine = AudioEngine(permission: permission, settingsManager: settings, autoEQProfileManager: profileManager) _audioEngine = State(initialValue: engine) + let controlService = ControlService(engine: engine, settings: settings) + _controlService = State(initialValue: controlService) + controlService.observeSetting() // Media keys / HUD services — instantiated at app scope so the tap // and HUD panel outlive popup open/close cycles. @@ -224,6 +240,7 @@ struct FineTuneApp: App { // Pass engine to AppDelegate _appDelegate.wrappedValue.audioEngine = engine + _appDelegate.wrappedValue.controlService = controlService if permission.status == .unknown { permission.request() @@ -248,8 +265,9 @@ struct FineTuneApp: App { forName: NSApplication.willTerminateNotification, object: nil, queue: .main - ) { [settings, monitor, accessibilityService, hud, coordinator] _ in + ) { [settings, monitor, accessibilityService, hud, coordinator, controlService] _ in MainActor.assumeIsolated { + controlService.stop() coordinator.stop() monitor.stop() accessibilityService.stop() diff --git a/FineTune/Settings/SettingsManager.swift b/FineTune/Settings/SettingsManager.swift index 8706ac57..5dd2e53b 100644 --- a/FineTune/Settings/SettingsManager.swift +++ b/FineTune/Settings/SettingsManager.swift @@ -25,6 +25,7 @@ struct IgnoredAppInfo: Codable, Equatable { nonisolated struct AppSettings: Codable, Equatable { // General var launchAtLogin: Bool = false + var externalControlEnabled: Bool = false var menuBarIconStyle: MenuBarIconStyle = .default // Audio @@ -66,6 +67,7 @@ nonisolated struct AppSettings: Codable, Equatable { init(from decoder: Decoder) throws { let c = try decoder.container(keyedBy: CodingKeys.self) launchAtLogin = try c.decodeIfPresent(Bool.self, forKey: .launchAtLogin) ?? false + externalControlEnabled = try c.decodeIfPresent(Bool.self, forKey: .externalControlEnabled) ?? false menuBarIconStyle = try c.decodeIfPresent(MenuBarIconStyle.self, forKey: .menuBarIconStyle) ?? .default defaultNewAppVolume = try c.decodeIfPresent(Float.self, forKey: .defaultNewAppVolume) ?? 1.0 lockInputDevice = try c.decodeIfPresent(Bool.self, forKey: .lockInputDevice) ?? true diff --git a/FineTune/Views/Settings/SettingsRootView.swift b/FineTune/Views/Settings/SettingsRootView.swift index e56ea4ab..ce49e287 100644 --- a/FineTune/Views/Settings/SettingsRootView.swift +++ b/FineTune/Views/Settings/SettingsRootView.swift @@ -11,6 +11,7 @@ struct SettingsRootView: View { let mediaKeyMonitor: MediaKeyMonitor let shortcutsRegistry: ShortcutsRegistry @ObservedObject var updateManager: UpdateManager + var controlService: ControlService? = nil enum Section: String, Hashable, CaseIterable, Identifiable { case general, audio, shortcuts, updates, about @@ -26,7 +27,8 @@ struct SettingsRootView: View { onResetAll: { audioEngine.handleSettingsReset() deviceVolumeMonitor.setSystemFollowDefault() - } + }, + controlFailure: controlService?.startupFailure ) .tabItem { Label("General", systemImage: "gearshape") } .tag(Section.general) diff --git a/FineTune/Views/Settings/Tabs/GeneralTab.swift b/FineTune/Views/Settings/Tabs/GeneralTab.swift index 4be4aa74..ff7a4d70 100644 --- a/FineTune/Views/Settings/Tabs/GeneralTab.swift +++ b/FineTune/Views/Settings/Tabs/GeneralTab.swift @@ -5,6 +5,7 @@ import SwiftUI struct GeneralTab: View { @Bindable var settings: SettingsManager let onResetAll: () -> Void + var controlFailure: String? = nil @State private var showResetConfirmation = false @@ -46,6 +47,22 @@ struct GeneralTab: View { .labelsHidden() } SettingsRowDivider() + SettingsRow( + "External controller access", + description: "Allow apps running as your user to read and control FineTune. Uses a local socket." + ) { + Toggle("", isOn: $settings.appSettings.externalControlEnabled) + .toggleStyle(.switch) + .controlSize(.small) + .labelsHidden() + } + if let controlFailure { + Text(controlFailure) + .font(.caption) + .foregroundStyle(.red) + .textSelection(.enabled) + } + SettingsRowDivider() SettingsRow( "Theme", description: "Match macOS, or lock to Light or Dark" diff --git a/FineTuneTests/ControlEngineTests.swift b/FineTuneTests/ControlEngineTests.swift new file mode 100644 index 00000000..9127f877 --- /dev/null +++ b/FineTuneTests/ControlEngineTests.swift @@ -0,0 +1,229 @@ +import XCTest +import AppKit +@testable import FineTune + +@MainActor +private final class ControlTestTap: ProcessTapControlling { + let app: AudioApp + var volume: Float = 1 + var isMuted = false + var currentDeviceVolume: Float = 1 + var isDeviceMuted = false + var audioLevel: Float = 0 + var currentDeviceUIDs: [String] + var currentDeviceUID: String? { currentDeviceUIDs.first } + var tapSourceDeviceUID: String? + var pending: [String: CheckedContinuation] = [:] + + init(app: AudioApp, deviceUIDs: [String]) { self.app = app; currentDeviceUIDs = deviceUIDs } + func activate(initial: TapInitialState) throws {} + func invalidate() {} + func updateEQSettings(_ settings: EQSettings) {} + func updateAutoEQProfile(_ profile: AutoEQProfile?) {} + func setAutoEQPreampEnabled(_ enabled: Bool) {} + func updateLoudnessCompensation(volume: Float, enabled: Bool) {} + func updateLoudnessEqualization(_ settings: LoudnessEqualizerSettings) {} + func hasRecentAudioCallback(within seconds: Double) -> Bool { false } + func isHealthCheckEligible(minActiveSeconds: Double) -> Bool { false } + func switchDevice(to newDeviceUID: String, preferredTapSourceDeviceUID: String?, sourceDeviceDead: Bool) async throws { + try await withCheckedThrowingContinuation { pending[newDeviceUID] = $0 } + currentDeviceUIDs = [newDeviceUID] + } + func updateDevices(to newDeviceUIDs: [String], preferredTapSourceDeviceUID: String?, sourceDeviceDead: Bool) async throws { + currentDeviceUIDs = newDeviceUIDs + } +} + +@MainActor +private final class ControlTestVolume: DeviceVolumeProviding { + var defaultDeviceID: UInt32 = 1 + var defaultDeviceUID: String? = "a" + var defaultInputDeviceUID: String? = "mic" + var volumes: [UInt32: Float] = [:] + var muteStates: [UInt32: Bool] = [:] + var onVolumeChanged: ((UInt32, Float) -> Void)? + var onMuteChanged: ((UInt32, Bool) -> Void)? + var onDefaultDeviceChanged: ((String) -> Void)? + var onDefaultInputDeviceChanged: ((String) -> Void)? + var succeeds = true + var selectedInputs: [UInt32] = [] + var selectedOutputs: [UInt32] = [] + func setDefaultDevice(_ deviceID: UInt32) -> Bool { selectedOutputs.append(deviceID); return succeeds } + func setDefaultInputDevice(_ deviceID: UInt32) -> Bool { selectedInputs.append(deviceID); return succeeds } + func setVolume(for deviceID: UInt32, to volume: Float) {} + func setMute(for deviceID: UInt32, to muted: Bool) {} + func outputVolumeBackend(for deviceID: UInt32) -> VolumeControlTier { .hardware } + func start() {} + func stop() {} +} + +@MainActor +private final class ControlEngineFixture { + let settings: SettingsManager + let permission = AudioRecordingPermission(status: .authorized) + let devices = MockAudioDeviceMonitor() + let processes = StubProcessMonitor() + let volume = ControlTestVolume() + var taps: [Int32: ControlTestTap] = [:] + var failTapCreation = false + lazy var engine = AudioEngine( + permission: permission, settingsManager: settings, autoEQProfileManager: AutoEQProfileManager(loadAutomatically: false), + deviceProvider: devices, processMonitor: processes, deviceVolumeMonitor: volume, + tapFactory: { [unowned self] app, uids, _ in + if failTapCreation { throw ControlFailure(code: "test", message: "Tap creation failed") } + let tap = ControlTestTap(app: app, deviceUIDs: uids) + taps[app.id] = tap + return tap + }, isAlive: { _ in true }, startMonitorsAutomatically: false + ) + + init(directory: URL) { + settings = SettingsManager(directory: directory) + for (index, uid) in ["a", "b", "c"].enumerated() { + devices.addOutputDevice(AudioDevice(id: UInt32(index + 1), uid: uid, name: uid, icon: nil, supportsAutoEQ: false)) + } + devices.inputDevices = [AudioDevice(id: 9, uid: "mic", name: "Mic", icon: nil, supportsAutoEQ: false)] + processes.activeApps = [Self.app(pid: 101)] + } + + static func app(pid: Int32) -> AudioApp { + AudioApp(id: pid, processObjectIDs: [], name: "Player", icon: NSImage(), bundleID: "test.player") + } +} + +nonisolated final class ControlEngineTests: XCTestCase { + @MainActor + private func fixture() -> ControlEngineFixture { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let f = ControlEngineFixture(directory: directory) + addTeardownBlock { + await MainActor.run { f.settings.flushSync() } + try? FileManager.default.removeItem(at: directory) + } + return f + } + + @MainActor + func testAmbiguousMissingAndStaleAppResolution() throws { + let f = fixture() + f.processes.activeApps.append(ControlEngineFixture.app(pid: 102)) + assertFailure("ambiguousApp") { try f.engine.performControlCommand(.mute("test.player", nil, true)) } + assertFailure("appNotFound") { try f.engine.performControlCommand(.mute("other", nil, true)) } + assertFailure("staleApp") { try f.engine.performControlCommand(.mute("other", 101, true)) } + assertFailure("staleApp") { try f.engine.performControlCommand(.mute("test.player", 103, true)) } + try f.engine.performControlCommand(.mute("test.player", 102, true)) + XCTAssertTrue(f.engine.getMute(for: f.processes.activeApps[1])) + XCTAssertFalse(f.engine.getMute(for: f.processes.activeApps[0])) + guard case .array(let rows) = f.engine.controlState().object?["apps"] else { return XCTFail("Missing apps") } + XCTAssertEqual(rows.count, 2) + XCTAssertEqual(rows[0].object?["pids"], .array([.number(101)])) + XCTAssertEqual(rows[1].object?["pids"], .array([.number(102)])) + } + + @MainActor + func testPermissionMultiModeAndWrongDirection() throws { + let f = fixture() + let app = f.processes.activeApps[0] + f.permission.status = .denied + assertFailure("permissionDenied") { try f.engine.performControlCommand(.volume("test.player", 101, 0.5)) } + assertFailure("permissionDenied") { try f.engine.performControlCommand(.mute("test.player", 101, true)) } + try f.engine.performControlCommand(.preferredInput("mic")) + f.permission.status = .authorized + f.engine.volumeState.setDeviceSelectionMode(for: app.id, to: .multi, identifier: app.persistenceIdentifier) + assertFailure("unsupportedRouting") { try f.engine.performControlCommand(.routing("test.player", 101, "a")) } + try f.engine.performControlCommand(.mute("test.player", 101, true)) + XCTAssertTrue(f.engine.getMute(for: app)) + assertFailure("deviceNotFound") { try f.engine.performControlCommand(.preferredInput("a")) } + assertFailure("deviceNotFound") { try f.engine.performControlCommand(.defaultOutput("mic")) } + assertFailure("deviceNotFound") { try f.engine.performControlCommand(.defaultOutput("gone")) } + } + + @MainActor + func testPreferredInputPreservesPolicyAndPropagatesFailure() throws { + let f = fixture() + f.settings.appSettings.lockInputDevice = false + try f.engine.performControlCommand(.preferredInput("mic")) + XCTAssertEqual(f.volume.selectedInputs, [9]) + XCTAssertEqual(f.settings.preferredInputDeviceUID, "mic") + XCTAssertEqual(f.settings.lockedInputDeviceUID, "mic") + XCTAssertFalse(f.settings.appSettings.lockInputDevice) + f.volume.succeeds = false + assertFailure("commandFailed") { try f.engine.performControlCommand(.preferredInput("mic")) } + assertFailure("commandFailed") { try f.engine.performControlCommand(.defaultOutput("b")) } + XCTAssertEqual(f.engine.controlState().object?["defaultInput"], .string("mic")) + XCTAssertEqual(f.engine.controlState().object?["defaultOutput"], .string("a")) + } + + @MainActor + func testStateSchemaExplicitNullsAndDeviceOrdering() throws { + let f = fixture() + let state = try XCTUnwrap(f.engine.controlState().object) + XCTAssertEqual(Set(state.keys), ["apps", "devices", "defaultOutput", "defaultInput", "inputPolicy", "permission"]) + guard case .array(let apps) = state["apps"], case .array(let devices) = state["devices"] else { return XCTFail("Missing arrays") } + let app = try XCTUnwrap(apps.first?.object) + XCTAssertEqual(Set(app.keys), ["appId", "pids", "name", "active", "volume", "boost", "muted", "routing"]) + XCTAssertEqual(app["active"], .bool(true)) + XCTAssertEqual(app["routing"]?.object?["appliedDeviceUIDs"], .null) + XCTAssertEqual(app["routing"]?.object?["error"], .null) + XCTAssertEqual(app["routing"]?.object?["status"], .string("unavailable")) + XCTAssertEqual(devices.compactMap { $0.object?["uid"]?.string }, ["mic", "a", "b", "c"]) + XCTAssertEqual(devices.first?.object?["direction"], .string("input")) + XCTAssertEqual(devices.first?.object?["volume"], .null) + XCTAssertEqual(devices.first?.object?["muted"], .null) + XCTAssertEqual(state["inputPolicy"]?.object?["preferredDeviceUID"], .null) + XCTAssertEqual(state["inputPolicy"]?.object?["lockedDeviceUID"], .null) + f.processes.activeApps = [] + XCTAssertEqual(f.engine.controlState().object?["apps"], .array([])) + } + + @MainActor + func testTapCreationFailureIsAsynchronousRoutingStateNotCommandFailure() throws { + let f = fixture() + f.failTapCreation = true + XCTAssertNoThrow(try f.engine.performControlCommand(.routing("test.player", 101, "a"))) + XCTAssertEqual(f.engine.controlRouting[101]?.status, "failed") + XCTAssertEqual(f.engine.controlRouting[101]?.error?.code, "routingFailed") + XCTAssertNil(f.engine.controlAppliedDeviceUIDs(for: 101)) + } + + @MainActor + func testAsyncRoutingSuccessFailureAndSupersededCompletion() async throws { + let f = fixture() + let app = f.processes.activeApps[0] + f.engine.setDevice(for: app, deviceUID: "a") + let tap = try XCTUnwrap(f.taps[101]) + XCTAssertEqual(f.engine.controlRouting[101]?.status, "applied") + f.engine.setDevice(for: app, deviceUID: "b") + await waitForPending("b", tap: tap) + XCTAssertEqual(f.engine.controlRouting[101]?.status, "pending") + f.engine.setDevice(for: app, deviceUID: "c") + await waitForPending("c", tap: tap) + tap.pending.removeValue(forKey: "c")?.resume() + for _ in 0..<10 { await Task.yield() } + XCTAssertEqual(f.engine.controlRouting[101]?.status, "applied") + tap.pending.removeValue(forKey: "b")?.resume(throwing: ControlFailure(code: "test", message: "Superseded")) + for _ in 0..<10 { await Task.yield() } + XCTAssertEqual(f.engine.controlRouting[101]?.status, "applied") + XCTAssertNil(f.engine.controlRouting[101]?.error) + XCTAssertEqual(f.engine.controlAppliedDeviceUIDs(for: 101), ["c"]) + f.engine.setDevice(for: app, deviceUID: "b") + await waitForPending("b", tap: tap) + tap.pending.removeValue(forKey: "b")?.resume(throwing: ControlFailure(code: "test", message: "Failed")) + for _ in 0..<10 { await Task.yield() } + XCTAssertEqual(f.engine.controlRouting[101]?.status, "failed") + XCTAssertEqual(f.engine.controlRouting[101]?.error?.code, "routingFailed") + } + + @MainActor + private func waitForPending(_ uid: String, tap: ControlTestTap) async { + for _ in 0..<100 where tap.pending[uid] == nil { await Task.yield() } + XCTAssertNotNil(tap.pending[uid]) + } + + @MainActor + private func assertFailure(_ code: String, _ action: () throws -> Void, file: StaticString = #filePath, line: UInt = #line) { + XCTAssertThrowsError(try action(), file: file, line: line) { error in + XCTAssertEqual((error as? ControlFailure)?.code, code, file: file, line: line) + } + } +} diff --git a/FineTuneTests/ControlProtocolTests.swift b/FineTuneTests/ControlProtocolTests.swift new file mode 100644 index 00000000..4fe5a9dc --- /dev/null +++ b/FineTuneTests/ControlProtocolTests.swift @@ -0,0 +1,56 @@ +import XCTest +@testable import FineTune + +nonisolated final class ControlProtocolTests: XCTestCase { + func testJSONRoundTripPreservesTypesAndNulls() throws { + let value = ControlJSON.object([ + "null": .null, "bool": .bool(true), "number": .number(0.5), + "array": .array([.string("日本語"), .number(12)]) + ]) + XCTAssertEqual(try JSONDecoder().decode(ControlJSON.self, from: JSONEncoder().encode(value)), value) + XCTAssertNotEqual(ControlJSON.bool(true), .number(1)) + } + + func testEnvelopeValidation() throws { + let valid = Data(#"{"type":"request","id":"1","sessionId":"s","method":"getState","params":{},"extra":true}"#.utf8) + XCTAssertEqual(try ControlRequest(data: valid).id, "1") + for text in ["{}", "[]", "null", "", #"{"type":"request","id":1,"sessionId":"s","method":"getState","params":{}}"#, + #"{"type":"request","id":"","sessionId":"s","method":"getState","params":{}}"#, + #"{"type":"request","id":"1","sessionId":"s","method":"getState","params":[]}"#] { + XCTAssertThrowsError(try ControlRequest(data: Data(text.utf8)), text) + } + XCTAssertThrowsError(try ControlRequest(data: Data([0xff]))) + } + + func testVolumeValidationDoesNotCoerceBooleansOrStrings() throws { + for value in [ControlJSON.bool(true), .string("0.5"), .number(-0.1), .number(1.01), .number(.infinity), .number(.nan), .null] { + assertFailure("invalidParams") { + try ControlCommand.parse(method: "setAppVolume", params: ["appId": .string("app"), "volume": value]) + } + } + XCTAssertEqual(try ControlCommand.parse(method: "setAppVolume", params: ["appId": .string("app"), "volume": .number(0)]), .volume("app", nil, 0)) + XCTAssertEqual(try ControlCommand.parse(method: "setAppVolume", params: ["appId": .string("app"), "volume": .number(1), "pid": .number(12)]), .volume("app", 12, 1)) + } + + func testPIDMuteAndRoutingValidation() throws { + for pid in [ControlJSON.bool(true), .number(0), .number(-1), .number(1.2), .number(Double(Int32.max) + 1), .string("1"), .null] { + assertFailure("invalidParams") { + try ControlCommand.parse(method: "setAppMute", params: ["appId": .string("a"), "pid": pid, "muted": .bool(true)]) + } + } + assertFailure("invalidParams") { try ControlCommand.parse(method: "setAppMute", params: ["appId": .string("a"), "muted": .number(1)]) } + assertFailure("unsupportedRouting") { + try ControlCommand.parse(method: "setAppRouting", params: ["appId": .string("a"), "routing": .object(["mode": .string("multi")])]) + } + assertFailure("invalidParams") { try ControlCommand.parse(method: "setPreferredInput", params: ["deviceUID": .string("")]) } + assertFailure("unknownMethod") { try ControlCommand.parse(method: "reset", params: [:]) } + XCTAssertEqual(try ControlCommand.parse(method: "setAppRouting", params: ["appId": .string("a"), "routing": .object(["mode": .string("followDefault")])]), .routing("a", nil, nil)) + XCTAssertEqual(try ControlCommand.parse(method: "setAppRouting", params: ["appId": .string("a"), "routing": .object(["mode": .string("single"), "deviceUID": .string("o")])]), .routing("a", nil, "o")) + } + + private func assertFailure(_ code: String, _ action: () throws -> ControlCommand, file: StaticString = #filePath, line: UInt = #line) { + XCTAssertThrowsError(try action(), file: file, line: line) { error in + XCTAssertEqual((error as? ControlFailure)?.code, code, file: file, line: line) + } + } +} diff --git a/FineTuneTests/ControlReadbackTests.swift b/FineTuneTests/ControlReadbackTests.swift new file mode 100644 index 00000000..8c10a70d --- /dev/null +++ b/FineTuneTests/ControlReadbackTests.swift @@ -0,0 +1,44 @@ +import XCTest +@testable import FineTune + +@MainActor +private final class ReadbackProbe { + var backends = 0 + var readability = 0 + var hardwareAvailable = true +} + +nonisolated final class ControlReadbackTests: XCTestCase { + @MainActor + func testSnapshotsReadCachedBackendWithoutHALProbes() { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let settings = SettingsManager(directory: root) + defer { settings.flushSync(); try? FileManager.default.removeItem(at: root) } + let probe = ReadbackProbe() + let monitor = DeviceVolumeMonitor( + deviceMonitor: AudioDeviceMonitor(), settingsManager: settings, + hardwareVolumeAvailable: { _ in probe.backends += 1; return probe.hardwareAvailable }, + hardwareReadability: { _, _ in probe.readability += 1; return (true, true) } + ) + let devices = MockAudioDeviceMonitor() + devices.addOutputDevice(AudioDevice(id: 42, uid: "output", name: "Output", icon: nil, supportsAutoEQ: false)) + let engine = AudioEngine( + permission: AudioRecordingPermission(status: .authorized), settingsManager: settings, + autoEQProfileManager: AutoEQProfileManager(loadAutomatically: false), + deviceProvider: devices, processMonitor: StubProcessMonitor(), deviceVolumeMonitor: monitor, + startMonitorsAutomatically: false + ) + monitor.refreshControlReadability(for: 42, input: false) + XCTAssertEqual(probe.backends, 1) + XCTAssertEqual(probe.readability, 1) + for _ in 0..<100 { _ = engine.controlState() } + XCTAssertEqual(probe.backends, 1) + XCTAssertEqual(probe.readability, 1) + probe.hardwareAvailable = false + monitor.refreshControlReadability(for: 42, input: false) + XCTAssertEqual(monitor.controlReadback(for: 42, input: false).source, "software") + for _ in 0..<100 { _ = engine.controlState() } + XCTAssertEqual(probe.backends, 2) + XCTAssertEqual(probe.readability, 1) + } +} diff --git a/FineTuneTests/ControlServiceTests.swift b/FineTuneTests/ControlServiceTests.swift new file mode 100644 index 00000000..ec51549b --- /dev/null +++ b/FineTuneTests/ControlServiceTests.swift @@ -0,0 +1,288 @@ +import XCTest +import Observation +import Darwin +@testable import FineTune + +@Observable +@MainActor +private final class FakeControlEngine: ControlEngine { + var state: ControlJSON = .object(["value": .number(0)]) + var commands: [ControlCommand] = [] + var failure: ControlFailure? + func controlState() -> ControlJSON { state } + func performControlCommand(_ command: ControlCommand) throws { + if let failure { throw failure } + commands.append(command) + state = .object(["value": .number(Double(commands.count))]) + } +} + +@MainActor +private final class ControlServiceFixture { + let engine = FakeControlEngine() + var messages: [UUID: [ControlJSON]] = [:] + var closed: [UUID] = [] + var scheduled: [@MainActor () -> Void] = [] + var holdResponses = false + var sent: [@MainActor () -> Void] = [] + lazy var service = ControlService( + engine: engine, + send: { [weak self] data, id, completed in + do { + let value = try JSONDecoder().decode(ControlJSON.self, from: data) + self?.messages[id, default: []].append(value) + if self?.holdResponses == true, value.object?["type"] == .string("response") { + self?.sent.append(completed) + } else { completed() } + } + catch { XCTFail("Invalid outbound JSON: \(error)") } + }, + disconnect: { [weak self] in self?.closed.append($0) }, + schedule: { [weak self] action in + self?.scheduled.append(action) + return Task {} + } + ) + + func connect() -> UUID { + if service.sessionID.isEmpty { service.start(listen: false) } + let id = UUID() + service.connected(id, session: service.sessionID) + return id + } + + func request(_ method: String, id requestID: String, client: UUID, params: [String: ControlJSON] = [:], session: String? = nil) throws { + let value = ControlJSON.object([ + "type": .string("request"), "id": .string(requestID), + "sessionId": .string(session ?? service.sessionID), "method": .string(method), "params": .object(params) + ]) + service.received(try JSONEncoder().encode(value), from: client, session: service.sessionID) + } + + func runPublication() { + let actions = scheduled + scheduled.removeAll() + for action in actions { action() } + } +} + +nonisolated final class ControlServiceTests: XCTestCase { + @MainActor + func testHelloSubscribeDedupFlushAndSessionReset() throws { + let f = ControlServiceFixture() + let id = f.connect() + defer { f.service.stop() } + XCTAssertEqual(f.messages[id]?.first?.object?["protocol"], .string("finetune-control")) + XCTAssertEqual(f.messages[id]?.first?.object?["version"], .number(1)) + try f.request("subscribe", id: "1", client: id) + XCTAssertEqual(f.messages[id]?.last?.object?["result"]?.object?["revision"], .number(0)) + f.engine.state = .object(["value": .number(2)]) + try f.request("getState", id: "2", client: id) + XCTAssertEqual(f.service.revision, 1) + XCTAssertEqual(f.messages[id]?.last?.object?["result"]?.object?["state"], f.engine.state) + f.service.flush() + XCTAssertEqual(f.service.revision, 1) + let session = f.service.sessionID + f.service.stop() + f.service.start(listen: false) + XCTAssertNotEqual(f.service.sessionID, session) + XCTAssertEqual(f.service.revision, 0) + } + + @MainActor + func testSubscribeResponsePrecedesEventsAndCommandResponse() throws { + let f = ControlServiceFixture() + let id = f.connect() + defer { f.service.stop() } + f.engine.state = .object(["value": .number(7)]) + try f.request("subscribe", id: "1", client: id) + XCTAssertEqual(f.messages[id]?.count, 2) + XCTAssertEqual(f.messages[id]?.last?.object?["type"], .string("response")) + try f.request("setAppMute", id: "2", client: id, params: ["appId": .string("a"), "muted": .bool(true)]) + XCTAssertEqual(f.engine.commands, [.mute("a", nil, true)]) + f.service.flush() + let messages = try XCTUnwrap(f.messages[id]) + XCTAssertEqual(messages[messages.count - 2].object?["result"], .object(["accepted": .bool(true)])) + XCTAssertEqual(messages.last?.object?["type"], .string("event")) + f.engine.state = .object(["value": .number(9)]) + try f.request("subscribe", id: "3", client: id) + XCTAssertEqual(f.messages[id]?.count, messages.count + 1) + } + + @MainActor + func testObservationRearmsWithoutRestartingPublicationWindow() async throws { + let f = ControlServiceFixture() + let id = f.connect() + defer { f.service.stop() } + try f.request("subscribe", id: "1", client: id) + f.engine.state = .object(["value": .number(1)]) + await nextMainTurn() + XCTAssertEqual(f.scheduled.count, 1) + f.engine.state = .object(["value": .number(2)]) + await nextMainTurn() + XCTAssertEqual(f.scheduled.count, 1) + f.runPublication() + XCTAssertEqual(f.service.revision, 1) + XCTAssertEqual(f.messages[id]?.last?.object?["state"], f.engine.state) + f.engine.state = .object(["value": .number(3)]) + await nextMainTurn() + XCTAssertEqual(f.scheduled.count, 1) + f.runPublication() + XCTAssertEqual(f.service.revision, 2) + } + + @MainActor + func testErrorsAndCompletedIDReuse() throws { + let f = ControlServiceFixture() + let id = f.connect() + defer { f.service.stop() } + try f.request("getState", id: "1", client: id, session: "old") + XCTAssertEqual(f.messages[id]?.last?.object?["error"]?.object?["code"], .string("staleSession")) + for (index, code) in ["appNotFound", "ambiguousApp", "staleApp", "deviceNotFound", "unsupportedRouting", "permissionDenied", "commandFailed", "internalError"].enumerated() { + f.engine.failure = ControlFailure(code: code, message: "Test failure") + try f.request("setPreferredInput", id: "error\(index)", client: id, params: ["deviceUID": .string("mic")]) + XCTAssertEqual(f.messages[id]?.last?.object?["error"]?.object?["code"], .string(code)) + } + try f.request("unknown", id: "method", client: id) + XCTAssertEqual(f.messages[id]?.last?.object?["error"]?.object?["code"], .string("unknownMethod")) + try f.request("setAppMute", id: "params", client: id) + XCTAssertEqual(f.messages[id]?.last?.object?["error"]?.object?["code"], .string("invalidParams")) + try f.request("getState", id: "1", client: id) + XCTAssertTrue(f.closed.isEmpty) + XCTAssertNotNil(f.messages[id]?.last?.object?["result"]) + XCTAssertEqual(f.service.pendingRequestCount, 0) + } + + @MainActor + func testDuplicateInFlightIDDisconnectsAndCompletedHistoryDoesNotGrow() throws { + let f = ControlServiceFixture() + let id = f.connect() + defer { f.service.stop() } + for index in 0..<10_000 { + try f.request("getState", id: String(index), client: id) + XCTAssertEqual(f.service.pendingRequestCount, 0) + f.messages[id]?.removeAll() + } + f.holdResponses = true + try f.request("getState", id: "held", client: id) + XCTAssertEqual(f.service.pendingRequestCount, 1) + try f.request("getState", id: "held", client: id) + XCTAssertEqual(f.closed, [id]) + XCTAssertEqual(f.service.pendingRequestCount, 0) + for completed in f.sent { completed() } + XCTAssertEqual(f.service.pendingRequestCount, 0) + } + + @MainActor + func testMalformedInputAndUnencodableStateDisconnect() { + let f = ControlServiceFixture() + let id = f.connect() + defer { f.service.stop() } + f.service.received(Data("not json".utf8), from: id, session: f.service.sessionID) + XCTAssertEqual(f.closed, [id]) + let other = f.connect() + f.engine.state = .number(.nan) + f.service.flush() + XCTAssertNoThrow(try f.request("getState", id: "state", client: other)) + XCTAssertTrue(f.closed.contains(other)) + } + + @MainActor + func testSettingObservationDefaultOffEnableDisableAndReenable() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent("ft-" + UUID().uuidString.prefix(8)) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + let settings = SettingsManager(directory: root) + let directory = root.appendingPathComponent("FineTune/Control") + let server = UnixSocketServer(directory: directory) + let firstReady = expectation(description: "First listener ready") + let secondReady = expectation(description: "Second listener ready") + var starts = 0 + let service = ControlService(engine: FakeControlEngine(), settings: settings, server: server, onReady: { + starts += 1 + if starts == 1 { firstReady.fulfill() } else { secondReady.fulfill() } + }) + addTeardownBlock { + await withCheckedContinuation { continuation in + Task { @MainActor in service.stop { continuation.resume() } } + } + await MainActor.run { settings.flushSync() } + try? FileManager.default.removeItem(at: root) + } + service.observeSetting() + XCTAssertEqual(service.sessionID, "") + XCTAssertFalse(FileManager.default.fileExists(atPath: directory.appendingPathComponent("control.sock").path)) + settings.appSettings.externalControlEnabled = true + let firstResult = await XCTWaiter.fulfillment(of: [firstReady], timeout: 3) + XCTAssertEqual(firstResult, .completed) + XCTAssertNil(service.startupFailure) + XCTAssertTrue(FileManager.default.fileExists(atPath: directory.appendingPathComponent("control.sock").path)) + let firstSession = service.sessionID + let (firstFD, firstHello) = try await socketHello(directory) + defer { close(firstFD) } + XCTAssertEqual(firstHello.object?["type"], .string("hello")) + XCTAssertEqual(firstHello.object?["sessionId"], .string(firstSession)) + settings.appSettings.externalControlEnabled = false + let firstClosed = await socketClosed(firstFD) + XCTAssertTrue(firstClosed) + XCTAssertFalse(FileManager.default.fileExists(atPath: directory.appendingPathComponent("control.sock").path)) + settings.appSettings.externalControlEnabled = true + let secondResult = await XCTWaiter.fulfillment(of: [secondReady], timeout: 3) + XCTAssertEqual(secondResult, .completed) + XCTAssertNil(service.startupFailure) + XCTAssertTrue(FileManager.default.fileExists(atPath: directory.appendingPathComponent("control.sock").path)) + XCTAssertNotEqual(service.sessionID, firstSession) + let (secondFD, secondHello) = try await socketHello(directory) + defer { close(secondFD) } + XCTAssertEqual(secondHello.object?["sessionId"], .string(service.sessionID)) + settings.appSettings.externalControlEnabled = false + let secondClosed = await socketClosed(secondFD) + XCTAssertTrue(secondClosed) + XCTAssertFalse(FileManager.default.fileExists(atPath: directory.appendingPathComponent("control.sock").path)) + } + + private func socketHello(_ directory: URL) async throws -> (Int32, ControlJSON) { + try await Task.detached { + var address = sockaddr_un() + address.sun_family = sa_family_t(AF_UNIX) + address.sun_len = UInt8(MemoryLayout.size) + let path = Array(directory.appendingPathComponent("control.sock").path.utf8) + [0] + guard path.count <= MemoryLayout.size(ofValue: address.sun_path) else { throw POSIXError(.ENAMETOOLONG) } + withUnsafeMutableBytes(of: &address.sun_path) { $0.copyBytes(from: path) } + let fd = socket(AF_UNIX, SOCK_STREAM, 0) + guard fd >= 0 else { throw POSIXError(.ENFILE) } + do { + let result = withUnsafePointer(to: &address) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { + Darwin.connect(fd, $0, socklen_t(MemoryLayout.size)) + } + } + guard result == 0 else { throw POSIXError(.ECONNREFUSED) } + var timeout = timeval(tv_sec: 2, tv_usec: 0) + guard setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &timeout, socklen_t(MemoryLayout.size)) == 0 else { + throw POSIXError(.EINVAL) + } + var data = Data() + var byte: UInt8 = 0 + while Darwin.read(fd, &byte, 1) == 1 { + if byte == 10 { return (fd, try JSONDecoder().decode(ControlJSON.self, from: data)) } + data.append(byte) + } + throw POSIXError(.ETIMEDOUT) + } catch { close(fd); throw error } + }.value + } + + private func socketClosed(_ fd: Int32) async -> Bool { + await Task.detached { + var byte: UInt8 = 0 + return Darwin.read(fd, &byte, 1) == 0 + }.value + } + + @MainActor + private func nextMainTurn() async { + await withCheckedContinuation { continuation in + DispatchQueue.main.async { continuation.resume() } + } + } +} diff --git a/FineTuneTests/ControlTerminationTests.swift b/FineTuneTests/ControlTerminationTests.swift new file mode 100644 index 00000000..918daf93 --- /dev/null +++ b/FineTuneTests/ControlTerminationTests.swift @@ -0,0 +1,49 @@ +import XCTest +@testable import FineTune + +nonisolated final class ControlTerminationTests: XCTestCase { + @MainActor + func testCleanupCompletionRepliesOnceAndCancelsFallback() async { + var timeout: (@MainActor () -> Void)? + var cleanup: (@Sendable () -> Void)? + var replies = 0 + let termination = ControlTermination(schedule: { action in timeout = action; return Task {} }) + termination.begin(stop: { cleanup = $0 }, reply: { replies += 1 }) + XCTAssertEqual(replies, 0) + cleanup?() + await nextMainTurn() + XCTAssertEqual(replies, 1) + timeout?() + cleanup?() + await nextMainTurn() + XCTAssertEqual(replies, 1) + } + + @MainActor + func testHungCleanupFallsBackAndLateOrRepeatedCompletionsCannotReplyTwice() async { + var timeout: (@MainActor () -> Void)? + var cleanup: (@Sendable () -> Void)? + var schedules = 0 + var replies = 0 + let termination = ControlTermination(schedule: { action in + schedules += 1 + timeout = action + return Task {} + }) + termination.begin(stop: { cleanup = $0 }, reply: { replies += 1 }) + termination.begin(stop: { _ in XCTFail("Stopped twice") }, reply: { XCTFail("Second reply") }) + XCTAssertEqual(schedules, 1) + timeout?() + XCTAssertEqual(replies, 1) + cleanup?() + cleanup?() + timeout?() + await nextMainTurn() + XCTAssertEqual(replies, 1) + } + + @MainActor + private func nextMainTurn() async { + await withCheckedContinuation { continuation in DispatchQueue.main.async { continuation.resume() } } + } +} diff --git a/FineTuneTests/ExternalControlSettingsTests.swift b/FineTuneTests/ExternalControlSettingsTests.swift new file mode 100644 index 00000000..816ea0b1 --- /dev/null +++ b/FineTuneTests/ExternalControlSettingsTests.swift @@ -0,0 +1,12 @@ +import XCTest +@testable import FineTune + +nonisolated final class ExternalControlSettingsTests: XCTestCase { + func testDefaultOffAndBackwardCompatibleDecode() throws { + XCTAssertFalse(AppSettings().externalControlEnabled) + XCTAssertFalse(try JSONDecoder().decode(AppSettings.self, from: Data("{}".utf8)).externalControlEnabled) + let enabled = try JSONDecoder().decode(AppSettings.self, from: Data(#"{"externalControlEnabled":true}"#.utf8)) + XCTAssertTrue(enabled.externalControlEnabled) + XCTAssertEqual(try JSONDecoder().decode(AppSettings.self, from: JSONEncoder().encode(enabled)), enabled) + } +} diff --git a/FineTuneTests/UnixSocketServerTests.swift b/FineTuneTests/UnixSocketServerTests.swift new file mode 100644 index 00000000..86418218 --- /dev/null +++ b/FineTuneTests/UnixSocketServerTests.swift @@ -0,0 +1,401 @@ +import XCTest +import Darwin +@testable import FineTune + +private nonisolated final class SocketTestClock: @unchecked Sendable { + private let lock = NSLock() + private var value: TimeInterval = 100 + func readAndAdvance() -> TimeInterval { lock.withLock { defer { value += 11 }; return value } } +} + +private nonisolated final class SocketNamespaceSwap: @unchecked Sendable { + private let lock = NSLock() + private var performed = false + + func once(_ action: () throws -> Void) rethrows { + try lock.withLock { + guard !performed else { return } + performed = true + try action() + } + } +} + +nonisolated final class UnixSocketServerTests: XCTestCase { + private func makeDirectory() throws -> URL { + let root = FileManager.default.temporaryDirectory.appendingPathComponent("ft-" + UUID().uuidString.prefix(8)) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + addTeardownBlock { try? FileManager.default.removeItem(at: root) } + return root.appendingPathComponent("FineTune/Control") + } + + private func start(_ server: UnixSocketServer, frame: @escaping @Sendable (UUID, Data) -> Void = { _, _ in }, + connected: @escaping @Sendable (UUID) -> Void = { _ in }, + disconnected: @escaping @Sendable (UUID) -> Void = { _ in }) async { + let ready = expectation(description: "Listener ready") + server.start(onConnect: connected, onFrame: { id, data, completed in frame(id, data); completed() }, onDisconnect: disconnected, + onFailure: { message in XCTFail(message); ready.fulfill() }, onReady: { ready.fulfill() }) + await fulfillment(of: [ready], timeout: 3) + addTeardownBlock { + await withCheckedContinuation { continuation in server.stop { continuation.resume() } } + } + } + + private nonisolated func connect(_ directory: URL) throws -> Int32 { + var address = sockaddr_un() + address.sun_family = sa_family_t(AF_UNIX) + address.sun_len = UInt8(MemoryLayout.size) + let path = Array(directory.appendingPathComponent("control.sock").path.utf8) + [0] + guard path.count <= MemoryLayout.size(ofValue: address.sun_path) else { + throw ControlFailure(code: "testPath", message: "Temporary socket path too long") + } + withUnsafeMutableBytes(of: &address.sun_path) { $0.copyBytes(from: path) } + let fd = socket(AF_UNIX, SOCK_STREAM, 0) + guard fd >= 0 else { throw POSIXError(.ENFILE) } + let result = withUnsafePointer(to: &address) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { + Darwin.connect(fd, $0, socklen_t(MemoryLayout.size)) + } + } + guard result == 0 else { close(fd); throw POSIXError(.ECONNREFUSED) } + var one: Int32 = 1 + setsockopt(fd, SOL_SOCKET, SO_NOSIGPIPE, &one, socklen_t(MemoryLayout.size(ofValue: one))) + return fd + } + + func testFragmentedCoalescedAndCRLFFrames() async throws { + let directory = try makeDirectory() + let server = UnixSocketServer(directory: directory) + let first = expectation(description: "Fragmented first frame") + let second = expectation(description: "Coalesced second frame") + await start(server, frame: { _, data in + if data == Data("{\"a\":1}".utf8) { first.fulfill() } + else if data == Data("{\"b\":2}".utf8) { second.fulfill() } + else { XCTFail("Unexpected frame") } + }) + let fd = try connect(directory) + defer { close(fd) } + let prefix = Data("{\"a\":".utf8) + let suffix = Data("1}\r\n{\"b\":2}\n".utf8) + XCTAssertEqual(prefix.withUnsafeBytes { Darwin.write(fd, $0.baseAddress, prefix.count) }, prefix.count) + XCTAssertEqual(suffix.withUnsafeBytes { Darwin.write(fd, $0.baseAddress, suffix.count) }, suffix.count) + await fulfillment(of: [first, second], timeout: 3) + } + + func testInvalidEmptyAndOversizedFramesClose() async throws { + for payload in [Data([10]), Data([0xff, 10]), Data(repeating: 65, count: UnixSocketServer.maximumFrameBytes + 1)] { + let directory = try makeDirectory() + let server = UnixSocketServer(directory: directory) + let closed = expectation(description: "Invalid client closed") + await start(server, frame: { _, _ in XCTFail("Invalid frame delivered") }, disconnected: { _ in closed.fulfill() }) + let fd = try connect(directory) + _ = payload.withUnsafeBytes { Darwin.write(fd, $0.baseAddress, payload.count) } + await fulfillment(of: [closed], timeout: 3) + close(fd) + } + } + + func testPeerRejectionBeforeParsing() async throws { + let directory = try makeDirectory() + let checked = expectation(description: "Peer checked") + let rejecting = UnixSocketServer(directory: directory, peerUID: { _ in checked.fulfill(); return geteuid() + 1 }) + await start(rejecting, frame: { _, _ in XCTFail("Unauthorized frame parsed") }, connected: { _ in XCTFail("Unauthorized peer accepted") }) + let fd = try connect(directory) + defer { close(fd) } + await fulfillment(of: [checked], timeout: 3) + var byte: UInt8 = 0 + XCTAssertEqual(Darwin.read(fd, &byte, 1), 0) + } + + func testPermissionsLockContentionAndCleanup() async throws { + let directory = try makeDirectory() + let server = UnixSocketServer(directory: directory) + await start(server) + var info = stat() + XCTAssertEqual(lstat(directory.path, &info), 0) + XCTAssertEqual(info.st_mode & 0o777, 0o700) + let path = directory.appendingPathComponent("control.sock").path + XCTAssertEqual(lstat(path, &info), 0) + XCTAssertEqual(info.st_mode & 0o777, 0o600) + let inode = info.st_ino + let second = UnixSocketServer(directory: directory) + let failed = expectation(description: "Second instance refused") + second.start(onConnect: { _ in XCTFail() }, onFrame: { _, _, completed in XCTFail(); completed() }, onDisconnect: { _ in }, onFailure: { _ in failed.fulfill() }) + await fulfillment(of: [failed], timeout: 3) + XCTAssertEqual(lstat(path, &info), 0) + XCTAssertEqual(info.st_ino, inode) + let fd = try connect(directory) + close(fd) + await withCheckedContinuation { continuation in server.stop { continuation.resume() } } + XCTAssertFalse(FileManager.default.fileExists(atPath: path)) + XCTAssertTrue(FileManager.default.fileExists(atPath: directory.appendingPathComponent("server.lock").path)) + await start(second) + } + + func testRefusesSymlinkAndNonSocket() async throws { + for symlink in [false, true] { + let directory = try makeDirectory() + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let path = directory.appendingPathComponent("control.sock") + if symlink { try FileManager.default.createSymbolicLink(at: path, withDestinationURL: directory) } + else { try Data("do not remove".utf8).write(to: path) } + let server = UnixSocketServer(directory: directory) + let failed = expectation(description: "Unsafe socket refused") + server.start(onConnect: { _ in XCTFail() }, onFrame: { _, _, completed in XCTFail(); completed() }, onDisconnect: { _ in }, onFailure: { _ in failed.fulfill() }) + await fulfillment(of: [failed], timeout: 3) + var info = stat() + XCTAssertEqual(lstat(path.path, &info), 0) + XCTAssertEqual(info.st_mode & S_IFMT, symlink ? S_IFLNK : S_IFREG) + } + } + + func testPartialFrameTimeoutUsesInjectedClock() async throws { + let directory = try makeDirectory() + let clock = SocketTestClock() + let server = UnixSocketServer(directory: directory, now: { clock.readAndAdvance() }) + let closed = expectation(description: "Partial frame timed out") + await start(server, disconnected: { _ in closed.fulfill() }) + let fd = try connect(directory) + defer { close(fd) } + var byte: UInt8 = 123 + XCTAssertEqual(Darwin.write(fd, &byte, 1), 1) + await fulfillment(of: [closed], timeout: 3) + } + + func testOutboundQueueBoundsAndStalledWriter() async throws { + for (frameCount, size, stalled) in [(1, 1_048_576, true), (10, 1_048_576, false), (20, 32_768, false)] { + let directory = try makeDirectory() + let clock = SocketTestClock() + let server = UnixSocketServer(directory: directory, now: { stalled ? clock.readAndAdvance() : ProcessInfo.processInfo.systemUptime }) + let closed = expectation(description: "Slow output client disconnected") + let payload = Data(repeating: 65, count: size) + await start(server, connected: { id in + for _ in 0.. 0 else { break } + result.append(contentsOf: chunk.prefix(count)) + } + return result + }.value + XCTAssertEqual(received, payload + Data([10])) + } + + func testClientLimit() async throws { + let directory = try makeDirectory() + let server = UnixSocketServer(directory: directory) + let connected = expectation(description: "Eight accepted clients") + connected.expectedFulfillmentCount = 8 + await start(server, connected: { _ in connected.fulfill() }) + var descriptors: [Int32] = [] + defer { for fd in descriptors { close(fd) } } + for _ in 0..<8 { descriptors.append(try connect(directory)) } + await fulfillment(of: [connected], timeout: 3) + let extra = try connect(directory) + defer { close(extra) } + var byte: UInt8 = 0 + XCTAssertEqual(Darwin.read(extra, &byte, 1), 0) + } + + private func bindSocket(_ directory: URL) throws -> Int32 { + var address = sockaddr_un() + address.sun_family = sa_family_t(AF_UNIX) + address.sun_len = UInt8(MemoryLayout.size) + let path = Array(directory.appendingPathComponent("control.sock").path.utf8) + [0] + guard path.count <= MemoryLayout.size(ofValue: address.sun_path) else { throw POSIXError(.ENAMETOOLONG) } + withUnsafeMutableBytes(of: &address.sun_path) { $0.copyBytes(from: path) } + let fd = socket(AF_UNIX, SOCK_STREAM, 0) + guard fd >= 0 else { throw POSIXError(.ENFILE) } + let result = withUnsafePointer(to: &address) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { + Darwin.bind(fd, $0, socklen_t(MemoryLayout.size)) + } + } + guard result == 0 else { close(fd); throw POSIXError(.EADDRINUSE) } + return fd + } + + func testStaleSocketRecoveryAndReplacementIdentity() async throws { + let directory = try makeDirectory() + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let stale = try bindSocket(directory) + close(stale) + let server = UnixSocketServer(directory: directory) + await start(server) + let path = directory.appendingPathComponent("control.sock").path + XCTAssertEqual(unlink(path), 0) + let replacement = try bindSocket(directory) + defer { close(replacement) } + var before = stat() + XCTAssertEqual(lstat(path, &before), 0) + await withCheckedContinuation { continuation in server.stop { continuation.resume() } } + var after = stat() + XCTAssertEqual(lstat(path, &after), 0) + XCTAssertEqual(before.st_ino, after.st_ino) + } + + func testRepairsOnlyControlPermissionsAndRejectsDirectorySymlinks() async throws { + let directory = try makeDirectory() + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let parent = directory.deletingLastPathComponent() + XCTAssertEqual(chmod(parent.path, 0o755), 0) + XCTAssertEqual(chmod(directory.path, 0o777), 0) + let server = UnixSocketServer(directory: directory) + await start(server) + var info = stat() + XCTAssertEqual(lstat(parent.path, &info), 0) + XCTAssertEqual(info.st_mode & 0o777, 0o755) + XCTAssertEqual(lstat(directory.path, &info), 0) + XCTAssertEqual(info.st_mode & 0o777, 0o700) + + let unsafe = try makeDirectory() + try FileManager.default.createDirectory(at: unsafe.deletingLastPathComponent(), withIntermediateDirectories: true) + try FileManager.default.createSymbolicLink(at: unsafe, withDestinationURL: directory) + let rejecting = UnixSocketServer(directory: unsafe) + let failed = expectation(description: "Directory symlink refused") + rejecting.start(onConnect: { _ in XCTFail() }, onFrame: { _, _, completed in XCTFail(); completed() }, onDisconnect: { _ in }, onFailure: { _ in failed.fulfill() }) + await fulfillment(of: [failed], timeout: 3) + } + + func testInjectedFilesystemRejectsForeignDirectoryAndSocketOwners() async throws { + for foreignSocket in [false, true] { + let directory = try makeDirectory() + if foreignSocket { + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + close(try bindSocket(directory)) + } + let server = UnixSocketServer(directory: directory, fileStatus: { fd, name, info in + let result = UnixSocketServer.readFileStatus(fd, name, info) + var parent = stat() + let isFineTune = lstat(directory.deletingLastPathComponent().path, &parent) == 0 && + parent.st_dev == info.pointee.st_dev && parent.st_ino == info.pointee.st_ino + if result == 0, foreignSocket ? name == "control.sock" : isFineTune { info.pointee.st_uid = geteuid() + 1 } + return result + }) + let failed = expectation(description: "Foreign ownership refused") + server.start(onConnect: { _ in XCTFail() }, onFrame: { _, _, completed in XCTFail(); completed() }, + onDisconnect: { _ in }, onFailure: { message in + XCTAssertTrue(message.contains(foreignSocket ? "unsafe existing" : "belong to this user")) + failed.fulfill() + }) + await fulfillment(of: [failed], timeout: 3) + if foreignSocket { + var info = stat() + XCTAssertEqual(lstat(directory.appendingPathComponent("control.sock").path, &info), 0) + XCTAssertEqual(info.st_mode & S_IFMT, S_IFSOCK) + } + } + } + + func testRejectsWritableParentWithoutRepairingIt() async throws { + let directory = try makeDirectory() + let parent = directory.deletingLastPathComponent() + try FileManager.default.createDirectory(at: parent, withIntermediateDirectories: true) + XCTAssertEqual(chmod(parent.path, 0o777), 0) + let server = UnixSocketServer(directory: directory) + let failed = expectation(description: "Writable parent refused") + server.start(onConnect: { _ in XCTFail() }, onFrame: { _, _, completed in XCTFail(); completed() }, + onDisconnect: { _ in }, onFailure: { _ in failed.fulfill() }) + await fulfillment(of: [failed], timeout: 3) + var info = stat() + XCTAssertEqual(lstat(parent.path, &info), 0) + XCTAssertEqual(info.st_mode & 0o777, 0o777) + } + + func testAcceptsMaximumFrameWithoutTruncation() async throws { + let directory = try makeDirectory() + let server = UnixSocketServer(directory: directory) + let received = expectation(description: "Maximum payload delivered") + await start(server, frame: { _, data in + XCTAssertEqual(data.count, UnixSocketServer.maximumFrameBytes) + received.fulfill() + }) + let fd = try connect(directory) + defer { close(fd) } + let payload = Data(repeating: 65, count: UnixSocketServer.maximumFrameBytes) + Data([10]) + let count = await Task.detached { + payload.withUnsafeBytes { bytes in + var offset = 0 + while offset < bytes.count { + let written = Darwin.write(fd, bytes.baseAddress!.advanced(by: offset), bytes.count - offset) + guard written > 0 else { break } + offset += written + } + return offset + } + }.value + XCTAssertEqual(count, payload.count) + await fulfillment(of: [received], timeout: 3) + } + + func testRejectsWritableApplicationSupportAndHigherAncestorWithoutRepair() async throws { + for higherAncestor in [false, true] { + let original = try makeDirectory() + let root = original.deletingLastPathComponent().deletingLastPathComponent() + let directory = higherAncestor ? root.appendingPathComponent("Support/FineTune/Control") : original + let support = directory.deletingLastPathComponent().deletingLastPathComponent() + try FileManager.default.createDirectory(at: support, withIntermediateDirectories: true) + let unsafe = higherAncestor ? root : support + XCTAssertEqual(chmod(unsafe.path, 0o777), 0) + let server = UnixSocketServer(directory: directory) + let failed = expectation(description: "Writable namespace ancestor refused") + server.start(onConnect: { _ in XCTFail() }, onFrame: { _, _, done in XCTFail(); done() }, + onDisconnect: { _ in }, onFailure: { message in + XCTAssertTrue(message.contains("ancestors")) + failed.fulfill() + }) + await fulfillment(of: [failed], timeout: 3) + var info = stat() + XCTAssertEqual(lstat(unsafe.path, &info), 0) + XCTAssertEqual(info.st_mode & 0o777, 0o777) + XCTAssertFalse(FileManager.default.fileExists(atPath: directory.appendingPathComponent("control.sock").path)) + } + } + + func testNamespaceSwapAfterValidationCleansActualBoundSocket() async throws { + let directory = try makeDirectory() + let parent = directory.deletingLastPathComponent() + let moved = parent.deletingLastPathComponent().appendingPathComponent("Moved") + let swap = SocketNamespaceSwap() + let server = UnixSocketServer(directory: directory, fileStatus: { fd, name, info in + let result = UnixSocketServer.readFileStatus(fd, name, info) + let savedErrno = errno + if name == "control.sock" { + do { + try swap.once { + try FileManager.default.moveItem(at: parent, to: moved) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + } + } catch { XCTFail("Cannot arrange namespace swap: \(error)") } + } + errno = savedErrno + return result + }) + let failed = expectation(description: "Namespace mismatch refused") + server.start(onConnect: { _ in XCTFail() }, onFrame: { _, _, done in XCTFail(); done() }, + onDisconnect: { _ in }, onFailure: { message in + XCTAssertTrue(message.contains("inspect bound socket")) + failed.fulfill() + }) + await fulfillment(of: [failed], timeout: 3) + XCTAssertFalse(FileManager.default.fileExists(atPath: directory.appendingPathComponent("control.sock").path)) + XCTAssertFalse(FileManager.default.fileExists(atPath: moved.appendingPathComponent("Control/control.sock").path)) + } +} diff --git a/README.md b/README.md index a90082ba..a26ce545 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,7 @@ That's it. Adjust sliders, route audio, and explore EQ from the menu bar. - **[AutoEQ & Headphone Correction](guide/autoeq.md)** — Apply frequency correction from the [AutoEQ](https://github.com/jaakkopasanen/AutoEq) project, import [EqualizerAPO](https://sourceforge.net/projects/equalizerapo/) profiles, or browse [autoeq.app](https://www.autoeq.app/) - **[URL Schemes](guide/url-schemes.md)** — Automate FineTune from Terminal, [Shortcuts](https://support.apple.com/guide/shortcuts-mac), [Raycast](https://raycast.com), or scripts +- **[External Control API](guide/control-api.md)** — Opt-in local socket for bidirectional app volume, mute, routing, and device selection - **[Troubleshooting](guide/troubleshooting.md)** — Permission issues, missing apps, audio problems ## Contributing diff --git a/guide/control-api.md b/guide/control-api.md new file mode 100644 index 00000000..b05b8272 --- /dev/null +++ b/guide/control-api.md @@ -0,0 +1,183 @@ +# External control API v1 + +FineTune can provide bidirectional control to local controllers (for example, +Stream Deck or Launchpad integrations). Enable **Settings → General → External +controller access**. It is off by default and runs only while FineTune runs. +Existing URL actions are unchanged. + +## Security and connection + +Connect an AF_UNIX stream to: + +``` +$HOME/Library/Application Support/FineTune/Control/control.sock +``` + +Here HOME means the current user's home directory, not an environment-variable +override. There is no TCP/WebSocket port, authentication token, helper, or daemon. +**Every process running as your user is trusted to read and control FineTune.** +The Control directory is owned by that user and mode 0700; the socket is 0600. +FineTune rejects symlinks, foreign-owned API directories/files, and peers whose +credentials cannot be verified as the same effective UID. +Application Support must belong to your user. Its canonical ancestors must belong +to root or your user, and neither they nor the FineTune parent may be +group/world-writable. FineTune never repairs parent permissions. After binding it +verifies that the absolute socket and the validated directory identify the same +inode; failed namespace verification cleans up the socket it actually created. + +A nonblocking exclusive lock on `Control/server.lock` prevents multiple listeners. +FineTune never removes the lock file. With the lock held, it removes a stale socket, +but refuses other file types. Disabling access or quitting closes connections, +cancels observation and removes only the socket it created. Re-enabling generates +a new session ID. Startup failures appear beside the setting and in Console +(category `ControlAPI`). + +## Framing and lifecycle + +Each frame is a UTF-8 JSON object terminated by LF. CRLF is accepted. Never send +pretty-printed JSON, empty lines, invalid UTF-8, or malformed envelopes. These close +the connection. Maximum payload is 1,048,576 bytes excluding LF. FineTune supports +eight clients, partial reads/writes, and disconnects clients that exceed eight +queued outbound frames or 4 MiB buffered output. A partial inbound frame, or +buffered output without write progress, times out after ten seconds. Oversized or +unencodable snapshots are never truncated: the affected client disconnects. + +The server speaks first: + +```json +{"type":"hello","protocol":"finetune-control","version":1,"appVersion":"1.9.0","sessionId":"example","capabilities":["getState","subscribe","setAppVolume","setAppMute","setAppRouting","setDefaultOutput","setPreferredInput"]} +``` + +Check protocol/version before sending requests. Use the hello's sessionId on every +request. Each request needs type, a nonempty string id (at most 128 UTF-8 bytes, +unique among requests still awaiting a response on that connection), sessionId, +method and an object params. Completed IDs are not retained and can be reused; +connection-unique IDs (for example, a counter) are recommended. Additional +fields are ignored. Requests are processed serially and responses remain ordered. + +```json +{"type":"request","id":"1","sessionId":"example","method":"getState","params":{}} +{"type":"response","id":"1","result":{"sessionId":"example","revision":0,"state":{}}} +{"type":"request","id":"2","sessionId":"example","method":"subscribe","params":{}} +{"type":"response","id":"2","result":{"sessionId":"example","revision":0,"state":{}}} +{"type":"event","event":"stateChanged","sessionId":"example","revision":1,"state":{}} +``` + +`state: {}` above stands for the **complete** state described below. Both getState +and subscribe flush pending changes before returning. Subscribe registers and +returns its snapshot without a gap; its response precedes subsequent events. +Repeated subscribe returns a fresh snapshot, not a second subscription. To +unsubscribe, close the connection. + +Revisions start at zero per listener session and increase once per distinct +published snapshot (up to 2^53−1). Identical snapshots do not increment them. +Changes are coalesced in a fixed 40 ms window; every event is a full snapshot. +Intermediate changes can be coalesced, but queued events are FIFO and never silently +dropped. Reconnect and resubscribe after a disconnect, discarding the old session +and state. Do not replay commands across reconnects or automatically launch FineTune. + +## State + +All keys are present; nullable values are explicit JSON nulls. + +```json +{ + "apps": [{ + "appId": "com.example.Player", "pids": [1234], "name": "Player", "active": true, + "volume": 0.5, "boost": 1, "muted": false, + "routing": { + "requested": {"mode": "single", "deviceUID": "output-uid"}, + "appliedDeviceUIDs": ["output-uid"], "status": "applied", "error": null + } + }], + "devices": [{ + "uid": "output-uid", "name": "Speakers", "direction": "output", "isDefault": true, + "volume": 0.75, "muted": false, "volumeSource": "hardware", + "capabilities": {"volumeReadable": true, "muteReadable": true, "selectable": true} + }], + "defaultOutput": "output-uid", "defaultInput": null, + "inputPolicy": {"lockEnabled": true, "preferredDeviceUID": null, "lockedDeviceUID": null}, + "permission": {"audioCapture": "authorized", "source": "preflight"} +} +``` + +- Apps are the engine's active audio-process rows, sorted by appId then PID. No + inactive persisted apps or meters. Active means present in the audio-process + list, **not proven audible**. Each pids array contains exactly the responsible + PID of that row. Duplicate appIds are legal and must not be collapsed. +- appId is the exact, case-sensitive persistence identifier: bundle ID, or + `name:` if absent. Persist appId, not PID; resolve the current PID from + each snapshot before writing. +- volume is managed linear gain 0…1, not slider position or app-internal volume. + boost is a separate read-only multiplier. +- Requested routing is `{"mode":"followDefault"}`, `{"mode":"single","deviceUID":"uid"}`, + or `{"mode":"multi","deviceUIDs":["sorted","uids"]}`. +- appliedDeviceUIDs is the sorted current tap configuration (null if unknown). + Applied means engine/tap configuration, **not verified sound**. Status is + unavailable, pending, applied or failed. Error is null or + `{"code":"routingFailed","message":"Unable to configure audio routing"}` and + persists until the next routing attempt. Superseded completions cannot replace + a newer attempt's status. +- Devices are connected devices sorted by direction then UID. Key rows by + `(uid,direction)`; duplex devices have separate input/output rows. Defaults + report monitored system UIDs or null, not optimistic command targets. +- Device volume/mute are cached values or null if unsupported/unreadable. + volumeSource is hardware, ddc, software or null; input readback uses hardware. + Hardware cached values can be optimistic immediately after writes. Capabilities + describe readback/selectability, not additional API writes. +- Audio capture permission is unknown, authorized or denied. Source is preflight, + or assumed in builds without ENABLE_TCC_SPI. + +## Commands + +```json +{"type":"request","id":"3","sessionId":"example","method":"setAppVolume","params":{"appId":"com.example.Player","pid":1234,"volume":0.5}} +{"type":"request","id":"4","sessionId":"example","method":"setAppMute","params":{"appId":"com.example.Player","pid":1234,"muted":true}} +{"type":"request","id":"5","sessionId":"example","method":"setAppRouting","params":{"appId":"com.example.Player","pid":1234,"routing":{"mode":"followDefault"}}} +{"type":"request","id":"6","sessionId":"example","method":"setAppRouting","params":{"appId":"com.example.Player","pid":1234,"routing":{"mode":"single","deviceUID":"output-uid"}}} +{"type":"request","id":"7","sessionId":"example","method":"setDefaultOutput","params":{"deviceUID":"output-uid"}} +{"type":"request","id":"8","sessionId":"example","method":"setPreferredInput","params":{"deviceUID":"input-uid"}} +{"type":"response","id":"3","result":{"accepted":true}} +{"type":"response","id":"4","error":{"code":"invalidParams","message":"muted must be a boolean"}} +``` + +PID is optional, but must be a positive integer when present. Without PID, +multiple matching rows return ambiguousApp; no match returns appNotFound. +A supplied PID that no longer matches appId returns staleApp. No fuzzy name +matching, inactive-app writes, or launch-on-demand. Values are strictly typed; +gain must be finite and within 0…1; IDs must be nonempty; device UIDs must be +connected and have the requested direction. + +App writes require authorized audio capture and never prompt. Device selection +does not require it. Routing writes reject multi mode and apps currently in multi +mode; volume and mute still work for those apps. + +**Accepted does not mean applied.** Reconcile against getState or subscription +state. Synchronous device-selection failure returns commandFailed; asynchronous +routing failures appear in routing state. A command response is queued before its +resulting events on that connection. Preferred input follows FineTune's existing +preference/lock path without changing the lock setting. Priority and hot-plug +policies can subsequently change defaults. + +Error codes: unknownMethod, staleSession, invalidParams, appNotFound, ambiguousApp, +staleApp, deviceNotFound, unsupportedRouting, permissionDenied, commandFailed, +internalError. Messages are for people; match codes, not messages. Invalid IDs, +duplicate in-flight IDs, malformed envelopes and invalid framing close the connection. + +No toggle/step/batch commands, device-volume writes, EQ/boost editing, reset, +audio transport/meters, Bluetooth control, generic RPC or network access in v1. + +## Troubleshooting + +- No socket: enable the setting and keep FineTune running. Check any failure shown + beside the toggle. Do not change directory/socket permissions to share access. +- Already locked: another FineTune instance owns the listener. Quit that instance; + never remove server.lock while a listener runs. +- Unsafe path: inspect ownership and symlinks in FineTune/Control. FineTune fails + closed rather than deleting an unrelated file or repairing a parent directory. +- permissionDenied: grant FineTune Screen & System Audio Recording access in + System Settings. A different signing identity may require a fresh grant. +- Disconnect: discard applied-state confidence, reconnect with backoff, read the + new hello and subscribe again. Never retry old commands automatically. +- Controller faders: coalesce writes to at most 25 Hz per target, use absolute + gain/mute values, and show authoritative snapshot state rather than local guesses.