diff --git a/Sources/MacActivityApp/Localization/AppLocalization.swift b/Sources/MacActivityApp/Localization/AppLocalization.swift index 375a723..cf57012 100644 --- a/Sources/MacActivityApp/Localization/AppLocalization.swift +++ b/Sources/MacActivityApp/Localization/AppLocalization.swift @@ -46,6 +46,7 @@ enum AppLocalization { case audioVolumeAccessibility = "audio.accessibility.volume" case audioMuteAccessibility = "audio.accessibility.mute" case audioUnmuteAccessibility = "audio.accessibility.unmute" + case audioMuteRestoreUnavailable = "audio.accessibility.muteRestoreUnavailable" case audioMuted = "audio.state.muted" case audioNotMuted = "audio.state.notMuted" case dashboardCPUGPU = "dashboard.cpuGpu" diff --git a/Sources/MacActivityApp/Models/AudioControlCoordinator.swift b/Sources/MacActivityApp/Models/AudioControlCoordinator.swift index 90945ba..aa07cf4 100644 --- a/Sources/MacActivityApp/Models/AudioControlCoordinator.swift +++ b/Sources/MacActivityApp/Models/AudioControlCoordinator.swift @@ -57,6 +57,37 @@ struct AudioProcessControlValues: Equatable, Sendable { } } +struct AudioEffectiveVolumeState: Equatable, Sendable { + let rawVolume: Double + let isMuted: Bool + + init(rawVolume: Double, isMuted: Bool) { + self.rawVolume = Self.clamped(rawVolume) + self.isMuted = isMuted + } + + var displayVolume: Double { isMuted || rawVolume == 0 ? 0 : rawVolume } + var showsMutedIcon: Bool { displayVolume == 0 } + var canRestore: Bool { rawVolume > 0 } + + func settingDisplayVolume(_ value: Double) -> Self { + let requested = Self.clamped(value) + return requested == 0 + ? Self(rawVolume: rawVolume, isMuted: true) + : Self(rawVolume: requested, isMuted: false) + } + + func settingMuted(_ muted: Bool) -> Self? { + if muted { return Self(rawVolume: rawVolume, isMuted: true) } + guard canRestore else { return nil } + return Self(rawVolume: rawVolume, isMuted: false) + } + + private static func clamped(_ value: Double) -> Double { + min(1, max(0, value.isFinite ? value : 1)) + } +} + struct AudioProcessControlSnapshot: Identifiable, Equatable, Sendable { var id: AudioObjectID { process.processObjectID } let process: AudioProcessEntry @@ -100,6 +131,16 @@ typealias AudioControlDelay = @Sendable (Duration) async -> Void @MainActor final class AudioControlCoordinator: AudioControlCoordinating, ObservableObject { + private enum DeviceControlIntent { + case effectiveState(AudioEffectiveVolumeState, debounceVolume: Bool) + case muteOnly(Bool) + } + + private enum DeviceControlReadback { + case volume(Double) + case mute(Bool) + } + @Published private(set) var snapshot: AudioControlSnapshot = .empty let supportsProcessControls: Bool @@ -123,8 +164,9 @@ final class AudioControlCoordinator: AudioControlCoordinating, ObservableObject private var gainIntentOrdinals: [AudioObjectID: UInt64] = [:] private var gainTasks: [AudioObjectID: Task] = [:] private var retiringProcessObjectIDs: Set = [] - private var deviceVolumeTasks: [String: Task] = [:] - private var deviceMuteTasks: [String: Task] = [:] + private var deviceControlOrdinals: [String: UInt64] = [:] + private var deviceControlLifetimes: [String: UInt64] = [:] + private var deviceControlTasks: [String: Task] = [:] private var processTasks: [AudioObjectID: Task] = [:] private var trackedTasks: [UInt64: Task] = [:] private var nextTrackedTaskID: UInt64 = 0 @@ -180,8 +222,7 @@ final class AudioControlCoordinator: AudioControlCoordinating, ObservableObject } deinit { - deviceVolumeTasks.values.forEach { $0.cancel() } - deviceMuteTasks.values.forEach { $0.cancel() } + deviceControlTasks.values.forEach { $0.cancel() } processTasks.values.forEach { $0.cancel() } trackedTasks.values.forEach { $0.cancel() } monitorTask?.cancel() @@ -235,6 +276,9 @@ final class AudioControlCoordinator: AudioControlCoordinating, ObservableObject let device = try? deviceProvider.outputDeviceSnapshot(forUID: deviceUID) else { return } + if let previous = confirmedDevices[deviceUID], previous.objectID != device.objectID { + invalidateDeviceControlLifetime(deviceUID) + } confirmedDevices[deviceUID] = device updateDevice(deviceUID) { row in row.device = device @@ -243,72 +287,308 @@ final class AudioControlCoordinator: AudioControlCoordinating, ObservableObject } func setDeviceVolume(_ volume: Double, for deviceUID: String) { - guard acceptsMutations, let confirmed = confirmedDevices[deviceUID] else { return } let requested = min(1, max(0, volume.isFinite ? volume : 1)) - updateDevice(deviceUID) { row in - row.device = Self.device(row.device, volume: .value(requested, isWritable: true)) + guard acceptsMutations, + let device = snapshot.devices.first(where: { $0.id == deviceUID })?.device, + let stateSource = requested == 0 ? confirmedDevices[deviceUID] : device, + let current = writableDeviceState(stateSource) else { return } + submitDeviceState( + current.settingDisplayVolume(requested), + for: deviceUID, + debounceVolume: requested > 0 + ) + } + + func setDeviceMuted(_ isMuted: Bool, for deviceUID: String) { + guard acceptsMutations, + let device = snapshot.devices.first(where: { $0.id == deviceUID })?.device else { + return + } + if let current = writableDeviceState(device), + let target = current.settingMuted(isMuted) { + submitDeviceState(target, for: deviceUID, debounceVolume: false) + return + } + guard let current = writableMuteOnlyDeviceState(device), + current.settingMuted(isMuted) != nil else { return } + submitDeviceControl(.muteOnly(isMuted), for: deviceUID) + } + + func writableDeviceState( + _ device: AudioOutputDeviceSnapshot + ) -> AudioEffectiveVolumeState? { + guard case .value(let volume, isWritable: true) = device.volume, + case .value(let muted, isWritable: true) = device.mute else { return nil } + return AudioEffectiveVolumeState(rawVolume: volume, isMuted: muted) + } + + private func writableMuteOnlyDeviceState( + _ device: AudioOutputDeviceSnapshot + ) -> AudioEffectiveVolumeState? { + guard case .value(let volume, isWritable: false) = device.volume, + case .value(let muted, isWritable: true) = device.mute else { return nil } + return AudioEffectiveVolumeState(rawVolume: volume, isMuted: muted) + } + + func isCurrentDeviceIntent(_ uid: String, ordinal: UInt64) -> Bool { + acceptsMutations && !Task.isCancelled && deviceControlOrdinals[uid] == ordinal + } + + private func isCurrentDeviceLifetime( + _ uid: String, + objectID: AudioObjectID, + lifetime: UInt64 + ) -> Bool { + acceptsMutations + && deviceControlLifetimes[uid, default: 0] == lifetime + && confirmedDevices[uid]?.objectID == objectID + } + + private func isCurrentDeviceOperation( + _ uid: String, + objectID: AudioObjectID, + lifetime: UInt64, + ordinal: UInt64 + ) -> Bool { + isCurrentDeviceIntent(uid, ordinal: ordinal) + && isCurrentDeviceLifetime(uid, objectID: objectID, lifetime: lifetime) + } + + func submitDeviceState( + _ target: AudioEffectiveVolumeState, + for uid: String, + debounceVolume: Bool + ) { + submitDeviceControl( + .effectiveState(target, debounceVolume: debounceVolume), + for: uid + ) + } + + private func submitDeviceControl(_ intent: DeviceControlIntent, for uid: String) { + guard let fallback = confirmedDevices[uid], + deviceCanAccept(intent, snapshot: fallback) else { return } + let objectID = fallback.objectID + let lifetime = deviceControlLifetimes[uid, default: 0] + let ordinal = (deviceControlOrdinals[uid] ?? 0) &+ 1 + deviceControlOrdinals[uid] = ordinal + let previous = deviceControlTasks[uid] + previous?.cancel() + updateDevice(uid) { row in + switch intent { + case .effectiveState(let target, _): + row.device = Self.device(row.device, state: target) + case .muteOnly(let muted): + row.device = Self.device( + row.device, + mute: .value(muted, isWritable: true) + ) + } row.error = nil } - deviceVolumeTasks[deviceUID]?.cancel() - deviceVolumeTasks[deviceUID] = trackedTask { @MainActor [weak self] in - guard let self else { return } - guard Task.isCancelled == false else { return } - await delay(.milliseconds(75)) - guard Task.isCancelled == false else { return } + + let task = trackedTask { @MainActor [weak self] in + await previous?.value + guard let self, + isCurrentDeviceOperation( + uid, + objectID: objectID, + lifetime: lifetime, + ordinal: ordinal + ) else { return } do { - let value = try deviceProvider.writeVolume(requested, forUID: deviceUID) - let current = confirmedDevices[deviceUID] ?? confirmed - let updated = Self.device(current, volume: .value(value, isWritable: true)) - confirmedDevices[deviceUID] = updated - updateDevice(deviceUID) { row in - row.device = updated - row.error = nil + guard let confirmed = try await executeDeviceControl( + intent, + uid: uid, + objectID: objectID, + lifetime: lifetime, + ordinal: ordinal + ) else { return } + updateDevice(uid) { row in + row.device = confirmed + row.error = Self.deviceError(in: confirmed) } } catch { - let rolledBack = confirmedDevices[deviceUID] ?? confirmed - updateDevice(deviceUID) { row in + guard isCurrentDeviceOperation( + uid, + objectID: objectID, + lifetime: lifetime, + ordinal: ordinal + ) else { return } + let rolledBack = confirmedDevices[uid] ?? fallback + updateDevice(uid) { row in row.device = rolledBack row.error = .deviceWrite } } } + deviceControlTasks[uid] = task } - func setDeviceMuted(_ isMuted: Bool, for deviceUID: String) { - guard acceptsMutations, let confirmed = confirmedDevices[deviceUID] else { return } - updateDevice(deviceUID) { row in - row.device = Self.device(row.device, mute: .value(isMuted, isWritable: true)) - row.error = nil + private func deviceCanAccept( + _ intent: DeviceControlIntent, + snapshot: AudioOutputDeviceSnapshot + ) -> Bool { + switch intent { + case .effectiveState: + return writableDeviceState(snapshot) != nil + case .muteOnly(let muted): + guard let current = writableMuteOnlyDeviceState(snapshot) else { return false } + return current.settingMuted(muted) != nil } - deviceMuteTasks[deviceUID]?.cancel() - deviceMuteTasks[deviceUID] = trackedTask { @MainActor [weak self] in - guard let self else { return } - guard Task.isCancelled == false else { return } - do { - let value = try deviceProvider.writeMute(isMuted, forUID: deviceUID) - let current = confirmedDevices[deviceUID] ?? confirmed - let updated = Self.device(current, mute: .value(value, isWritable: true)) - confirmedDevices[deviceUID] = updated - updateDevice(deviceUID) { row in - row.device = updated - row.error = nil - } - } catch { - let rolledBack = confirmedDevices[deviceUID] ?? confirmed - updateDevice(deviceUID) { row in - row.device = rolledBack - row.error = .deviceWrite + } + + private func executeDeviceControl( + _ intent: DeviceControlIntent, + uid: String, + objectID: AudioObjectID, + lifetime: UInt64, + ordinal: UInt64 + ) async throws -> AudioOutputDeviceSnapshot? { + switch intent { + case .effectiveState(let target, let debounceVolume): + return try await executeEffectiveDeviceControl( + target, + uid: uid, + objectID: objectID, + lifetime: lifetime, + ordinal: ordinal, + debounceVolume: debounceVolume + ) + case .muteOnly(let targetMuted): + guard let latest = confirmedDevices[uid], + case .value(let volume, _) = latest.volume, + case .value(let currentMuted, isWritable: true) = latest.mute, + AudioEffectiveVolumeState( + rawVolume: volume, + isMuted: currentMuted + ).settingMuted(targetMuted) != nil else { return nil } + guard currentMuted != targetMuted else { return latest } + guard isCurrentDeviceOperation( + uid, + objectID: objectID, + lifetime: lifetime, + ordinal: ordinal + ) else { return nil } + let muted = try deviceProvider.writeMute(targetMuted, forUID: uid) + guard isCurrentDeviceLifetime(uid, objectID: objectID, lifetime: lifetime), + let refreshed = confirmedDevices[uid] else { return nil } + let confirmed = mergeSuccessfulDeviceReadback(.mute(muted), into: refreshed) + confirmedDevices[uid] = confirmed + guard isCurrentDeviceIntent(uid, ordinal: ordinal) else { return nil } + return confirmed + } + } + + private func executeEffectiveDeviceControl( + _ target: AudioEffectiveVolumeState, + uid: String, + objectID: AudioObjectID, + lifetime: UInt64, + ordinal: UInt64, + debounceVolume: Bool + ) async throws -> AudioOutputDeviceSnapshot? { + guard var confirmed = confirmedDevices[uid], + var current = writableDeviceState(confirmed) else { return nil } + if current.rawVolume != target.rawVolume { + if debounceVolume { + await delay(.milliseconds(75)) + guard isCurrentDeviceOperation( + uid, + objectID: objectID, + lifetime: lifetime, + ordinal: ordinal + ), + let latest = confirmedDevices[uid], + let latestState = writableDeviceState(latest) else { return nil } + confirmed = latest + current = latestState + } + if current.rawVolume != target.rawVolume { + guard isCurrentDeviceOperation( + uid, + objectID: objectID, + lifetime: lifetime, + ordinal: ordinal + ) else { return nil } + let volume = try deviceProvider.writeVolume(target.rawVolume, forUID: uid) + guard isCurrentDeviceLifetime(uid, objectID: objectID, lifetime: lifetime), + let latest = confirmedDevices[uid] else { return nil } + confirmed = mergeSuccessfulDeviceReadback(.volume(volume), into: latest) + confirmedDevices[uid] = confirmed + guard let merged = writableDeviceState(confirmed) else { + guard isCurrentDeviceIntent(uid, ordinal: ordinal) else { return nil } + return confirmed } + current = merged + guard isCurrentDeviceIntent(uid, ordinal: ordinal) else { return nil } } } + if current.isMuted != target.isMuted { + guard isCurrentDeviceOperation( + uid, + objectID: objectID, + lifetime: lifetime, + ordinal: ordinal + ) else { return nil } + let muted = try deviceProvider.writeMute(target.isMuted, forUID: uid) + guard isCurrentDeviceLifetime(uid, objectID: objectID, lifetime: lifetime), + let latest = confirmedDevices[uid] else { return nil } + confirmed = mergeSuccessfulDeviceReadback(.mute(muted), into: latest) + confirmedDevices[uid] = confirmed + guard isCurrentDeviceIntent(uid, ordinal: ordinal) else { return nil } + } + return confirmed + } + + private func mergeSuccessfulDeviceReadback( + _ readback: DeviceControlReadback, + into latest: AudioOutputDeviceSnapshot + ) -> AudioOutputDeviceSnapshot { + switch readback { + case .volume(let volume): + let isWritable: Bool + if case .value(_, let latestWritable) = latest.volume { + isWritable = latestWritable + } else { + isWritable = true + } + return Self.device( + latest, + volume: .value(volume, isWritable: isWritable) + ) + case .mute(let muted): + let isWritable: Bool + if case .value(_, let latestWritable) = latest.mute { + isWritable = latestWritable + } else { + isWritable = true + } + return Self.device( + latest, + mute: .value(muted, isWritable: isWritable) + ) + } } func setProcessVolume(_ volume: Double, for processObjectID: AudioObjectID) { - updateProcessIntent(processObjectID) { $0.volume = min(1, max(0, volume)) } + updateProcessIntent(processObjectID) { values in + let next = AudioEffectiveVolumeState( + rawVolume: values.volume, isMuted: values.isMuted + ).settingDisplayVolume(volume) + values.volume = next.rawVolume + values.isMuted = next.isMuted + } } func setProcessMuted(_ isMuted: Bool, for processObjectID: AudioObjectID) { - updateProcessIntent(processObjectID) { $0.isMuted = isMuted } + updateProcessIntent(processObjectID) { values in + guard let next = AudioEffectiveVolumeState( + rawVolume: values.volume, isMuted: values.isMuted + ).settingMuted(isMuted) else { return } + values.volume = next.rawVolume + values.isMuted = next.isMuted + } } func setProcessRoute(_ route: AudioRouteMode, for processObjectID: AudioObjectID) { @@ -351,8 +631,7 @@ final class AudioControlCoordinator: AudioControlCoordinating, ObservableObject } requestShutdown() didBeginShutdown = true - deviceVolumeTasks.values.forEach { $0.cancel() } - deviceMuteTasks.values.forEach { $0.cancel() } + deviceControlTasks.values.forEach { $0.cancel() } processTasks.values.forEach { $0.cancel() } let workTasks = Array(trackedTasks.values) workTasks.forEach { $0.cancel() } @@ -380,8 +659,8 @@ final class AudioControlCoordinator: AudioControlCoordinating, ObservableObject } } - func testingWaitForDeviceMute(_ deviceUID: String) async { - await deviceMuteTasks[deviceUID]?.value + func testingWaitForDeviceControl(_ deviceUID: String) async { + await deviceControlTasks[deviceUID]?.value } func testingWaitForProcessTask(_ processObjectID: AudioObjectID) async { @@ -417,10 +696,38 @@ private extension AudioControlCoordinator { } func refreshDevices() { - snapshot.devices = (try? deviceProvider.outputDeviceSnapshots().map { - confirmedDevices[$0.id] = $0 - return AudioDeviceControlSnapshot(device: $0, error: Self.deviceError(in: $0)) - }) ?? [] + guard let devices = try? deviceProvider.outputDeviceSnapshots() else { + invalidateAllDeviceControlLifetimes() + confirmedDevices.removeAll() + snapshot.devices = [] + return + } + var refreshedDevices: [String: AudioOutputDeviceSnapshot] = [:] + let refreshedRows = devices.map { device in + refreshedDevices[device.id] = device + return AudioDeviceControlSnapshot( + device: device, + error: Self.deviceError(in: device) + ) + } + for (uid, previous) in confirmedDevices + where refreshedDevices[uid]?.objectID != previous.objectID { + invalidateDeviceControlLifetime(uid) + } + snapshot.devices = refreshedRows + confirmedDevices = refreshedDevices + } + + func invalidateDeviceControlLifetime(_ uid: String) { + deviceControlLifetimes[uid] = (deviceControlLifetimes[uid] ?? 0) &+ 1 + deviceControlOrdinals[uid] = (deviceControlOrdinals[uid] ?? 0) &+ 1 + deviceControlTasks[uid]?.cancel() + } + + func invalidateAllDeviceControlLifetimes() { + for uid in confirmedDevices.keys { + invalidateDeviceControlLifetime(uid) + } } func refreshProcesses(resetSessions: Bool = false) { @@ -586,8 +893,11 @@ private extension AudioControlCoordinator { isMuted: row.isMuted, route: row.route ) + let originalValues = values + let shouldRetryFailedIntent = row.error != nil let previousRoute = values.route mutate(&values) + guard values != originalValues || shouldRetryFailedIntent else { return } let routeOptions = values.route == previousRoute ? row.routeOptions : makeRouteOptions(for: values.route, process: row.process) @@ -892,6 +1202,17 @@ private extension AudioControlCoordinator { return .operationFailed(error) } + static func device( + _ device: AudioOutputDeviceSnapshot, + state: AudioEffectiveVolumeState + ) -> AudioOutputDeviceSnapshot { + Self.device( + device, + volume: .value(state.rawVolume, isWritable: true), + mute: .value(state.isMuted, isWritable: true) + ) + } + static func device( _ device: AudioOutputDeviceSnapshot, volume: AudioPropertyValue? = nil, @@ -1067,6 +1388,7 @@ private extension AudioControlCoordinator { func handle(_ changes: Set) async { if changes.contains(.serviceRestarted) { + invalidateAllDeviceControlLifetimes() if processRuntimeWasStarted { await engine.stopAll() } diff --git a/Sources/MacActivityApp/Resources/de.lproj/Localizable.strings b/Sources/MacActivityApp/Resources/de.lproj/Localizable.strings index 0e3fa6f..06910ed 100644 --- a/Sources/MacActivityApp/Resources/de.lproj/Localizable.strings +++ b/Sources/MacActivityApp/Resources/de.lproj/Localizable.strings @@ -36,6 +36,7 @@ "audio.accessibility.volume" = "Lautstärke von %@, %d Prozent"; "audio.accessibility.mute" = "%@ stummschalten"; "audio.accessibility.unmute" = "Stummschaltung für %@ aufheben"; +"audio.accessibility.muteRestoreUnavailable" = "Stummgeschaltet; keine vorherige Lautstärke zum Wiederherstellen"; "audio.state.muted" = "Stummgeschaltet"; "audio.state.notMuted" = "Nicht stummgeschaltet"; "dashboard.cpuGpu" = "CPU / GPU"; diff --git a/Sources/MacActivityApp/Resources/en.lproj/Localizable.strings b/Sources/MacActivityApp/Resources/en.lproj/Localizable.strings index 9fbb6ca..5502152 100644 --- a/Sources/MacActivityApp/Resources/en.lproj/Localizable.strings +++ b/Sources/MacActivityApp/Resources/en.lproj/Localizable.strings @@ -36,6 +36,7 @@ "audio.accessibility.volume" = "%@ volume, %d percent"; "audio.accessibility.mute" = "Mute %@"; "audio.accessibility.unmute" = "Unmute %@"; +"audio.accessibility.muteRestoreUnavailable" = "Muted; no previous volume to restore"; "audio.state.muted" = "Muted"; "audio.state.notMuted" = "Not muted"; "dashboard.cpuGpu" = "CPU / GPU"; diff --git a/Sources/MacActivityApp/Resources/fr.lproj/Localizable.strings b/Sources/MacActivityApp/Resources/fr.lproj/Localizable.strings index b413a7b..91723d3 100644 --- a/Sources/MacActivityApp/Resources/fr.lproj/Localizable.strings +++ b/Sources/MacActivityApp/Resources/fr.lproj/Localizable.strings @@ -36,6 +36,7 @@ "audio.accessibility.volume" = "Volume de %@, %d pour cent"; "audio.accessibility.mute" = "Couper le son de %@"; "audio.accessibility.unmute" = "Réactiver le son de %@"; +"audio.accessibility.muteRestoreUnavailable" = "Son coupé ; aucun volume précédent à restaurer"; "audio.state.muted" = "Son coupé"; "audio.state.notMuted" = "Son non coupé"; "dashboard.cpuGpu" = "CPU / GPU"; diff --git a/Sources/MacActivityApp/Resources/ja.lproj/Localizable.strings b/Sources/MacActivityApp/Resources/ja.lproj/Localizable.strings index 1264d8c..9a480e6 100644 --- a/Sources/MacActivityApp/Resources/ja.lproj/Localizable.strings +++ b/Sources/MacActivityApp/Resources/ja.lproj/Localizable.strings @@ -36,6 +36,7 @@ "audio.accessibility.volume" = "%@の音量、%dパーセント"; "audio.accessibility.mute" = "%@をミュート"; "audio.accessibility.unmute" = "%@のミュートを解除"; +"audio.accessibility.muteRestoreUnavailable" = "ミュート中。復元できる以前の音量はありません"; "audio.state.muted" = "ミュート中"; "audio.state.notMuted" = "ミュートされていません"; "dashboard.cpuGpu" = "CPU / GPU"; diff --git a/Sources/MacActivityApp/Resources/ko.lproj/Localizable.strings b/Sources/MacActivityApp/Resources/ko.lproj/Localizable.strings index 02e3014..84c5005 100644 --- a/Sources/MacActivityApp/Resources/ko.lproj/Localizable.strings +++ b/Sources/MacActivityApp/Resources/ko.lproj/Localizable.strings @@ -36,6 +36,7 @@ "audio.accessibility.volume" = "%@ 음량, %d퍼센트"; "audio.accessibility.mute" = "%@ 음소거"; "audio.accessibility.unmute" = "%@ 음소거 해제"; +"audio.accessibility.muteRestoreUnavailable" = "음소거됨. 복원할 이전 음량이 없습니다"; "audio.state.muted" = "음소거됨"; "audio.state.notMuted" = "음소거되지 않음"; "dashboard.cpuGpu" = "CPU / GPU"; diff --git a/Sources/MacActivityApp/Resources/zh-Hans.lproj/Localizable.strings b/Sources/MacActivityApp/Resources/zh-Hans.lproj/Localizable.strings index c4403ef..a5a1de8 100644 --- a/Sources/MacActivityApp/Resources/zh-Hans.lproj/Localizable.strings +++ b/Sources/MacActivityApp/Resources/zh-Hans.lproj/Localizable.strings @@ -36,6 +36,7 @@ "audio.accessibility.volume" = "%@ 音量,百分之 %d"; "audio.accessibility.mute" = "将 %@ 静音"; "audio.accessibility.unmute" = "取消 %@ 静音"; +"audio.accessibility.muteRestoreUnavailable" = "已静音,没有可恢复的历史音量"; "audio.state.muted" = "已静音"; "audio.state.notMuted" = "未静音"; "dashboard.cpuGpu" = "CPU / GPU"; diff --git a/Sources/MacActivityApp/Resources/zh-Hant.lproj/Localizable.strings b/Sources/MacActivityApp/Resources/zh-Hant.lproj/Localizable.strings index e6cfdb6..b059973 100644 --- a/Sources/MacActivityApp/Resources/zh-Hant.lproj/Localizable.strings +++ b/Sources/MacActivityApp/Resources/zh-Hant.lproj/Localizable.strings @@ -36,6 +36,7 @@ "audio.accessibility.volume" = "%@ 音量,百分之 %d"; "audio.accessibility.mute" = "將 %@ 靜音"; "audio.accessibility.unmute" = "取消 %@ 靜音"; +"audio.accessibility.muteRestoreUnavailable" = "已靜音,沒有可恢復的先前音量"; "audio.state.muted" = "已靜音"; "audio.state.notMuted" = "未靜音"; "dashboard.cpuGpu" = "CPU / GPU"; diff --git a/Sources/MacActivityApp/Views/AudioDashboardView.swift b/Sources/MacActivityApp/Views/AudioDashboardView.swift index 89912e3..390e633 100644 --- a/Sources/MacActivityApp/Views/AudioDashboardView.swift +++ b/Sources/MacActivityApp/Views/AudioDashboardView.swift @@ -60,6 +60,195 @@ extension View { } } +enum AudioVolumeMotion: Equatable { + case mute + case restore + case rollback + case external +} + +struct AudioVolumeMotionTrigger: Equatable { + let id: UInt64 + let motion: AudioVolumeMotion +} + +enum AudioVolumeMotionSelection { + static func resolve( + trigger: AudioVolumeMotionTrigger?, + consumedTriggerID: UInt64?, + hasWriteFailure: Bool + ) -> AudioVolumeMotion { + if hasWriteFailure { return .rollback } + guard let trigger, trigger.id != consumedTriggerID else { return .external } + return trigger.motion + } +} + +struct AudioMuteGlyphPresentation: Equatable { + let isMuted: Bool + let reduceMotion: Bool + let motion: AudioVolumeMotion + + var waveOpacity: Double { isMuted ? 0 : 1 } + var mutedOpacity: Double { isMuted ? 1 : 0 } + var crossfadeDuration: TimeInterval { + if reduceMotion { return 0.1 } + return motion == .rollback ? 0.16 : 0.14 + } +} + +struct AudioVolumeMotionPolicy: Equatable { + let isEditing: Bool + let reduceMotion: Bool + let motion: AudioVolumeMotion + + var duration: TimeInterval? { + guard !isEditing && !reduceMotion else { return nil } + switch motion { + case .mute: return 0.2 + case .restore: return 0.22 + case .rollback: return 0.16 + case .external: return 0.14 + } + } + + var usesSpring: Bool { motion == .mute || motion == .restore } + + var animation: Animation? { + guard let duration else { return nil } + return usesSpring + ? .spring(response: duration, dampingFraction: 1) + : .easeInOut(duration: duration) + } +} + +private struct AudioMuteGlyph: View { + let isMuted: Bool + let motion: AudioVolumeMotion + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + private var presentation: AudioMuteGlyphPresentation { + .init(isMuted: isMuted, reduceMotion: reduceMotion, motion: motion) + } + + var body: some View { + ZStack { + Image(systemName: "speaker.wave.2.fill") + .opacity(presentation.waveOpacity) + Image(systemName: "speaker.slash.fill") + .opacity(presentation.mutedOpacity) + } + .animation(.easeInOut(duration: presentation.crossfadeDuration), value: isMuted) + .frame(width: 20, height: 20) + } +} + +private struct AudioMuteButtonStyle: ButtonStyle { + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .scaleEffect(configuration.isPressed && !reduceMotion ? 0.96 : 1) + .opacity(configuration.isPressed ? 0.78 : 1) + .animation(.easeOut(duration: 0.1), value: configuration.isPressed) + } +} + +private struct AudioVolumeTrack: View { + let value: Double + + private var clampedValue: CGFloat { + CGFloat(min(max(value, 0), 1)) + } + + var body: some View { + GeometryReader { geometry in + ZStack(alignment: .leading) { + Capsule() + .fill(Color.primary.opacity(0.12)) + Capsule() + .fill(Color.accentColor) + .frame(width: geometry.size.width * clampedValue) + } + } + .frame(height: 4) + .accessibilityHidden(true) + } +} + +private struct AudioAnimatedVolumeSlider: View { + @Binding var value: Double + let accessibility: AudioAccessibilityContract + let trigger: AudioVolumeMotionTrigger? + let hasWriteFailure: Bool + @State private var displayedValue: Double + @State private var isEditing = false + @State private var consumedTriggerID: UInt64? + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + init( + value: Binding, + accessibility: AudioAccessibilityContract, + trigger: AudioVolumeMotionTrigger?, + hasWriteFailure: Bool + ) { + _value = value + self.accessibility = accessibility + self.trigger = trigger + self.hasWriteFailure = hasWriteFailure + _displayedValue = State(initialValue: value.wrappedValue) + } + + private var motionPolicy: AudioVolumeMotionPolicy { + .init(isEditing: isEditing, reduceMotion: reduceMotion, motion: motion) + } + + private var motion: AudioVolumeMotion { + AudioVolumeMotionSelection.resolve( + trigger: trigger, + consumedTriggerID: consumedTriggerID, + hasWriteFailure: hasWriteFailure + ) + } + + var body: some View { + ZStack { + AudioVolumeTrack(value: displayedValue) + .allowsHitTesting(false) + + Slider(value: $displayedValue, in: 0...1, onEditingChanged: { isEditing = $0 }) + .opacity(0.01) + } + .frame(height: 20) + .onChange(of: displayedValue) { updatedValue in + guard isEditing else { return } + value = updatedValue + } + .onChange(of: value) { updatedValue in + synchronizeDisplayedValue(to: updatedValue) + + if let trigger, trigger.id != consumedTriggerID { + consumedTriggerID = trigger.id + } + } + .onChange(of: hasWriteFailure) { didFail in + guard didFail else { return } + synchronizeDisplayedValue(to: value) + } + .audioAccessibility(accessibility) + } + + private func synchronizeDisplayedValue(to updatedValue: Double) { + if isEditing { + displayedValue = updatedValue + } else { + withAnimation(motionPolicy.animation) { + displayedValue = updatedValue + } + } + } +} + @MainActor enum AudioDashboardControlBindings { static func deviceVolume( @@ -70,12 +259,17 @@ enum AudioDashboardControlBindings { Binding( get: { guard let row = model.snapshot.devices.first(where: { $0.id == deviceUID }), - case .value(let value, _) = row.device.volume else { return fallback } - return value + case .value(let volume, _) = row.device.volume else { return fallback } + guard case .value(let muted, _) = row.device.mute else { return volume } + return AudioEffectiveVolumeState( + rawVolume: volume, + isMuted: muted + ).displayVolume }, set: { value in guard let row = model.snapshot.devices.first(where: { $0.id == deviceUID }), - row.device.volume.isWritable else { return } + case .value(_, isWritable: true) = row.device.volume, + case .value(_, isWritable: true) = row.device.mute else { return } model.setDeviceVolume(value, for: deviceUID) } ) @@ -88,8 +282,13 @@ enum AudioDashboardControlBindings { ) -> Binding { Binding( get: { - model.snapshot.processes.first(where: { $0.id == processObjectID })?.volume - ?? fallback + guard let row = model.snapshot.processes.first(where: { + $0.id == processObjectID + }) else { return fallback } + return AudioEffectiveVolumeState( + rawVolume: row.volume, + isMuted: row.isMuted + ).displayVolume }, set: { value in guard model.snapshot.processes.contains(where: { $0.id == processObjectID }) else { @@ -128,8 +327,12 @@ enum AudioDashboardControlBindings { static func toggleDeviceMute(model: AudioDashboardModel, deviceUID: String) { guard let row = model.snapshot.devices.first(where: { $0.id == deviceUID }), - case .value(let current, isWritable: true) = row.device.mute else { return } - model.setDeviceMuted(!current, for: deviceUID) + case .value(let volume, _) = row.device.volume, + case .value(let muted, isWritable: true) = row.device.mute else { return } + let current = AudioEffectiveVolumeState(rawVolume: volume, isMuted: muted) + let targetMuted = !current.showsMutedIcon + guard current.settingMuted(targetMuted) != nil else { return } + model.setDeviceMuted(targetMuted, for: deviceUID) } static func toggleProcessMute( @@ -138,7 +341,13 @@ enum AudioDashboardControlBindings { ) { guard let row = model.snapshot.processes.first(where: { $0.id == processObjectID }) else { return } - model.setProcessMuted(!row.isMuted, for: processObjectID) + let current = AudioEffectiveVolumeState( + rawVolume: row.volume, + isMuted: row.isMuted + ) + let targetMuted = !current.showsMutedIcon + guard current.settingMuted(targetMuted) != nil else { return } + model.setProcessMuted(targetMuted, for: processObjectID) } } @@ -214,8 +423,12 @@ private struct AudioDashboardSection: View { private struct AudioDeviceControlRow: View { let presentation: AudioDeviceRowPresentation @ObservedObject var model: AudioDashboardModel + @State private var muteMotion: AudioVolumeMotionTrigger? private var snapshot: AudioDeviceControlSnapshot { presentation.snapshot } + private var muteVisualMotion: AudioVolumeMotion { + snapshot.error == nil ? (muteMotion?.motion ?? .external) : .rollback + } var body: some View { VStack(alignment: .leading, spacing: 5) { @@ -226,7 +439,7 @@ private struct AudioDeviceControlRow: View { Spacer(minLength: 12) volumeControl - .frame(maxWidth: 150) + .frame(width: 150, height: 20, alignment: .center) muteControl } @@ -251,13 +464,14 @@ private struct AudioDeviceControlRow: View { private var volumeControl: some View { switch presentation.volume { case .slider(let value): - Slider( + AudioAnimatedVolumeSlider( value: AudioDashboardControlBindings.deviceVolume( model: model, deviceUID: snapshot.id, fallback: value ), - in: 0...1 + accessibility: presentation.volumeAccessibility, + trigger: muteMotion, + hasWriteFailure: snapshot.error != nil ) - .audioAccessibility(presentation.volumeAccessibility) case .readOnly(let value): Text(value, format: .percent.precision(.fractionLength(0))) @@ -283,20 +497,20 @@ private struct AudioDeviceControlRow: View { @ViewBuilder private var muteControl: some View { switch presentation.mute { - case .button(let isMuted): + case .button(let isMuted, _): Button { + recordMuteToggle(from: isMuted) AudioDashboardControlBindings.toggleDeviceMute( model: model, deviceUID: snapshot.id ) } label: { - Image(systemName: isMuted ? "speaker.slash.fill" : "speaker.wave.2.fill") - .frame(width: 20) + AudioMuteGlyph(isMuted: isMuted, motion: muteVisualMotion) } - .buttonStyle(.plain) + .buttonStyle(AudioMuteButtonStyle()) .audioAccessibility(presentation.muteAccessibility) case .readOnly(let isMuted): - Image(systemName: isMuted ? "speaker.slash.fill" : "speaker.wave.2.fill") + AudioMuteGlyph(isMuted: isMuted, motion: muteVisualMotion) .foregroundStyle(.secondary) .audioAccessibility(presentation.muteAccessibility) @@ -331,13 +545,24 @@ private struct AudioDeviceControlRow: View { .font(.caption2) .foregroundStyle(.secondary) } + + private func recordMuteToggle(from isMuted: Bool) { + muteMotion = .init( + id: (muteMotion?.id ?? 0) &+ 1, + motion: isMuted ? .restore : .mute + ) + } } private struct AudioProcessControlRow: View { let presentation: AudioProcessRowPresentation @ObservedObject var model: AudioDashboardModel + @State private var muteMotion: AudioVolumeMotionTrigger? private var snapshot: AudioProcessControlSnapshot { presentation.snapshot } + private var muteVisualMotion: AudioVolumeMotion { + snapshot.error == nil ? (muteMotion?.motion ?? .external) : .rollback + } var body: some View { VStack(alignment: .leading, spacing: 6) { @@ -348,19 +573,26 @@ private struct AudioProcessControlRow: View { Spacer(minLength: 12) - Slider(value: volumeBinding, in: 0...1) + AudioAnimatedVolumeSlider( + value: volumeBinding, + accessibility: presentation.volumeAccessibility, + trigger: muteMotion, + hasWriteFailure: snapshot.error != nil + ) .frame(maxWidth: 130) - .audioAccessibility(presentation.volumeAccessibility) Button { + recordMuteToggle(from: presentation.showsMutedIcon) AudioDashboardControlBindings.toggleProcessMute( model: model, processObjectID: snapshot.id ) } label: { - Image(systemName: snapshot.isMuted ? "speaker.slash.fill" : "speaker.wave.2.fill") - .frame(width: 20) + AudioMuteGlyph( + isMuted: presentation.showsMutedIcon, + motion: muteVisualMotion + ) } - .buttonStyle(.plain) + .buttonStyle(AudioMuteButtonStyle()) .audioAccessibility(presentation.muteAccessibility) routeMenu @@ -387,6 +619,13 @@ private struct AudioProcessControlRow: View { ) } + private func recordMuteToggle(from isMuted: Bool) { + muteMotion = .init( + id: (muteMotion?.id ?? 0) &+ 1, + motion: isMuted ? .restore : .mute + ) + } + private var routeMenu: some View { Menu { Button { @@ -579,7 +818,7 @@ enum AudioVolumeControlPresentation: Equatable { } enum AudioMuteControlPresentation: Equatable { - case button(Bool) + case button(isMuted: Bool, canToggle: Bool) case readOnly(Bool) case unsupported case unavailable(String) @@ -662,16 +901,49 @@ struct AudioDeviceRowPresentation: Identifiable { init(_ snapshot: AudioDeviceControlSnapshot) { self.snapshot = snapshot + let effective: AudioEffectiveVolumeState? + if case .value(let volume, _) = snapshot.device.volume, + case .value(let muted, _) = snapshot.device.mute { + effective = AudioEffectiveVolumeState(rawVolume: volume, isMuted: muted) + } else { + effective = nil + } + let supportsStrongBinding: Bool + let supportsMuteOnly: Bool + switch (snapshot.device.volume, snapshot.device.mute) { + case (.value(_, isWritable: true), .value(_, isWritable: true)): + supportsStrongBinding = true + supportsMuteOnly = false + case (.value(_, isWritable: false), .value(_, isWritable: true)): + supportsStrongBinding = false + supportsMuteOnly = true + default: + supportsStrongBinding = false + supportsMuteOnly = false + } switch snapshot.device.volume { - case .value(let value, isWritable: true): volume = .slider(value) - case .value(let value, isWritable: false): volume = .readOnly(value) + case .value(let value, isWritable: true): + let displayVolume = effective?.displayVolume ?? value + volume = supportsStrongBinding ? .slider(displayVolume) : .readOnly(displayVolume) + case .value(let value, isWritable: false): + volume = .readOnly(effective?.displayVolume ?? value) case .unsupported: volume = .unsupported case .unavailable: volume = .unavailable case .failed: volume = .failed } switch snapshot.device.mute { - case .value(let value, isWritable: true): mute = .button(value) - case .value(let value, isWritable: false): mute = .readOnly(value) + case .value(let value, isWritable: true): + let isMuted = effective?.showsMutedIcon ?? value + if supportsStrongBinding || supportsMuteOnly { + mute = .button( + isMuted: isMuted, + canToggle: isMuted ? (effective?.canRestore ?? true) : true + ) + } else { + mute = .readOnly(isMuted) + } + case .value(let value, isWritable: false): + mute = .readOnly(effective?.showsMutedIcon ?? value) case .unsupported: mute = .unsupported case .unavailable: mute = .unavailable( @@ -687,16 +959,23 @@ struct AudioDeviceRowPresentation: Identifiable { var muteAccessibility: AudioAccessibilityContract { let identifier = "\(accessibilityPrefix).\(mute.identifierSuffix ?? "mute")" switch mute { - case .button(let isMuted): - return AudioAccessibilityContract( - identifier: identifier, - label: audioTargetLabel( + case .button(let isMuted, let canToggle): + let label = isMuted && !canToggle + ? audioTargetLabel( + AppLocalization.string(.audioMuteRestoreUnavailable), + target: snapshot.device.name + ) + : audioTargetLabel( AppLocalization.string( isMuted ? .audioUnmuteAccessibility : .audioMuteAccessibility, snapshot.device.name ), target: snapshot.device.name - ), - value: AppLocalization.string(isMuted ? .audioMuted : .audioNotMuted) + ) + return AudioAccessibilityContract( + identifier: identifier, + label: label, + value: AppLocalization.string(isMuted ? .audioMuted : .audioNotMuted), + isEnabled: canToggle ) case .readOnly(let isMuted): return AudioAccessibilityContract( @@ -731,28 +1010,41 @@ struct AudioProcessRowPresentation: Identifiable { var rowAccessibility: AudioAccessibilityContract { AudioAccessibilityContract(identifier: accessibilityPrefix, label: snapshot.process.name) } + var effectiveVolume: AudioEffectiveVolumeState { + AudioEffectiveVolumeState(rawVolume: snapshot.volume, isMuted: snapshot.isMuted) + } + var showsMutedIcon: Bool { effectiveVolume.showsMutedIcon } + var canToggleMute: Bool { showsMutedIcon ? effectiveVolume.canRestore : true } var volumeAccessibility: AudioAccessibilityContract { - AudioAccessibilityContract( + let displayVolume = effectiveVolume.displayVolume + return AudioAccessibilityContract( identifier: "\(accessibilityPrefix).volume.slider", label: audioTargetLabel( AppLocalization.string( .audioVolumeAccessibility, snapshot.process.name, - Int((snapshot.volume * 100).rounded()) + Int((displayVolume * 100).rounded()) ), target: snapshot.process.name ), - value: snapshot.volume.formatted(.percent.precision(.fractionLength(0))) + value: displayVolume.formatted(.percent.precision(.fractionLength(0))) ) } var muteAccessibility: AudioAccessibilityContract { - AudioAccessibilityContract( - identifier: "\(accessibilityPrefix).mute", - label: audioTargetLabel( + let label = showsMutedIcon && !canToggleMute + ? audioTargetLabel( + AppLocalization.string(.audioMuteRestoreUnavailable), + target: snapshot.process.name + ) + : audioTargetLabel( AppLocalization.string( - snapshot.isMuted ? .audioUnmuteAccessibility : .audioMuteAccessibility, + showsMutedIcon ? .audioUnmuteAccessibility : .audioMuteAccessibility, snapshot.process.name ), target: snapshot.process.name - ), - value: AppLocalization.string(snapshot.isMuted ? .audioMuted : .audioNotMuted) + ) + return AudioAccessibilityContract( + identifier: "\(accessibilityPrefix).mute", + label: label, + value: AppLocalization.string(showsMutedIcon ? .audioMuted : .audioNotMuted), + isEnabled: canToggleMute ) } var routeAccessibility: AudioAccessibilityContract { diff --git a/Tests/MacActivityAppTests/AudioControlCoordinatorTests.swift b/Tests/MacActivityAppTests/AudioControlCoordinatorTests.swift index c73053d..39f68a2 100644 --- a/Tests/MacActivityAppTests/AudioControlCoordinatorTests.swift +++ b/Tests/MacActivityAppTests/AudioControlCoordinatorTests.swift @@ -694,6 +694,69 @@ final class AudioControlCoordinatorTests: XCTestCase { XCTAssertEqual(fixture.coordinator.snapshot.devices[0].error, .deviceWrite) } + func testRapidDeviceIntentsCommitOnlyLatestEffectiveState() async { + let delay = ControlledAudioDelay() + let fixture = CoordinatorFixture(availability: .supported, delay: delay.callAsFunction) + await fixture.coordinator.start() + fixture.deviceProvider.confirmedMute = true + fixture.coordinator.setDeviceVolume(0.2, for: "BuiltIn") + await delay.waitUntilCallCount(1) + fixture.coordinator.setDeviceVolume(0, for: "BuiltIn") + await delay.resumeAll() + await fixture.coordinator.testingWaitForDeviceControl("BuiltIn") + await fixture.coordinator.testingWaitUntilIdle() + XCTAssertEqual(fixture.deviceProvider.writes, [.mute(true)]) + } + + func testProcessSliderZeroMutesWithoutDiscardingRestoreVolume() async { + let fixture = CoordinatorFixture(availability: .supported) + await fixture.coordinator.start() + fixture.coordinator.setProcessVolume(0.4, for: 11) + await fixture.coordinator.testingWaitUntilIdle() + fixture.coordinator.setProcessVolume(0, for: 11) + await fixture.coordinator.testingWaitUntilIdle() + + let row = fixture.coordinator.snapshot.processes[0] + XCTAssertEqual(row.volume, 0.4) + XCTAssertTrue(row.isMuted) + XCTAssertEqual(fixture.engine.gainUpdateCalls.last?.gain, .init(volume: 0.4, isMuted: true)) + let profile = fixture.store.savedPreferences.audioProcessProfiles["com.example.music"] + XCTAssertEqual(profile?.volume, 0.4) + XCTAssertEqual(profile?.isMuted, true) + } + + func testProcessPositiveSliderValueSetsVolumeAndUnmutesTogether() async { + let fixture = CoordinatorFixture(availability: .supported) + await fixture.coordinator.start() + fixture.coordinator.setProcessMuted(true, for: 11) + await fixture.coordinator.testingWaitUntilIdle() + fixture.coordinator.setProcessVolume(0.3, for: 11) + await fixture.coordinator.testingWaitUntilIdle() + + let row = fixture.coordinator.snapshot.processes[0] + XCTAssertEqual(row.volume, 0.3) + XCTAssertFalse(row.isMuted) + XCTAssertEqual(fixture.engine.gainUpdateCalls.last?.gain, .init(volume: 0.3, isMuted: false)) + } + + func testProcessCannotUnmuteAnInitiallyZeroProfile() async { + let fixture = CoordinatorFixture( + availability: .supported, + savedProfiles: ["com.example.music": .init( + bundleIdentifier: "com.example.music", volume: 0, isMuted: false + )] + ) + await fixture.coordinator.start() + await fixture.coordinator.testingWaitUntilIdle() + let gainUpdateCount = fixture.engine.gainUpdateCalls.count + let saveCount = fixture.store.saveCount + fixture.coordinator.setProcessMuted(false, for: 11) + await fixture.coordinator.testingWaitUntilIdle() + XCTAssertEqual(fixture.coordinator.snapshot.processes[0].volume, 0) + XCTAssertEqual(fixture.engine.gainUpdateCalls.count, gainUpdateCount) + XCTAssertEqual(fixture.store.saveCount, saveCount) + } + func testBundlelessProcessIntentIsSessionOnly() async { let fixture = CoordinatorFixture( availability: .supported, @@ -778,6 +841,28 @@ final class AudioControlCoordinatorTests: XCTestCase { XCTAssertNil(fixture.coordinator.snapshot.processes[0].pendingValues) } + func testPermissionFailureCanRetrySameValueThroughControl() async { + let fixture = CoordinatorFixture(availability: .supported) + await fixture.coordinator.start() + fixture.engine.nextError = .permissionDenied(-1) + + fixture.coordinator.setProcessVolume(0.4, for: 11) + await fixture.coordinator.testingWaitUntilIdle() + + XCTAssertEqual(fixture.coordinator.snapshot.processes[0].volume, 1) + XCTAssertEqual(fixture.coordinator.snapshot.processes[0].pendingValues?.volume, 0.4) + XCTAssertEqual(fixture.coordinator.snapshot.processes[0].error, .permissionDenied) + + fixture.engine.nextError = nil + fixture.coordinator.setProcessVolume(0.4, for: 11) + await fixture.coordinator.testingWaitUntilIdle() + + let row = fixture.coordinator.snapshot.processes[0] + XCTAssertEqual(row.volume, 0.4) + XCTAssertNil(row.pendingValues) + XCTAssertNil(row.error) + } + func testResetStopsNonDefaultSessionWithoutApplyingDefaultProfile() async { let fixture = CoordinatorFixture(availability: .supported) await fixture.coordinator.start() @@ -2042,6 +2127,628 @@ final class AudioControlCoordinatorTests: XCTestCase { XCTAssertEqual(fixture.coordinator.snapshot.devices[0].error, .deviceWrite) } + func testReadOnlyVolumeWritableMuteWritesOnlyHardwareMuteInBothDirections() async { + let fixture = CoordinatorFixture(availability: .supported) + fixture.deviceProvider.outputSnapshots = [AudioOutputDeviceSnapshot( + id: "BuiltIn", + objectID: 10, + name: "Speakers", + volume: .value(0.5, isWritable: false), + mute: .value(false, isWritable: true) + )] + await fixture.coordinator.start() + + fixture.coordinator.setDeviceVolume(0.8, for: "BuiltIn") + await fixture.coordinator.testingWaitUntilIdle() + XCTAssertEqual(fixture.deviceProvider.writes, []) + + fixture.deviceProvider.confirmedMute = true + fixture.coordinator.setDeviceMuted(true, for: "BuiltIn") + await fixture.coordinator.testingWaitUntilIdle() + fixture.deviceProvider.confirmedMute = false + fixture.coordinator.setDeviceMuted(false, for: "BuiltIn") + await fixture.coordinator.testingWaitUntilIdle() + + XCTAssertEqual(fixture.deviceProvider.writes, [.mute(true), .mute(false)]) + let device = fixture.coordinator.snapshot.devices[0].device + XCTAssertEqual(device.volume, .value(0.5, isWritable: false)) + XCTAssertEqual(device.mute, .value(false, isWritable: true)) + } + + func testQueuedMixedMuteStopsWhenLatestRefreshMakesMuteNonWritable() async { + let delay = ControlledAudioDelay() + let fixture = CoordinatorFixture(availability: .supported, delay: delay.callAsFunction) + await fixture.coordinator.start() + fixture.coordinator.setDeviceVolume(0.8, for: "BuiltIn") + await delay.waitUntilCallCount(1) + + fixture.deviceProvider.outputSnapshots = [AudioOutputDeviceSnapshot( + id: "BuiltIn", + objectID: 10, + name: "Speakers", + volume: .value(0.61, isWritable: false), + mute: .value(false, isWritable: true) + )] + fixture.coordinator.retryDevice("BuiltIn") + fixture.coordinator.setDeviceMuted(true, for: "BuiltIn") + + let refreshed = AudioOutputDeviceSnapshot( + id: "BuiltIn", + objectID: 10, + name: "Speakers", + volume: .value(0.42, isWritable: false), + mute: .value(false, isWritable: false) + ) + fixture.deviceProvider.outputSnapshots = [refreshed] + fixture.coordinator.retryDevice("BuiltIn") + await delay.resumeAll() + await fixture.coordinator.testingWaitForDeviceControl("BuiltIn") + await fixture.coordinator.testingWaitUntilIdle() + + XCTAssertEqual(fixture.deviceProvider.writes, []) + XCTAssertEqual(fixture.coordinator.snapshot.devices[0].device, refreshed) + } + + func testDeviceCapabilityMatrixOnlyWritesSupportedHardwareIntents() async { + let failure = AudioHALError( + operation: .getData, + objectID: 10, + address: nil, + reason: .status(-1) + ) + let cases: [( + name: String, + volume: AudioPropertyValue, + mute: AudioPropertyValue, + expectedWrites: [DeviceProviderFake.Write] + )] = [ + ( + "fully writable", + .value(0.5, isWritable: true), + .value(false, isWritable: true), + [.volume(0.8), .mute(true)] + ), + ( + "authorized mute only", + .value(0.5, isWritable: false), + .value(false, isWritable: true), + [.mute(true)] + ), + ( + "writable volume read only mute", + .value(0.5, isWritable: true), + .value(false, isWritable: false), + [] + ), + ( + "writable volume unsupported mute", + .value(0.5, isWritable: true), + .unsupported, + [] + ), + ( + "unsupported volume writable mute", + .unsupported, + .value(false, isWritable: true), + [] + ), + ( + "unavailable volume writable mute", + .unavailable, + .value(false, isWritable: true), + [] + ), + ( + "failed volume writable mute", + .failed(failure), + .value(false, isWritable: true), + [] + ), + ] + + for entry in cases { + let fixture = CoordinatorFixture(availability: .supported) + fixture.deviceProvider.outputSnapshots = [AudioOutputDeviceSnapshot( + id: "BuiltIn", + objectID: 10, + name: "Speakers", + volume: entry.volume, + mute: entry.mute + )] + fixture.deviceProvider.confirmedMute = true + await fixture.coordinator.start() + + fixture.coordinator.setDeviceVolume(0.8, for: "BuiltIn") + await fixture.coordinator.testingWaitUntilIdle() + fixture.coordinator.setDeviceMuted(true, for: "BuiltIn") + await fixture.coordinator.testingWaitUntilIdle() + + XCTAssertEqual(fixture.deviceProvider.writes, entry.expectedWrites, entry.name) + } + } + + func testQueuedMixedMuteDoesNotWriteAfterFullRefreshRemovesDevice() async { + let delay = ControlledAudioDelay() + let fixture = CoordinatorFixture(availability: .supported, delay: delay.callAsFunction) + await fixture.coordinator.start() + fixture.coordinator.setDeviceVolume(0.8, for: "BuiltIn") + await delay.waitUntilCallCount(1) + + fixture.deviceProvider.outputSnapshots = [AudioOutputDeviceSnapshot( + id: "BuiltIn", + objectID: 10, + name: "Speakers", + volume: .value(0.61, isWritable: false), + mute: .value(false, isWritable: true) + )] + fixture.coordinator.retryDevice("BuiltIn") + fixture.coordinator.setDeviceMuted(true, for: "BuiltIn") + + fixture.deviceProvider.outputSnapshots = [] + await fixture.emit([.deviceList]) + XCTAssertTrue(fixture.coordinator.snapshot.devices.isEmpty) + await delay.resumeAll() + await fixture.coordinator.testingWaitForDeviceControl("BuiltIn") + await fixture.coordinator.testingWaitUntilIdle() + + XCTAssertEqual(fixture.deviceProvider.writes, []) + XCTAssertTrue(fixture.coordinator.snapshot.devices.isEmpty) + } + + func testPendingDeviceIntentDoesNotCrossRemovedAndReaddedUIDLifetime() async { + let delay = ControlledAudioDelay() + let fixture = CoordinatorFixture(availability: .supported, delay: delay.callAsFunction) + await fixture.coordinator.start() + fixture.coordinator.setDeviceVolume(0.8, for: "BuiltIn") + await delay.waitUntilCallCount(1) + + fixture.deviceProvider.outputSnapshots = [] + await fixture.emit([.deviceList]) + let replacement = AudioOutputDeviceSnapshot( + id: "BuiltIn", + objectID: 20, + name: "Replacement", + volume: .value(0.31, isWritable: true), + mute: .value(true, isWritable: true) + ) + fixture.deviceProvider.outputSnapshots = [replacement] + await fixture.emit([.deviceList]) + await delay.resumeAll() + await fixture.coordinator.testingWaitForDeviceControl("BuiltIn") + await fixture.coordinator.testingWaitUntilIdle() + + XCTAssertEqual(fixture.deviceProvider.writes, []) + XCTAssertEqual(fixture.coordinator.snapshot.devices[0].device, replacement) + } + + func testPendingDeviceIntentIsRevokedWhenDeviceEnumerationFails() async { + let delay = ControlledAudioDelay() + let fixture = CoordinatorFixture(availability: .supported, delay: delay.callAsFunction) + await fixture.coordinator.start() + fixture.coordinator.setDeviceVolume(0.8, for: "BuiltIn") + await delay.waitUntilCallCount(1) + + fixture.deviceProvider.outputSnapshotsError = FixtureError.writeFailed + await fixture.emit([.deviceList]) + XCTAssertTrue(fixture.coordinator.snapshot.devices.isEmpty) + await delay.resumeAll() + await fixture.coordinator.testingWaitForDeviceControl("BuiltIn") + await fixture.coordinator.testingWaitUntilIdle() + + XCTAssertEqual(fixture.deviceProvider.writes, []) + XCTAssertTrue(fixture.coordinator.snapshot.devices.isEmpty) + } + + func testPendingDeviceIntentIsRevokedByServiceRestart() async { + let delay = ControlledAudioDelay() + let fixture = CoordinatorFixture(availability: .supported, delay: delay.callAsFunction) + await fixture.coordinator.start() + fixture.coordinator.setDeviceVolume(0.8, for: "BuiltIn") + await delay.waitUntilCallCount(1) + + let restarted = AudioOutputDeviceSnapshot( + id: "BuiltIn", + objectID: 10, + name: "Speakers After Restart", + volume: .value(0.27, isWritable: true), + mute: .value(false, isWritable: true) + ) + fixture.deviceProvider.outputSnapshots = [restarted] + await fixture.emit([.serviceRestarted]) + await delay.resumeAll() + await fixture.coordinator.testingWaitForDeviceControl("BuiltIn") + await fixture.coordinator.testingWaitUntilIdle() + + XCTAssertEqual(fixture.deviceProvider.writes, []) + XCTAssertEqual(fixture.coordinator.snapshot.devices[0].device, restarted) + } + + func testDeviceSliderZeroMutesWithoutWritingRawVolumeZero() async { + let fixture = CoordinatorFixture(availability: .supported) + fixture.deviceProvider.confirmedMute = true + await fixture.coordinator.start() + fixture.coordinator.setDeviceVolume(0, for: "BuiltIn") + await fixture.coordinator.testingWaitUntilIdle() + XCTAssertEqual(fixture.deviceProvider.writes, [.mute(true)]) + XCTAssertEqual(fixture.coordinator.snapshot.devices[0].device.volume.value, 0.5) + } + + func testDevicePositiveValueWhileMutedWritesVolumeBeforeUnmute() async { + let fixture = CoordinatorFixture(availability: .supported) + fixture.deviceProvider.snapshotMute = true + fixture.deviceProvider.confirmedVolume = 0.3 + fixture.deviceProvider.confirmedMute = false + await fixture.coordinator.start() + fixture.coordinator.setDeviceVolume(0.3, for: "BuiltIn") + await fixture.coordinator.testingWaitUntilIdle() + XCTAssertEqual(fixture.deviceProvider.writes, [.volume(0.3), .mute(false)]) + } + + func testDeviceUnmuteFailureKeepsNewConfirmedVolumeMuted() async { + let fixture = CoordinatorFixture(availability: .supported) + fixture.deviceProvider.snapshotMute = true + fixture.deviceProvider.confirmedVolume = 0.3 + fixture.deviceProvider.muteWriteError = FixtureError.writeFailed + await fixture.coordinator.start() + fixture.coordinator.setDeviceVolume(0.3, for: "BuiltIn") + await fixture.coordinator.testingWaitUntilIdle() + let row = fixture.coordinator.snapshot.devices[0] + XCTAssertEqual(fixture.deviceProvider.writes, [.volume(0.3), .mute(false)]) + XCTAssertEqual(row.device.volume.value, 0.3) + XCTAssertEqual(row.device.mute.value, true) + XCTAssertEqual(row.error, .deviceWrite) + + fixture.deviceProvider.muteWriteError = nil + fixture.coordinator.setDeviceMuted(false, for: "BuiltIn") + await fixture.coordinator.testingWaitUntilIdle() + XCTAssertEqual( + fixture.deviceProvider.writes, + [.volume(0.3), .mute(false), .mute(false)] + ) + XCTAssertEqual(fixture.coordinator.snapshot.devices[0].device.volume.value, 0.3) + XCTAssertEqual(fixture.coordinator.snapshot.devices[0].device.mute.value, false) + XCTAssertNil(fixture.coordinator.snapshot.devices[0].error) + } + + func testDeviceRefreshDuringDebounceStopsIntentWhenDeviceIsNoLongerFullyWritable() async { + let delay = ControlledAudioDelay() + let fixture = CoordinatorFixture(availability: .supported, delay: delay.callAsFunction) + await fixture.coordinator.start() + fixture.coordinator.setDeviceVolume(0.8, for: "BuiltIn") + await delay.waitUntilCallCount(1) + + let refreshed = AudioOutputDeviceSnapshot( + id: "BuiltIn", + objectID: 10, + name: "Speakers", + volume: .value(0.61, isWritable: true), + mute: .value(true, isWritable: false) + ) + fixture.deviceProvider.outputSnapshots = [refreshed] + fixture.coordinator.retryDevice("BuiltIn") + await delay.resumeAll() + await fixture.coordinator.testingWaitForDeviceControl("BuiltIn") + await fixture.coordinator.testingWaitUntilIdle() + + XCTAssertEqual(fixture.deviceProvider.writes, []) + XCTAssertEqual(fixture.coordinator.snapshot.devices[0].device, refreshed) + } + + func testDeviceVolumeReadbackMergesIntoLatestRefreshSnapshot() async { + let delay = ControlledAudioDelay() + let fixture = CoordinatorFixture(availability: .supported, delay: delay.callAsFunction) + fixture.deviceProvider.snapshotMute = true + fixture.deviceProvider.confirmedVolume = 0.73 + await fixture.coordinator.start() + + let refreshed = AudioOutputDeviceSnapshot( + id: "BuiltIn", + objectID: 10, + name: "Speakers", + volume: .value(0.61, isWritable: true), + mute: .value(false, isWritable: true) + ) + fixture.deviceProvider.outputSnapshots = [refreshed] + fixture.deviceProvider.onVolumeWrite = { + fixture.coordinator.retryDevice("BuiltIn") + } + fixture.coordinator.setDeviceVolume(0.8, for: "BuiltIn") + await delay.waitUntilCallCount(1) + await delay.resumeAll() + await fixture.coordinator.testingWaitUntilIdle() + + XCTAssertEqual(fixture.deviceProvider.writes, [.volume(0.8)]) + XCTAssertEqual(fixture.coordinator.snapshot.devices[0].device.volume.value, 0.73) + XCTAssertEqual(fixture.coordinator.snapshot.devices[0].device.mute.value, false) + } + + func testDeviceMuteReadbackMergesIntoLatestRefreshSnapshot() async { + let fixture = CoordinatorFixture(availability: .supported) + fixture.deviceProvider.confirmedMute = true + await fixture.coordinator.start() + + let refreshed = AudioOutputDeviceSnapshot( + id: "BuiltIn", + objectID: 10, + name: "Speakers", + volume: .value(0.61, isWritable: true), + mute: .value(false, isWritable: true) + ) + fixture.deviceProvider.outputSnapshots = [refreshed] + fixture.deviceProvider.onMuteWrite = { + fixture.coordinator.retryDevice("BuiltIn") + } + fixture.coordinator.setDeviceMuted(true, for: "BuiltIn") + await fixture.coordinator.testingWaitUntilIdle() + + XCTAssertEqual(fixture.deviceProvider.writes, [.mute(true)]) + XCTAssertEqual(fixture.coordinator.snapshot.devices[0].device.volume.value, 0.61) + XCTAssertEqual(fixture.coordinator.snapshot.devices[0].device.mute.value, true) + } + + func testDeviceVolumeReadbackSurvivesLatestNonWritableMuteRefresh() async { + let delay = ControlledAudioDelay() + let fixture = CoordinatorFixture(availability: .supported, delay: delay.callAsFunction) + fixture.deviceProvider.snapshotMute = true + fixture.deviceProvider.confirmedVolume = 0.73 + await fixture.coordinator.start() + + fixture.deviceProvider.outputSnapshots = [AudioOutputDeviceSnapshot( + id: "BuiltIn", + objectID: 10, + name: "Speakers", + volume: .value(0.61, isWritable: true), + mute: .value(true, isWritable: false) + )] + fixture.deviceProvider.onVolumeWrite = { + fixture.coordinator.retryDevice("BuiltIn") + } + fixture.coordinator.setDeviceVolume(0.8, for: "BuiltIn") + await delay.waitUntilCallCount(1) + await delay.resumeAll() + await fixture.coordinator.testingWaitUntilIdle() + + let device = fixture.coordinator.snapshot.devices[0].device + XCTAssertEqual(fixture.deviceProvider.writes, [.volume(0.8)]) + XCTAssertEqual(device.volume, .value(0.73, isWritable: true)) + XCTAssertEqual(device.mute, .value(true, isWritable: false)) + } + + func testSuccessfulVolumeReadbackReplacesUnavailableVolumeAndRetainsLatestMuteFailure() async { + let fixture = CoordinatorFixture(availability: .supported) + fixture.deviceProvider.confirmedVolume = 0.73 + await fixture.coordinator.start() + let muteFailure = AudioHALError( + operation: .getData, + objectID: 10, + address: nil, + reason: .status(-1) + ) + fixture.deviceProvider.outputSnapshots = [AudioOutputDeviceSnapshot( + id: "BuiltIn", + objectID: 10, + name: "Speakers", + volume: .unavailable, + mute: .failed(muteFailure) + )] + fixture.deviceProvider.onVolumeWrite = { + fixture.coordinator.retryDevice("BuiltIn") + } + + fixture.coordinator.setDeviceVolume(0.8, for: "BuiltIn") + await fixture.coordinator.testingWaitUntilIdle() + + let row = fixture.coordinator.snapshot.devices[0] + XCTAssertEqual(row.device.volume, .value(0.73, isWritable: true)) + XCTAssertEqual(row.device.mute, .failed(muteFailure)) + XCTAssertEqual(row.error, .deviceRead(muteFailure)) + } + + func testSuccessfulVolumeReadbackPreservesLatestNonWritableVolumeCapability() async { + let fixture = CoordinatorFixture(availability: .supported) + fixture.deviceProvider.confirmedVolume = 0.73 + await fixture.coordinator.start() + fixture.deviceProvider.outputSnapshots = [AudioOutputDeviceSnapshot( + id: "BuiltIn", + objectID: 10, + name: "Speakers", + volume: .value(0.61, isWritable: false), + mute: .value(false, isWritable: true) + )] + fixture.deviceProvider.onVolumeWrite = { + fixture.coordinator.retryDevice("BuiltIn") + } + + fixture.coordinator.setDeviceVolume(0.8, for: "BuiltIn") + await fixture.coordinator.testingWaitUntilIdle() + var device = fixture.coordinator.snapshot.devices[0].device + XCTAssertEqual(device.volume, .value(0.73, isWritable: false)) + XCTAssertEqual(device.mute, .value(false, isWritable: true)) + + fixture.coordinator.setDeviceVolume(0.9, for: "BuiltIn") + await fixture.coordinator.testingWaitUntilIdle() + device = fixture.coordinator.snapshot.devices[0].device + XCTAssertEqual(fixture.deviceProvider.writes, [.volume(0.8)]) + XCTAssertEqual(device.volume, .value(0.73, isWritable: false)) + } + + func testSuccessfulEffectiveMuteReadbackReplacesUnavailableMuteAndRetainsLatestVolumeFailure() async { + let fixture = CoordinatorFixture(availability: .supported) + fixture.deviceProvider.confirmedMute = true + await fixture.coordinator.start() + let volumeFailure = AudioHALError( + operation: .getData, + objectID: 10, + address: nil, + reason: .status(-2) + ) + fixture.deviceProvider.outputSnapshots = [AudioOutputDeviceSnapshot( + id: "BuiltIn", + objectID: 10, + name: "Speakers", + volume: .failed(volumeFailure), + mute: .unavailable + )] + fixture.deviceProvider.onMuteWrite = { + fixture.coordinator.retryDevice("BuiltIn") + } + + fixture.coordinator.setDeviceMuted(true, for: "BuiltIn") + await fixture.coordinator.testingWaitUntilIdle() + + let row = fixture.coordinator.snapshot.devices[0] + XCTAssertEqual(row.device.volume, .failed(volumeFailure)) + XCTAssertEqual(row.device.mute, .value(true, isWritable: true)) + XCTAssertEqual(row.error, .deviceRead(volumeFailure)) + } + + func testSuccessfulEffectiveMuteReadbackPreservesLatestNonWritableMuteCapability() async { + let fixture = CoordinatorFixture(availability: .supported) + fixture.deviceProvider.confirmedMute = true + await fixture.coordinator.start() + fixture.deviceProvider.outputSnapshots = [AudioOutputDeviceSnapshot( + id: "BuiltIn", + objectID: 10, + name: "Speakers", + volume: .value(0.44, isWritable: true), + mute: .value(false, isWritable: false) + )] + fixture.deviceProvider.onMuteWrite = { + fixture.coordinator.retryDevice("BuiltIn") + } + + fixture.coordinator.setDeviceMuted(true, for: "BuiltIn") + await fixture.coordinator.testingWaitUntilIdle() + var device = fixture.coordinator.snapshot.devices[0].device + XCTAssertEqual(device.volume, .value(0.44, isWritable: true)) + XCTAssertEqual(device.mute, .value(true, isWritable: false)) + + fixture.coordinator.setDeviceMuted(false, for: "BuiltIn") + await fixture.coordinator.testingWaitUntilIdle() + device = fixture.coordinator.snapshot.devices[0].device + XCTAssertEqual(fixture.deviceProvider.writes, [.mute(true)]) + XCTAssertEqual(device.mute, .value(true, isWritable: false)) + } + + func testSuccessfulMuteOnlyReadbackReplacesFailedMute() async { + let fixture = CoordinatorFixture(availability: .supported) + fixture.deviceProvider.outputSnapshots = [AudioOutputDeviceSnapshot( + id: "BuiltIn", + objectID: 10, + name: "Speakers", + volume: .value(0.61, isWritable: false), + mute: .value(false, isWritable: true) + )] + fixture.deviceProvider.confirmedMute = true + await fixture.coordinator.start() + let muteFailure = AudioHALError( + operation: .getData, + objectID: 10, + address: nil, + reason: .status(-3) + ) + fixture.deviceProvider.outputSnapshots = [AudioOutputDeviceSnapshot( + id: "BuiltIn", + objectID: 10, + name: "Speakers", + volume: .value(0.42, isWritable: false), + mute: .failed(muteFailure) + )] + fixture.deviceProvider.onMuteWrite = { + fixture.coordinator.retryDevice("BuiltIn") + } + + fixture.coordinator.setDeviceMuted(true, for: "BuiltIn") + await fixture.coordinator.testingWaitUntilIdle() + + let row = fixture.coordinator.snapshot.devices[0] + XCTAssertEqual(row.device.volume, .value(0.42, isWritable: false)) + XCTAssertEqual(row.device.mute, .value(true, isWritable: true)) + XCTAssertNil(row.error) + } + + func testSuccessfulMuteOnlyReadbackPreservesLatestNonWritableMuteCapability() async { + let fixture = CoordinatorFixture(availability: .supported) + fixture.deviceProvider.outputSnapshots = [AudioOutputDeviceSnapshot( + id: "BuiltIn", + objectID: 10, + name: "Speakers", + volume: .value(0.61, isWritable: false), + mute: .value(false, isWritable: true) + )] + fixture.deviceProvider.confirmedMute = true + await fixture.coordinator.start() + fixture.deviceProvider.outputSnapshots = [AudioOutputDeviceSnapshot( + id: "BuiltIn", + objectID: 10, + name: "Speakers", + volume: .value(0.42, isWritable: false), + mute: .value(false, isWritable: false) + )] + fixture.deviceProvider.onMuteWrite = { + fixture.coordinator.retryDevice("BuiltIn") + } + + fixture.coordinator.setDeviceMuted(true, for: "BuiltIn") + await fixture.coordinator.testingWaitUntilIdle() + var device = fixture.coordinator.snapshot.devices[0].device + XCTAssertEqual(device.volume, .value(0.42, isWritable: false)) + XCTAssertEqual(device.mute, .value(true, isWritable: false)) + + fixture.coordinator.setDeviceMuted(false, for: "BuiltIn") + await fixture.coordinator.testingWaitUntilIdle() + device = fixture.coordinator.snapshot.devices[0].device + XCTAssertEqual(fixture.deviceProvider.writes, [.mute(true)]) + XCTAssertEqual(device.mute, .value(true, isWritable: false)) + } + + func testSuccessfulReadbackDoesNotMergeIntoReplacementDeviceLifetime() async { + let fixture = CoordinatorFixture(availability: .supported) + fixture.deviceProvider.confirmedVolume = 0.73 + await fixture.coordinator.start() + let replacement = AudioOutputDeviceSnapshot( + id: "BuiltIn", + objectID: 20, + name: "Replacement", + volume: .value(0.21, isWritable: true), + mute: .value(true, isWritable: true) + ) + fixture.deviceProvider.outputSnapshots = [replacement] + fixture.deviceProvider.onVolumeWrite = { + fixture.coordinator.retryDevice("BuiltIn") + } + + fixture.coordinator.setDeviceVolume(0.8, for: "BuiltIn") + await fixture.coordinator.testingWaitUntilIdle() + + XCTAssertEqual(fixture.deviceProvider.writes, [.volume(0.8)]) + XCTAssertEqual(fixture.coordinator.snapshot.devices[0].device, replacement) + } + + func testDeviceMuteReadbackSurvivesLatestNonWritableVolumeRefresh() async { + let fixture = CoordinatorFixture(availability: .supported) + fixture.deviceProvider.confirmedMute = true + await fixture.coordinator.start() + + fixture.deviceProvider.outputSnapshots = [AudioOutputDeviceSnapshot( + id: "BuiltIn", + objectID: 10, + name: "Speakers", + volume: .value(0.61, isWritable: false), + mute: .value(false, isWritable: true) + )] + fixture.deviceProvider.onMuteWrite = { + fixture.coordinator.retryDevice("BuiltIn") + } + fixture.coordinator.setDeviceMuted(true, for: "BuiltIn") + await fixture.coordinator.testingWaitUntilIdle() + + let device = fixture.coordinator.snapshot.devices[0].device + XCTAssertEqual(fixture.deviceProvider.writes, [.mute(true)]) + XCTAssertEqual(device.volume, .value(0.61, isWritable: false)) + XCTAssertEqual(device.mute, .value(true, isWritable: true)) + } + func testDeviceVolumeUsesHardwareConfirmedValueAndRetryRefreshesOneRow() async { let fixture = CoordinatorFixture(availability: .supported) await fixture.coordinator.start() @@ -2060,7 +2767,7 @@ final class AudioControlCoordinatorTests: XCTestCase { XCTAssertEqual(fixture.processProvider.callCount, 1) } - func testDeviceVolumeAndMuteUseIndependentTasksAndMergeLatestConfirmation() async { + func testDeviceVolumeAndMuteShareTaskAndMergeLatestConfirmation() async { let delay = ControlledAudioDelay() let fixture = CoordinatorFixture(availability: .supported, delay: delay.callAsFunction) await fixture.coordinator.start() @@ -2070,8 +2777,8 @@ final class AudioControlCoordinatorTests: XCTestCase { fixture.coordinator.setDeviceVolume(0.8, for: "BuiltIn") await delay.waitUntilCallCount(1) fixture.coordinator.setDeviceMuted(true, for: "BuiltIn") - await fixture.coordinator.testingWaitForDeviceMute("BuiltIn") await delay.resumeAll() + await fixture.coordinator.testingWaitForDeviceControl("BuiltIn") await fixture.coordinator.testingWaitUntilIdle() XCTAssertEqual(fixture.deviceProvider.volumeWrites, [0.8]) diff --git a/Tests/MacActivityAppTests/AudioDashboardViewTests.swift b/Tests/MacActivityAppTests/AudioDashboardViewTests.swift index 0b4dc1f..0f85be6 100644 --- a/Tests/MacActivityAppTests/AudioDashboardViewTests.swift +++ b/Tests/MacActivityAppTests/AudioDashboardViewTests.swift @@ -8,6 +8,156 @@ import XCTest @MainActor final class AudioDashboardViewTests: XCTestCase { + func testEffectiveVolumeStatePreservesRestoreVolumeAcrossMuteAndUnmute() throws { + let audible = AudioEffectiveVolumeState(rawVolume: 0.6, isMuted: false) + let muted = audible.settingDisplayVolume(0) + XCTAssertEqual(muted.rawVolume, 0.6) + XCTAssertEqual(muted.displayVolume, 0) + XCTAssertTrue(muted.showsMutedIcon) + XCTAssertTrue(muted.canRestore) + XCTAssertEqual(try XCTUnwrap(muted.settingMuted(false)), audible) + } + + func testEffectiveVolumeStateCannotInventRestoreForInitialZero() { + let zero = AudioEffectiveVolumeState(rawVolume: 0, isMuted: false) + XCTAssertEqual(zero.displayVolume, 0) + XCTAssertTrue(zero.showsMutedIcon) + XCTAssertFalse(zero.canRestore) + XCTAssertNil(zero.settingMuted(false)) + XCTAssertEqual( + zero.settingDisplayVolume(0.3), + AudioEffectiveVolumeState(rawVolume: 0.3, isMuted: false) + ) + } + + func testEffectiveVolumeStateClampsDisplayInput() { + let state = AudioEffectiveVolumeState(rawVolume: 0.4, isMuted: false) + XCTAssertEqual(state.settingDisplayVolume(-1).displayVolume, 0) + XCTAssertEqual(state.settingDisplayVolume(2).displayVolume, 1) + XCTAssertEqual(state.settingDisplayVolume(.nan).displayVolume, 1) + } + + func testMuteGlyphPresentationCrossfadesTheSystemIconsAtTheSpecifiedDurations() { + let audible = AudioMuteGlyphPresentation( + isMuted: false, reduceMotion: false, motion: .external + ) + let muted = AudioMuteGlyphPresentation( + isMuted: true, reduceMotion: false, motion: .rollback + ) + let reduced = AudioMuteGlyphPresentation( + isMuted: true, reduceMotion: true, motion: .mute + ) + + XCTAssertEqual(audible.waveOpacity, 1) + XCTAssertEqual(audible.mutedOpacity, 0) + XCTAssertEqual(audible.crossfadeDuration, 0.14) + + XCTAssertEqual(muted.waveOpacity, 0) + XCTAssertEqual(muted.mutedOpacity, 1) + XCTAssertEqual(muted.crossfadeDuration, 0.16) + + XCTAssertEqual(reduced.crossfadeDuration, 0.1) + } + + func testVolumeMotionPolicyMatchesTheDragToggleRollbackAndExternalTimingContract() { + XCTAssertNil(AudioVolumeMotionPolicy( + isEditing: true, reduceMotion: false, motion: .mute + ).duration) + XCTAssertNil(AudioVolumeMotionPolicy( + isEditing: false, reduceMotion: true, motion: .restore + ).duration) + + let mute = AudioVolumeMotionPolicy(isEditing: false, reduceMotion: false, motion: .mute) + XCTAssertEqual(mute.duration, 0.2) + XCTAssertTrue(mute.usesSpring) + + let restore = AudioVolumeMotionPolicy( + isEditing: false, reduceMotion: false, motion: .restore + ) + XCTAssertEqual(restore.duration, 0.22) + XCTAssertTrue(restore.usesSpring) + + let rollback = AudioVolumeMotionPolicy( + isEditing: false, reduceMotion: false, motion: .rollback + ) + XCTAssertEqual(rollback.duration, 0.16) + XCTAssertFalse(rollback.usesSpring) + + let external = AudioVolumeMotionPolicy( + isEditing: false, reduceMotion: false, motion: .external + ) + XCTAssertEqual(external.duration, 0.14) + XCTAssertFalse(external.usesSpring) + } + + func testVolumeMotionSelectionPrioritizesWriteRollbackOverAnUnconsumedToggle() { + let trigger = AudioVolumeMotionTrigger(id: 3, motion: .mute) + + XCTAssertEqual( + AudioVolumeMotionSelection.resolve( + trigger: trigger, + consumedTriggerID: nil, + hasWriteFailure: true + ), + .rollback + ) + XCTAssertEqual( + AudioVolumeMotionSelection.resolve( + trigger: trigger, + consumedTriggerID: nil, + hasWriteFailure: false + ), + .mute + ) + XCTAssertEqual( + AudioVolumeMotionSelection.resolve( + trigger: trigger, + consumedTriggerID: trigger.id, + hasWriteFailure: false + ), + .external + ) + } + + func testAudioControlsUseSharedCrossfadeGlyphAndSourceAwareVolumeMotion() throws { + let source = try audioDashboardViewSource() + + for fragment in [ + "struct AudioMuteGlyph", + ".animation(.easeInOut(duration: presentation.crossfadeDuration), value: isMuted)", + "@Environment(\\.accessibilityReduceMotion)", + "struct AudioAnimatedVolumeSlider", + "struct AudioVolumeMotionTrigger", + "trigger: muteMotion", + "hasWriteFailure: snapshot.error != nil", + "AudioMuteButtonStyle" + ] { + XCTAssertTrue(source.contains(fragment), fragment) + } + XCTAssertEqual(source.components(separatedBy: "AudioMuteGlyph(").count - 1, 3) + XCTAssertEqual(source.components(separatedBy: "AudioAnimatedVolumeSlider(").count - 1, 2) + } + + func testDeviceVolumeControlReservesAndCentersTheSliderLaneForUnavailableText() throws { + let source = try audioDashboardViewSource() + + XCTAssertTrue(source.contains(".frame(width: 150, height: 20, alignment: .center)")) + XCTAssertFalse(source.contains(".frame(maxWidth: 150)")) + } + + func testAnimatedVolumeSliderUsesDisplayedValueForMuteTransitions() throws { + let source = try audioDashboardViewSource() + + for fragment in [ + "@State private var displayedValue: Double", + "AudioVolumeTrack(value: displayedValue)", + "Slider(value: $displayedValue, in: 0...1", + "withAnimation(motionPolicy.animation)" + ] { + XCTAssertTrue(source.contains(fragment), fragment) + } + } + func testRealViewWiresContractsWithoutAnAccessibilityManifest() throws { let source = try audioDashboardViewSource() @@ -15,9 +165,9 @@ final class AudioDashboardViewTests: XCTestCase { let requiredCalls: [(String, Int)] = [ ("accessibility: presentation.devicesAccessibility", 1), ("accessibility: processSection.accessibility", 1), - (".audioAccessibility(accessibility)", 2), + (".audioAccessibility(accessibility)", 3), (".audioAccessibility(processSection.emptyAccessibility)", 1), - (".audioAccessibility(presentation.volumeAccessibility)", 6), + (".audioAccessibility(presentation.volumeAccessibility)", 4), (".audioAccessibility(presentation.muteAccessibility)", 5), (".audioAccessibility(presentation.rowAccessibility)", 2), (".audioAccessibility(presentation.resetAccessibility)", 1), @@ -154,6 +304,243 @@ final class AudioDashboardViewTests: XCTestCase { XCTAssertTrue(coordinator.processMutes[0].1) } + func testMutedBindingsDisplayZeroAndPositiveInputForwardsOneValue() { + var snapshot = AudioControlSnapshot.fixture() + snapshot.devices[0].device = .fixtureDevice( + uid: "BuiltInOutput", volume: 0.5, mute: true + ) + snapshot.processes[0].volume = 0.4 + snapshot.processes[0].isMuted = true + let coordinator = AudioViewCoordinatorSpy(snapshot: snapshot) + let model = AudioDashboardModel(coordinator: coordinator) + let device = AudioDashboardControlBindings.deviceVolume( + model: model, deviceUID: "BuiltInOutput", fallback: 1 + ) + let process = AudioDashboardControlBindings.processVolume( + model: model, processObjectID: 11, fallback: 1 + ) + XCTAssertEqual(device.wrappedValue, 0) + XCTAssertEqual(process.wrappedValue, 0) + device.wrappedValue = 0.3 + process.wrappedValue = 0.2 + XCTAssertEqual(coordinator.deviceVolumes.last?.1, 0.3) + XCTAssertEqual(coordinator.processVolumes.last?.1, 0.2) + } + + func testInitialZeroRowsShowMutedAndDisableRestore() { + var snapshot = AudioControlSnapshot.fixture() + snapshot.devices[0].device = .fixtureDevice( + uid: "BuiltInOutput", volume: 0, mute: false + ) + snapshot.processes[0].volume = 0 + snapshot.processes[0].isMuted = false + let device = AudioDeviceRowPresentation(snapshot.devices[0]) + let process = AudioProcessRowPresentation(snapshot.processes[0]) + XCTAssertEqual(device.mute, .button(isMuted: true, canToggle: false)) + XCTAssertTrue(process.showsMutedIcon) + XCTAssertFalse(process.canToggleMute) + XCTAssertFalse(device.muteAccessibility.isEnabled) + XCTAssertFalse(process.muteAccessibility.isEnabled) + XCTAssertEqual(device.volumeAccessibility.value, "0%") + XCTAssertEqual(process.volumeAccessibility.value, "0%") + } + + func testInitialZeroMuteHelpersDoNotSendNoOpIntents() { + var snapshot = AudioControlSnapshot.fixture() + snapshot.devices[0].device = .fixtureDevice( + uid: "BuiltInOutput", volume: 0, mute: false + ) + snapshot.processes[0].volume = 0 + snapshot.processes[0].isMuted = false + let coordinator = AudioViewCoordinatorSpy(snapshot: snapshot) + let model = AudioDashboardModel(coordinator: coordinator) + + AudioDashboardControlBindings.toggleDeviceMute( + model: model, deviceUID: "BuiltInOutput" + ) + AudioDashboardControlBindings.toggleProcessMute( + model: model, processObjectID: 11 + ) + + XCTAssertTrue(coordinator.deviceMutes.isEmpty) + XCTAssertTrue(coordinator.processMutes.isEmpty) + } + + func testNoRestoreAccessibilityUsesLocalizedLabelAndRetainsMutedValue() { + defer { AppLocalization.setPreferredLanguageIdentifier(nil) } + AppLocalization.setPreferredLanguageIdentifier("en") + var snapshot = AudioControlSnapshot.fixture() + snapshot.devices[0].device = .fixtureDevice( + uid: "BuiltInOutput", volume: 0, mute: false + ) + snapshot.processes[0].volume = 0 + snapshot.processes[0].isMuted = false + + let device = AudioDeviceRowPresentation(snapshot.devices[0]) + let process = AudioProcessRowPresentation(snapshot.processes[0]) + + XCTAssertTrue(device.muteAccessibility.label?.contains("BuiltInOutput") == true) + XCTAssertTrue(process.muteAccessibility.label?.contains("Music") == true) + XCTAssertTrue(device.muteAccessibility.label?.contains( + "Muted; no previous volume to restore" + ) == true) + XCTAssertTrue(process.muteAccessibility.label?.contains( + "Muted; no previous volume to restore" + ) == true) + XCTAssertEqual(device.muteAccessibility.value, "Muted") + XCTAssertEqual(process.muteAccessibility.value, "Muted") + } + + func testMixedDeviceCapabilitiesKeepExistingConservativePresentation() { + var snapshot = AudioControlSnapshot.fixture() + snapshot.devices[0] = .fixture( + volume: .value(0.5, isWritable: false), + mute: .value(false, isWritable: true) + ) + let coordinator = AudioViewCoordinatorSpy(snapshot: snapshot) + let model = AudioDashboardModel(coordinator: coordinator) + let readOnlyVolume = AudioDeviceRowPresentation(snapshot.devices[0]) + XCTAssertEqual(readOnlyVolume.volume, .readOnly(0.5)) + XCTAssertEqual( + readOnlyVolume.mute, + .button(isMuted: false, canToggle: true) + ) + XCTAssertTrue(readOnlyVolume.muteAccessibility.isEnabled) + + AudioDashboardControlBindings.toggleDeviceMute( + model: model, deviceUID: "BuiltInOutput" + ) + coordinator.update { snapshot in + snapshot.devices[0] = .fixture( + volume: .value(0.5, isWritable: false), + mute: .value(true, isWritable: true) + ) + } + AudioDashboardControlBindings.toggleDeviceMute( + model: model, deviceUID: "BuiltInOutput" + ) + XCTAssertEqual(coordinator.deviceMutes.map(\.1), [true, false]) + + let initialZero = AudioDeviceRowPresentation(.fixture( + volume: .value(0, isWritable: false), + mute: .value(false, isWritable: true) + )) + XCTAssertEqual(initialZero.volume, .readOnly(0)) + XCTAssertEqual(initialZero.mute, .button(isMuted: true, canToggle: false)) + XCTAssertFalse(initialZero.muteAccessibility.isEnabled) + + let unsupportedMute = AudioDeviceRowPresentation(.fixture( + volume: .value(0.5, isWritable: true), + mute: .unsupported + )) + XCTAssertEqual(unsupportedMute.volume, .readOnly(0.5)) + XCTAssertEqual(unsupportedMute.mute, .unsupported) + } + + func testDeviceCapabilityMatrixMatchesAcceptedHelperIntents() { + let failure = Self.halFailure + let cases: [( + name: String, + volume: AudioPropertyValue, + mute: AudioPropertyValue, + expectedVolume: AudioVolumeControlPresentation, + expectedMute: AudioMuteControlPresentation, + acceptsVolume: Bool, + acceptsMute: Bool + )] = [ + ( + "fully writable", + .value(0.5, isWritable: true), + .value(false, isWritable: true), + .slider(0.5), + .button(isMuted: false, canToggle: true), + true, + true + ), + ( + "authorized mute only", + .value(0.5, isWritable: false), + .value(false, isWritable: true), + .readOnly(0.5), + .button(isMuted: false, canToggle: true), + false, + true + ), + ( + "writable volume read only mute", + .value(0.5, isWritable: true), + .value(false, isWritable: false), + .readOnly(0.5), + .readOnly(false), + false, + false + ), + ( + "writable volume unsupported mute", + .value(0.5, isWritable: true), + .unsupported, + .readOnly(0.5), + .unsupported, + false, + false + ), + ( + "unsupported volume writable mute", + .unsupported, + .value(false, isWritable: true), + .unsupported, + .readOnly(false), + false, + false + ), + ( + "unavailable volume writable mute", + .unavailable, + .value(false, isWritable: true), + .unavailable, + .readOnly(false), + false, + false + ), + ( + "failed volume writable mute", + .failed(failure), + .value(false, isWritable: true), + .failed, + .readOnly(false), + false, + false + ), + ] + + for entry in cases { + var snapshot = AudioControlSnapshot.fixture() + snapshot.devices[0] = .fixture(volume: entry.volume, mute: entry.mute) + let coordinator = AudioViewCoordinatorSpy(snapshot: snapshot) + let model = AudioDashboardModel(coordinator: coordinator) + let presentation = AudioDeviceRowPresentation(snapshot.devices[0]) + + XCTAssertEqual(presentation.volume, entry.expectedVolume, entry.name) + XCTAssertEqual(presentation.mute, entry.expectedMute, entry.name) + AudioDashboardControlBindings.deviceVolume( + model: model, deviceUID: "BuiltInOutput", fallback: 1 + ).wrappedValue = 0.8 + AudioDashboardControlBindings.toggleDeviceMute( + model: model, deviceUID: "BuiltInOutput" + ) + XCTAssertEqual( + coordinator.deviceVolumes.isEmpty == false, + entry.acceptsVolume, + entry.name + ) + XCTAssertEqual( + coordinator.deviceMutes.isEmpty == false, + entry.acceptsMute, + entry.name + ) + } + } + func testMuteFailurePresentationsContainVisibleDeviceNameTextAndRetry() { for mute in [AudioPropertyValue.unavailable, .failed(Self.halFailure)] { let row = AudioDeviceRowPresentation(.fixture(mute: mute)) @@ -611,9 +998,14 @@ private extension AudioDeviceControlSnapshot { } private extension AudioOutputDeviceSnapshot { - static func fixtureDevice(uid: String, volume: Double) -> Self { - Self(id: uid, objectID: 1, name: uid, - volume: .value(volume, isWritable: true), mute: .value(false, isWritable: true)) + static func fixtureDevice(uid: String, volume: Double, mute: Bool = false) -> Self { + Self( + id: uid, + objectID: 1, + name: uid, + volume: .value(volume, isWritable: true), + mute: .value(mute, isWritable: true) + ) } } diff --git a/Tests/MacActivityAppTests/Support/AudioControlComponentFixture.swift b/Tests/MacActivityAppTests/Support/AudioControlComponentFixture.swift index 789ffb3..0c98789 100644 --- a/Tests/MacActivityAppTests/Support/AudioControlComponentFixture.swift +++ b/Tests/MacActivityAppTests/Support/AudioControlComponentFixture.swift @@ -313,17 +313,26 @@ extension AudioFeatureAvailability { @MainActor final class DeviceProviderFake: AudioDeviceControlProviding, AudioRouteDeviceProviding { + enum Write: Equatable { + case volume(Double) + case mute(Bool) + } + var volumeWriteError: Error? var muteWriteError: Error? var confirmedMute = false var confirmedVolume = 0.5 var snapshotVolume = 0.5 var snapshotMute = false + private(set) var writes: [Write] = [] private(set) var volumeWrites: [Double] = [] private(set) var muteWrites: [Bool] = [] var lifecycle: LifecycleRecorder? + var onVolumeWrite: (@MainActor () -> Void)? + var onMuteWrite: (@MainActor () -> Void)? var onRouteRead: (@MainActor () -> Void)? var routeReadError: Error? + var outputSnapshotsError: Error? var outputSnapshots: [AudioOutputDeviceSnapshot]? var routeDescriptors: [AudioRouteDevice] = [ @@ -333,6 +342,7 @@ final class DeviceProviderFake: AudioDeviceControlProviding, AudioRouteDevicePro func outputDeviceSnapshots() throws -> [AudioOutputDeviceSnapshot] { lifecycle?.events.append("devices.read") + if let outputSnapshotsError { throw outputSnapshotsError } return outputSnapshots ?? [.init( id: "BuiltIn", objectID: 10, @@ -348,12 +358,16 @@ final class DeviceProviderFake: AudioDeviceControlProviding, AudioRouteDevicePro } func writeVolume(_ volume: Double, forUID uid: String) throws -> Double { + writes.append(.volume(volume)) volumeWrites.append(volume) + onVolumeWrite?() if let volumeWriteError { throw volumeWriteError } return confirmedVolume } func writeMute(_ isMuted: Bool, forUID uid: String) throws -> Bool { + writes.append(.mute(isMuted)) muteWrites.append(isMuted) + onMuteWrite?() if let muteWriteError { throw muteWriteError } return confirmedMute }