From affb9a8f9a7d0f48b04228466f667a530021d9f1 Mon Sep 17 00:00:00 2001 From: bigtomcat Date: Fri, 17 Jul 2026 21:39:18 +1000 Subject: [PATCH 01/15] feat(audio): define effective volume state --- .../Models/AudioControlCoordinator.swift | 31 +++++++++++++++++++ .../AudioDashboardViewTests.swift | 29 +++++++++++++++++ 2 files changed, 60 insertions(+) diff --git a/Sources/MacActivityApp/Models/AudioControlCoordinator.swift b/Sources/MacActivityApp/Models/AudioControlCoordinator.swift index 90945ba..82818c0 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 diff --git a/Tests/MacActivityAppTests/AudioDashboardViewTests.swift b/Tests/MacActivityAppTests/AudioDashboardViewTests.swift index 0b4dc1f..5ab25a1 100644 --- a/Tests/MacActivityAppTests/AudioDashboardViewTests.swift +++ b/Tests/MacActivityAppTests/AudioDashboardViewTests.swift @@ -8,6 +8,35 @@ 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 testRealViewWiresContractsWithoutAnAccessibilityManifest() throws { let source = try audioDashboardViewSource() From db2a401943cddfd395284a3a3e389278f3e7dda9 Mon Sep 17 00:00:00 2001 From: bigtomcat Date: Fri, 17 Jul 2026 21:44:38 +1000 Subject: [PATCH 02/15] feat(audio): bind process mute to effective volume --- .../Models/AudioControlCoordinator.swift | 18 ++++++- .../AudioControlCoordinatorTests.swift | 47 +++++++++++++++++++ 2 files changed, 63 insertions(+), 2 deletions(-) diff --git a/Sources/MacActivityApp/Models/AudioControlCoordinator.swift b/Sources/MacActivityApp/Models/AudioControlCoordinator.swift index 82818c0..db910d0 100644 --- a/Sources/MacActivityApp/Models/AudioControlCoordinator.swift +++ b/Sources/MacActivityApp/Models/AudioControlCoordinator.swift @@ -335,11 +335,23 @@ final class AudioControlCoordinator: AudioControlCoordinating, ObservableObject } 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) { @@ -617,8 +629,10 @@ private extension AudioControlCoordinator { isMuted: row.isMuted, route: row.route ) + let originalValues = values let previousRoute = values.route mutate(&values) + guard values != originalValues else { return } let routeOptions = values.route == previousRoute ? row.routeOptions : makeRouteOptions(for: values.route, process: row.process) diff --git a/Tests/MacActivityAppTests/AudioControlCoordinatorTests.swift b/Tests/MacActivityAppTests/AudioControlCoordinatorTests.swift index c73053d..a250ac2 100644 --- a/Tests/MacActivityAppTests/AudioControlCoordinatorTests.swift +++ b/Tests/MacActivityAppTests/AudioControlCoordinatorTests.swift @@ -694,6 +694,53 @@ final class AudioControlCoordinatorTests: XCTestCase { XCTAssertEqual(fixture.coordinator.snapshot.devices[0].error, .deviceWrite) } + 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 count = fixture.engine.gains.count + fixture.coordinator.setProcessMuted(false, for: 11) + await fixture.coordinator.testingWaitUntilIdle() + XCTAssertEqual(fixture.coordinator.snapshot.processes[0].volume, 0) + XCTAssertEqual(fixture.engine.gains.count, count) + } + func testBundlelessProcessIntentIsSessionOnly() async { let fixture = CoordinatorFixture( availability: .supported, From 24c03335cdeff2c958c8e5eb296ebd1a6543ba0a Mon Sep 17 00:00:00 2001 From: bigtomcat Date: Fri, 17 Jul 2026 21:47:57 +1000 Subject: [PATCH 03/15] test(audio): cover process no-op side effects --- .../MacActivityAppTests/AudioControlCoordinatorTests.swift | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Tests/MacActivityAppTests/AudioControlCoordinatorTests.swift b/Tests/MacActivityAppTests/AudioControlCoordinatorTests.swift index a250ac2..57c7711 100644 --- a/Tests/MacActivityAppTests/AudioControlCoordinatorTests.swift +++ b/Tests/MacActivityAppTests/AudioControlCoordinatorTests.swift @@ -734,11 +734,13 @@ final class AudioControlCoordinatorTests: XCTestCase { ) await fixture.coordinator.start() await fixture.coordinator.testingWaitUntilIdle() - let count = fixture.engine.gains.count + 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.gains.count, count) + XCTAssertEqual(fixture.engine.gainUpdateCalls.count, gainUpdateCount) + XCTAssertEqual(fixture.store.saveCount, saveCount) } func testBundlelessProcessIntentIsSessionOnly() async { From 6c63ebc4061a451a3726b7e4489684c157b4d0a7 Mon Sep 17 00:00:00 2001 From: bigtomcat Date: Fri, 17 Jul 2026 22:02:35 +1000 Subject: [PATCH 04/15] fix(audio): serialize effective device volume --- .../Models/AudioControlCoordinator.swift | 145 ++++++++++++------ .../AudioControlCoordinatorTests.swift | 50 +++++- .../AudioControlComponentFixture.swift | 8 + 3 files changed, 150 insertions(+), 53 deletions(-) diff --git a/Sources/MacActivityApp/Models/AudioControlCoordinator.swift b/Sources/MacActivityApp/Models/AudioControlCoordinator.swift index db910d0..5c28c1f 100644 --- a/Sources/MacActivityApp/Models/AudioControlCoordinator.swift +++ b/Sources/MacActivityApp/Models/AudioControlCoordinator.swift @@ -154,8 +154,8 @@ 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 deviceControlTasks: [String: Task] = [:] private var processTasks: [AudioObjectID: Task] = [:] private var trackedTasks: [UInt64: Task] = [:] private var nextTrackedTaskID: UInt64 = 0 @@ -211,8 +211,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() @@ -274,64 +273,98 @@ 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)) - 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 } - 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 - } - } catch { - let rolledBack = confirmedDevices[deviceUID] ?? confirmed - updateDevice(deviceUID) { row in - row.device = rolledBack - row.error = .deviceWrite - } - } - } + 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 confirmed = confirmedDevices[deviceUID] else { return } - updateDevice(deviceUID) { row in - row.device = Self.device(row.device, mute: .value(isMuted, isWritable: true)) + guard acceptsMutations, + let device = snapshot.devices.first(where: { $0.id == deviceUID })?.device, + let current = writableDeviceState(device), + let target = current.settingMuted(isMuted) else { return } + submitDeviceState(target, for: deviceUID, debounceVolume: false) + } + + 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) + } + + func isCurrentDeviceIntent(_ uid: String, ordinal: UInt64) -> Bool { + acceptsMutations && !Task.isCancelled && deviceControlOrdinals[uid] == ordinal + } + + func submitDeviceState( + _ target: AudioEffectiveVolumeState, + for uid: String, + debounceVolume: Bool + ) { + guard let fallback = confirmedDevices[uid], + writableDeviceState(fallback) != nil else { return } + let ordinal = (deviceControlOrdinals[uid] ?? 0) &+ 1 + deviceControlOrdinals[uid] = ordinal + let previous = deviceControlTasks[uid] + previous?.cancel() + updateDevice(uid) { row in + row.device = Self.device(row.device, state: target) row.error = nil } - deviceMuteTasks[deviceUID]?.cancel() - deviceMuteTasks[deviceUID] = trackedTask { @MainActor [weak self] in - guard let self else { return } - guard Task.isCancelled == false else { return } + + let task = trackedTask { @MainActor [weak self] in + await previous?.value + guard let self, isCurrentDeviceIntent(uid, ordinal: ordinal) else { return } + var confirmed = confirmedDevices[uid] ?? fallback 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 + guard var current = writableDeviceState(confirmed) else { return } + if current.rawVolume != target.rawVolume { + if debounceVolume { await delay(.milliseconds(75)) } + guard isCurrentDeviceIntent(uid, ordinal: ordinal) else { return } + let volume = try deviceProvider.writeVolume(target.rawVolume, forUID: uid) + confirmed = Self.device( + confirmed, + volume: .value(volume, isWritable: true) + ) + confirmedDevices[uid] = confirmed + current = AudioEffectiveVolumeState( + rawVolume: volume, + isMuted: current.isMuted + ) + guard isCurrentDeviceIntent(uid, ordinal: ordinal) else { return } + } + if current.isMuted != target.isMuted { + let muted = try deviceProvider.writeMute(target.isMuted, forUID: uid) + confirmed = Self.device( + confirmed, + mute: .value(muted, isWritable: true) + ) + confirmedDevices[uid] = confirmed + guard isCurrentDeviceIntent(uid, ordinal: ordinal) else { return } + } + updateDevice(uid) { row in + row.device = confirmed row.error = nil } } catch { - let rolledBack = confirmedDevices[deviceUID] ?? confirmed - updateDevice(deviceUID) { row in + guard isCurrentDeviceIntent(uid, ordinal: ordinal) else { return } + let rolledBack = confirmedDevices[uid] ?? fallback + updateDevice(uid) { row in row.device = rolledBack row.error = .deviceWrite } } } + deviceControlTasks[uid] = task } func setProcessVolume(_ volume: Double, for processObjectID: AudioObjectID) { @@ -394,8 +427,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() } @@ -423,8 +455,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 { @@ -937,6 +969,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, diff --git a/Tests/MacActivityAppTests/AudioControlCoordinatorTests.swift b/Tests/MacActivityAppTests/AudioControlCoordinatorTests.swift index 57c7711..acd2d43 100644 --- a/Tests/MacActivityAppTests/AudioControlCoordinatorTests.swift +++ b/Tests/MacActivityAppTests/AudioControlCoordinatorTests.swift @@ -694,6 +694,16 @@ final class AudioControlCoordinatorTests: XCTestCase { XCTAssertEqual(fixture.coordinator.snapshot.devices[0].error, .deviceWrite) } + func testRapidDeviceIntentsCommitOnlyLatestEffectiveState() async { + let fixture = CoordinatorFixture(availability: .supported) + await fixture.coordinator.start() + fixture.deviceProvider.confirmedMute = true + fixture.coordinator.setDeviceVolume(0.2, for: "BuiltIn") + fixture.coordinator.setDeviceVolume(0, for: "BuiltIn") + await fixture.coordinator.testingWaitUntilIdle() + XCTAssertEqual(fixture.deviceProvider.writes, [.mute(true)]) + } + func testProcessSliderZeroMutesWithoutDiscardingRestoreVolume() async { let fixture = CoordinatorFixture(availability: .supported) await fixture.coordinator.start() @@ -2091,6 +2101,42 @@ final class AudioControlCoordinatorTests: XCTestCase { XCTAssertEqual(fixture.coordinator.snapshot.devices[0].error, .deviceWrite) } + 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) + } + func testDeviceVolumeUsesHardwareConfirmedValueAndRetryRefreshesOneRow() async { let fixture = CoordinatorFixture(availability: .supported) await fixture.coordinator.start() @@ -2109,7 +2155,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() @@ -2119,8 +2165,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/Support/AudioControlComponentFixture.swift b/Tests/MacActivityAppTests/Support/AudioControlComponentFixture.swift index 789ffb3..a1b364f 100644 --- a/Tests/MacActivityAppTests/Support/AudioControlComponentFixture.swift +++ b/Tests/MacActivityAppTests/Support/AudioControlComponentFixture.swift @@ -313,12 +313,18 @@ 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? @@ -348,11 +354,13 @@ final class DeviceProviderFake: AudioDeviceControlProviding, AudioRouteDevicePro } func writeVolume(_ volume: Double, forUID uid: String) throws -> Double { + writes.append(.volume(volume)) volumeWrites.append(volume) if let volumeWriteError { throw volumeWriteError } return confirmedVolume } func writeMute(_ isMuted: Bool, forUID uid: String) throws -> Bool { + writes.append(.mute(isMuted)) muteWrites.append(isMuted) if let muteWriteError { throw muteWriteError } return confirmedMute From f1ebeb89fbe865c9bf8f516135a4c660f72c5d9e Mon Sep 17 00:00:00 2001 From: bigtomcat Date: Fri, 17 Jul 2026 22:20:11 +1000 Subject: [PATCH 05/15] fix(audio): preserve refreshed device state --- .../Models/AudioControlCoordinator.swift | 42 +++++---- .../AudioControlCoordinatorTests.swift | 93 ++++++++++++++++++- .../AudioControlComponentFixture.swift | 4 + 3 files changed, 122 insertions(+), 17 deletions(-) diff --git a/Sources/MacActivityApp/Models/AudioControlCoordinator.swift b/Sources/MacActivityApp/Models/AudioControlCoordinator.swift index 5c28c1f..a7eb909 100644 --- a/Sources/MacActivityApp/Models/AudioControlCoordinator.swift +++ b/Sources/MacActivityApp/Models/AudioControlCoordinator.swift @@ -324,28 +324,38 @@ final class AudioControlCoordinator: AudioControlCoordinating, ObservableObject let task = trackedTask { @MainActor [weak self] in await previous?.value guard let self, isCurrentDeviceIntent(uid, ordinal: ordinal) else { return } - var confirmed = confirmedDevices[uid] ?? fallback do { - guard var current = writableDeviceState(confirmed) else { return } + guard var confirmed = confirmedDevices[uid], + var current = writableDeviceState(confirmed) else { return } if current.rawVolume != target.rawVolume { - if debounceVolume { await delay(.milliseconds(75)) } - guard isCurrentDeviceIntent(uid, ordinal: ordinal) else { return } - let volume = try deviceProvider.writeVolume(target.rawVolume, forUID: uid) - confirmed = Self.device( - confirmed, - volume: .value(volume, isWritable: true) - ) - confirmedDevices[uid] = confirmed - current = AudioEffectiveVolumeState( - rawVolume: volume, - isMuted: current.isMuted - ) - guard isCurrentDeviceIntent(uid, ordinal: ordinal) else { return } + if debounceVolume { + await delay(.milliseconds(75)) + guard isCurrentDeviceIntent(uid, ordinal: ordinal), + let latest = confirmedDevices[uid], + let latestState = writableDeviceState(latest) else { return } + confirmed = latest + current = latestState + } + if current.rawVolume != target.rawVolume { + let volume = try deviceProvider.writeVolume(target.rawVolume, forUID: uid) + guard let latest = confirmedDevices[uid], + writableDeviceState(latest) != nil else { return } + confirmed = Self.device( + latest, + volume: .value(volume, isWritable: true) + ) + confirmedDevices[uid] = confirmed + guard let merged = writableDeviceState(confirmed) else { return } + current = merged + guard isCurrentDeviceIntent(uid, ordinal: ordinal) else { return } + } } if current.isMuted != target.isMuted { let muted = try deviceProvider.writeMute(target.isMuted, forUID: uid) + guard let latest = confirmedDevices[uid], + writableDeviceState(latest) != nil else { return } confirmed = Self.device( - confirmed, + latest, mute: .value(muted, isWritable: true) ) confirmedDevices[uid] = confirmed diff --git a/Tests/MacActivityAppTests/AudioControlCoordinatorTests.swift b/Tests/MacActivityAppTests/AudioControlCoordinatorTests.swift index acd2d43..d4e56df 100644 --- a/Tests/MacActivityAppTests/AudioControlCoordinatorTests.swift +++ b/Tests/MacActivityAppTests/AudioControlCoordinatorTests.swift @@ -695,11 +695,15 @@ final class AudioControlCoordinatorTests: XCTestCase { } func testRapidDeviceIntentsCommitOnlyLatestEffectiveState() async { - let fixture = CoordinatorFixture(availability: .supported) + 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)]) } @@ -2135,6 +2139,93 @@ final class AudioControlCoordinatorTests: XCTestCase { 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 testDeviceVolumeUsesHardwareConfirmedValueAndRetryRefreshesOneRow() async { diff --git a/Tests/MacActivityAppTests/Support/AudioControlComponentFixture.swift b/Tests/MacActivityAppTests/Support/AudioControlComponentFixture.swift index a1b364f..3c4f73d 100644 --- a/Tests/MacActivityAppTests/Support/AudioControlComponentFixture.swift +++ b/Tests/MacActivityAppTests/Support/AudioControlComponentFixture.swift @@ -328,6 +328,8 @@ final class DeviceProviderFake: AudioDeviceControlProviding, AudioRouteDevicePro 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 outputSnapshots: [AudioOutputDeviceSnapshot]? @@ -356,12 +358,14 @@ 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 } From 7ad879f7b884c0bf0bbb1d07ea47048895685ffd Mon Sep 17 00:00:00 2001 From: bigtomcat Date: Fri, 17 Jul 2026 22:27:40 +1000 Subject: [PATCH 06/15] fix(audio): retain successful device readbacks --- .../Models/AudioControlCoordinator.swift | 16 ++++-- .../AudioControlCoordinatorTests.swift | 52 +++++++++++++++++++ 2 files changed, 63 insertions(+), 5 deletions(-) diff --git a/Sources/MacActivityApp/Models/AudioControlCoordinator.swift b/Sources/MacActivityApp/Models/AudioControlCoordinator.swift index a7eb909..105ff6d 100644 --- a/Sources/MacActivityApp/Models/AudioControlCoordinator.swift +++ b/Sources/MacActivityApp/Models/AudioControlCoordinator.swift @@ -339,13 +339,19 @@ final class AudioControlCoordinator: AudioControlCoordinating, ObservableObject if current.rawVolume != target.rawVolume { let volume = try deviceProvider.writeVolume(target.rawVolume, forUID: uid) guard let latest = confirmedDevices[uid], - writableDeviceState(latest) != nil else { return } + case .value(_, let isWritable) = latest.volume else { return } confirmed = Self.device( latest, - volume: .value(volume, isWritable: true) + volume: .value(volume, isWritable: isWritable) ) confirmedDevices[uid] = confirmed - guard let merged = writableDeviceState(confirmed) else { return } + guard let merged = writableDeviceState(confirmed) else { + guard isCurrentDeviceIntent(uid, ordinal: ordinal) else { return } + updateDevice(uid) { row in + row.device = confirmed + } + return + } current = merged guard isCurrentDeviceIntent(uid, ordinal: ordinal) else { return } } @@ -353,10 +359,10 @@ final class AudioControlCoordinator: AudioControlCoordinating, ObservableObject if current.isMuted != target.isMuted { let muted = try deviceProvider.writeMute(target.isMuted, forUID: uid) guard let latest = confirmedDevices[uid], - writableDeviceState(latest) != nil else { return } + case .value(_, let isWritable) = latest.mute else { return } confirmed = Self.device( latest, - mute: .value(muted, isWritable: true) + mute: .value(muted, isWritable: isWritable) ) confirmedDevices[uid] = confirmed guard isCurrentDeviceIntent(uid, ordinal: ordinal) else { return } diff --git a/Tests/MacActivityAppTests/AudioControlCoordinatorTests.swift b/Tests/MacActivityAppTests/AudioControlCoordinatorTests.swift index d4e56df..9980e80 100644 --- a/Tests/MacActivityAppTests/AudioControlCoordinatorTests.swift +++ b/Tests/MacActivityAppTests/AudioControlCoordinatorTests.swift @@ -2228,6 +2228,58 @@ final class AudioControlCoordinatorTests: XCTestCase { 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 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() From f33e879a09546d59610fb21b8f3dfa2826c1b372 Mon Sep 17 00:00:00 2001 From: bigtomcat Date: Sat, 18 Jul 2026 00:01:20 +1000 Subject: [PATCH 07/15] feat(audio): bind mute presentation to zero volume --- .../Localization/AppLocalization.swift | 1 + .../Models/AudioControlCoordinator.swift | 188 +++++++++++++----- .../Resources/de.lproj/Localizable.strings | 1 + .../Resources/en.lproj/Localizable.strings | 1 + .../Resources/fr.lproj/Localizable.strings | 1 + .../Resources/ja.lproj/Localizable.strings | 1 + .../Resources/ko.lproj/Localizable.strings | 1 + .../zh-Hans.lproj/Localizable.strings | 1 + .../zh-Hant.lproj/Localizable.strings | 1 + .../Views/AudioDashboardView.swift | 113 ++++++++--- .../AudioControlCoordinatorTests.swift | 62 ++++++ .../AudioDashboardViewTests.swift | 144 +++++++++++++- 12 files changed, 435 insertions(+), 80 deletions(-) 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 105ff6d..55cf876 100644 --- a/Sources/MacActivityApp/Models/AudioControlCoordinator.swift +++ b/Sources/MacActivityApp/Models/AudioControlCoordinator.swift @@ -131,6 +131,11 @@ typealias AudioControlDelay = @Sendable (Duration) async -> Void @MainActor final class AudioControlCoordinator: AudioControlCoordinating, ObservableObject { + private enum DeviceControlIntent { + case effectiveState(AudioEffectiveVolumeState, debounceVolume: Bool) + case muteOnly(Bool) + } + @Published private(set) var snapshot: AudioControlSnapshot = .empty let supportsProcessControls: Bool @@ -287,10 +292,17 @@ final class AudioControlCoordinator: AudioControlCoordinating, ObservableObject func setDeviceMuted(_ isMuted: Bool, for deviceUID: String) { guard acceptsMutations, - let device = snapshot.devices.first(where: { $0.id == deviceUID })?.device, - let current = writableDeviceState(device), - let target = current.settingMuted(isMuted) else { return } - submitDeviceState(target, for: deviceUID, debounceVolume: false) + 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( @@ -301,6 +313,14 @@ final class AudioControlCoordinator: AudioControlCoordinating, ObservableObject 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 } @@ -310,14 +330,29 @@ final class AudioControlCoordinator: AudioControlCoordinating, ObservableObject 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], - writableDeviceState(fallback) != nil else { return } + deviceCanAccept(intent, snapshot: fallback) else { return } let ordinal = (deviceControlOrdinals[uid] ?? 0) &+ 1 deviceControlOrdinals[uid] = ordinal let previous = deviceControlTasks[uid] previous?.cancel() updateDevice(uid) { row in - row.device = Self.device(row.device, state: target) + 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 } @@ -325,48 +360,9 @@ final class AudioControlCoordinator: AudioControlCoordinating, ObservableObject await previous?.value guard let self, isCurrentDeviceIntent(uid, ordinal: ordinal) else { return } do { - guard var confirmed = confirmedDevices[uid], - var current = writableDeviceState(confirmed) else { return } - if current.rawVolume != target.rawVolume { - if debounceVolume { - await delay(.milliseconds(75)) - guard isCurrentDeviceIntent(uid, ordinal: ordinal), - let latest = confirmedDevices[uid], - let latestState = writableDeviceState(latest) else { return } - confirmed = latest - current = latestState - } - if current.rawVolume != target.rawVolume { - let volume = try deviceProvider.writeVolume(target.rawVolume, forUID: uid) - guard let latest = confirmedDevices[uid], - case .value(_, let isWritable) = latest.volume else { return } - confirmed = Self.device( - latest, - volume: .value(volume, isWritable: isWritable) - ) - confirmedDevices[uid] = confirmed - guard let merged = writableDeviceState(confirmed) else { - guard isCurrentDeviceIntent(uid, ordinal: ordinal) else { return } - updateDevice(uid) { row in - row.device = confirmed - } - return - } - current = merged - guard isCurrentDeviceIntent(uid, ordinal: ordinal) else { return } - } - } - if current.isMuted != target.isMuted { - let muted = try deviceProvider.writeMute(target.isMuted, forUID: uid) - guard let latest = confirmedDevices[uid], - case .value(_, let isWritable) = latest.mute else { return } - confirmed = Self.device( - latest, - mute: .value(muted, isWritable: isWritable) - ) - confirmedDevices[uid] = confirmed - guard isCurrentDeviceIntent(uid, ordinal: ordinal) else { return } - } + guard let confirmed = try await executeDeviceControl( + intent, uid: uid, ordinal: ordinal + ) else { return } updateDevice(uid) { row in row.device = confirmed row.error = nil @@ -383,6 +379,102 @@ final class AudioControlCoordinator: AudioControlCoordinating, ObservableObject deviceControlTasks[uid] = task } + 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 + } + } + + private func executeDeviceControl( + _ intent: DeviceControlIntent, + uid: String, + ordinal: UInt64 + ) async throws -> AudioOutputDeviceSnapshot? { + switch intent { + case .effectiveState(let target, let debounceVolume): + return try await executeEffectiveDeviceControl( + target, + uid: uid, + 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 } + let muted = try deviceProvider.writeMute(targetMuted, forUID: uid) + guard let refreshed = confirmedDevices[uid], + case .value(_, let isWritable) = refreshed.mute else { return nil } + let confirmed = Self.device( + refreshed, + mute: .value(muted, isWritable: isWritable) + ) + confirmedDevices[uid] = confirmed + guard isCurrentDeviceIntent(uid, ordinal: ordinal) else { return nil } + return confirmed + } + } + + private func executeEffectiveDeviceControl( + _ target: AudioEffectiveVolumeState, + uid: String, + 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 isCurrentDeviceIntent(uid, ordinal: ordinal), + let latest = confirmedDevices[uid], + let latestState = writableDeviceState(latest) else { return nil } + confirmed = latest + current = latestState + } + if current.rawVolume != target.rawVolume { + let volume = try deviceProvider.writeVolume(target.rawVolume, forUID: uid) + guard let latest = confirmedDevices[uid], + case .value(_, let isWritable) = latest.volume else { return nil } + confirmed = Self.device( + latest, + volume: .value(volume, isWritable: isWritable) + ) + 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 { + let muted = try deviceProvider.writeMute(target.isMuted, forUID: uid) + guard let latest = confirmedDevices[uid], + case .value(_, let isWritable) = latest.mute else { return nil } + confirmed = Self.device( + latest, + mute: .value(muted, isWritable: isWritable) + ) + confirmedDevices[uid] = confirmed + guard isCurrentDeviceIntent(uid, ordinal: ordinal) else { return nil } + } + return confirmed + } + func setProcessVolume(_ volume: Double, for processObjectID: AudioObjectID) { updateProcessIntent(processObjectID) { values in let next = AudioEffectiveVolumeState( 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..f6991b9 100644 --- a/Sources/MacActivityApp/Views/AudioDashboardView.swift +++ b/Sources/MacActivityApp/Views/AudioDashboardView.swift @@ -70,8 +70,12 @@ 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 }), @@ -88,8 +92,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 +137,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 +151,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) } } @@ -283,7 +302,7 @@ private struct AudioDeviceControlRow: View { @ViewBuilder private var muteControl: some View { switch presentation.mute { - case .button(let isMuted): + case .button(let isMuted, _): Button { AudioDashboardControlBindings.toggleDeviceMute( model: model, deviceUID: snapshot.id @@ -357,7 +376,8 @@ private struct AudioProcessControlRow: View { model: model, processObjectID: snapshot.id ) } label: { - Image(systemName: snapshot.isMuted ? "speaker.slash.fill" : "speaker.wave.2.fill") + Image(systemName: presentation.showsMutedIcon + ? "speaker.slash.fill" : "speaker.wave.2.fill") .frame(width: 20) } .buttonStyle(.plain) @@ -579,7 +599,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 +682,31 @@ 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 + } 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): + volume = .slider(effective?.displayVolume ?? value) + 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 + mute = .button( + isMuted: isMuted, + canToggle: isMuted ? (effective?.canRestore ?? true) : true + ) + case .value(let value, isWritable: false): + mute = .readOnly(effective?.showsMutedIcon ?? value) case .unsupported: mute = .unsupported case .unavailable: mute = .unavailable( @@ -687,16 +722,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 +773,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 9980e80..e507603 100644 --- a/Tests/MacActivityAppTests/AudioControlCoordinatorTests.swift +++ b/Tests/MacActivityAppTests/AudioControlCoordinatorTests.swift @@ -2105,6 +2105,68 @@ 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 testDeviceSliderZeroMutesWithoutWritingRawVolumeZero() async { let fixture = CoordinatorFixture(availability: .supported) fixture.deviceProvider.confirmedMute = true diff --git a/Tests/MacActivityAppTests/AudioDashboardViewTests.swift b/Tests/MacActivityAppTests/AudioDashboardViewTests.swift index 5ab25a1..fde608d 100644 --- a/Tests/MacActivityAppTests/AudioDashboardViewTests.swift +++ b/Tests/MacActivityAppTests/AudioDashboardViewTests.swift @@ -183,6 +183,139 @@ 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, .slider(0.5)) + XCTAssertEqual(unsupportedMute.mute, .unsupported) + } + func testMuteFailurePresentationsContainVisibleDeviceNameTextAndRetry() { for mute in [AudioPropertyValue.unavailable, .failed(Self.halFailure)] { let row = AudioDeviceRowPresentation(.fixture(mute: mute)) @@ -640,9 +773,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) + ) } } From 4ee04add1cc58508625d51f490a661f5799a0606 Mon Sep 17 00:00:00 2001 From: bigtomcat Date: Sat, 18 Jul 2026 00:16:55 +1000 Subject: [PATCH 08/15] fix(audio): align device control capabilities --- .../Models/AudioControlCoordinator.swift | 17 ++- .../Views/AudioDashboardView.swift | 31 ++++- .../AudioControlCoordinatorTests.swift | 106 ++++++++++++++++++ .../AudioDashboardViewTests.swift | 106 +++++++++++++++++- 4 files changed, 249 insertions(+), 11 deletions(-) diff --git a/Sources/MacActivityApp/Models/AudioControlCoordinator.swift b/Sources/MacActivityApp/Models/AudioControlCoordinator.swift index 55cf876..6e73139 100644 --- a/Sources/MacActivityApp/Models/AudioControlCoordinator.swift +++ b/Sources/MacActivityApp/Models/AudioControlCoordinator.swift @@ -600,10 +600,19 @@ 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 { + snapshot.devices = [] + return + } + var refreshedDevices: [String: AudioOutputDeviceSnapshot] = [:] + snapshot.devices = devices.map { device in + refreshedDevices[device.id] = device + return AudioDeviceControlSnapshot( + device: device, + error: Self.deviceError(in: device) + ) + } + confirmedDevices = refreshedDevices } func refreshProcesses(resetSessions: Bool = false) { diff --git a/Sources/MacActivityApp/Views/AudioDashboardView.swift b/Sources/MacActivityApp/Views/AudioDashboardView.swift index f6991b9..1c9d3ef 100644 --- a/Sources/MacActivityApp/Views/AudioDashboardView.swift +++ b/Sources/MacActivityApp/Views/AudioDashboardView.swift @@ -79,7 +79,8 @@ enum AudioDashboardControlBindings { }, 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) } ) @@ -689,9 +690,23 @@ struct AudioDeviceRowPresentation: Identifiable { } 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(effective?.displayVolume ?? value) + 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 @@ -701,10 +716,14 @@ struct AudioDeviceRowPresentation: Identifiable { switch snapshot.device.mute { case .value(let value, isWritable: true): let isMuted = effective?.showsMutedIcon ?? value - mute = .button( - isMuted: isMuted, - canToggle: isMuted ? (effective?.canRestore ?? true) : true - ) + 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 diff --git a/Tests/MacActivityAppTests/AudioControlCoordinatorTests.swift b/Tests/MacActivityAppTests/AudioControlCoordinatorTests.swift index e507603..5ded222 100644 --- a/Tests/MacActivityAppTests/AudioControlCoordinatorTests.swift +++ b/Tests/MacActivityAppTests/AudioControlCoordinatorTests.swift @@ -2167,6 +2167,112 @@ final class AudioControlCoordinatorTests: XCTestCase { 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 testDeviceSliderZeroMutesWithoutWritingRawVolumeZero() async { let fixture = CoordinatorFixture(availability: .supported) fixture.deviceProvider.confirmedMute = true diff --git a/Tests/MacActivityAppTests/AudioDashboardViewTests.swift b/Tests/MacActivityAppTests/AudioDashboardViewTests.swift index fde608d..bc78f3b 100644 --- a/Tests/MacActivityAppTests/AudioDashboardViewTests.swift +++ b/Tests/MacActivityAppTests/AudioDashboardViewTests.swift @@ -312,10 +312,114 @@ final class AudioDashboardViewTests: XCTestCase { volume: .value(0.5, isWritable: true), mute: .unsupported )) - XCTAssertEqual(unsupportedMute.volume, .slider(0.5)) + 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)) From 1a0bedf115c1a5177fcdd94f14d6d8202da1fe98 Mon Sep 17 00:00:00 2001 From: bigtomcat Date: Sat, 18 Jul 2026 00:46:25 +1000 Subject: [PATCH 09/15] fix(audio): isolate device control lifetimes --- .../Models/AudioControlCoordinator.swift | 120 +++++++++-- .../AudioControlCoordinatorTests.swift | 188 ++++++++++++++++++ .../AudioControlComponentFixture.swift | 2 + 3 files changed, 295 insertions(+), 15 deletions(-) diff --git a/Sources/MacActivityApp/Models/AudioControlCoordinator.swift b/Sources/MacActivityApp/Models/AudioControlCoordinator.swift index 6e73139..867bf4b 100644 --- a/Sources/MacActivityApp/Models/AudioControlCoordinator.swift +++ b/Sources/MacActivityApp/Models/AudioControlCoordinator.swift @@ -160,6 +160,7 @@ final class AudioControlCoordinator: AudioControlCoordinating, ObservableObject private var gainTasks: [AudioObjectID: Task] = [:] private var retiringProcessObjectIDs: Set = [] 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] = [:] @@ -270,6 +271,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 @@ -325,6 +329,26 @@ final class AudioControlCoordinator: AudioControlCoordinating, ObservableObject 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, @@ -339,6 +363,8 @@ final class AudioControlCoordinator: AudioControlCoordinating, ObservableObject 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] @@ -358,17 +384,32 @@ final class AudioControlCoordinator: AudioControlCoordinating, ObservableObject let task = trackedTask { @MainActor [weak self] in await previous?.value - guard let self, isCurrentDeviceIntent(uid, ordinal: ordinal) else { return } + guard let self, + isCurrentDeviceOperation( + uid, + objectID: objectID, + lifetime: lifetime, + ordinal: ordinal + ) else { return } do { guard let confirmed = try await executeDeviceControl( - intent, uid: uid, ordinal: ordinal + intent, + uid: uid, + objectID: objectID, + lifetime: lifetime, + ordinal: ordinal ) else { return } updateDevice(uid) { row in row.device = confirmed - row.error = nil + row.error = Self.deviceError(in: confirmed) } } catch { - guard isCurrentDeviceIntent(uid, ordinal: ordinal) else { return } + guard isCurrentDeviceOperation( + uid, + objectID: objectID, + lifetime: lifetime, + ordinal: ordinal + ) else { return } let rolledBack = confirmedDevices[uid] ?? fallback updateDevice(uid) { row in row.device = rolledBack @@ -395,6 +436,8 @@ final class AudioControlCoordinator: AudioControlCoordinating, ObservableObject private func executeDeviceControl( _ intent: DeviceControlIntent, uid: String, + objectID: AudioObjectID, + lifetime: UInt64, ordinal: UInt64 ) async throws -> AudioOutputDeviceSnapshot? { switch intent { @@ -402,6 +445,8 @@ final class AudioControlCoordinator: AudioControlCoordinating, ObservableObject return try await executeEffectiveDeviceControl( target, uid: uid, + objectID: objectID, + lifetime: lifetime, ordinal: ordinal, debounceVolume: debounceVolume ) @@ -414,12 +459,18 @@ final class AudioControlCoordinator: AudioControlCoordinating, ObservableObject 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 let refreshed = confirmedDevices[uid], - case .value(_, let isWritable) = refreshed.mute else { return nil } + guard isCurrentDeviceLifetime(uid, objectID: objectID, lifetime: lifetime), + let refreshed = confirmedDevices[uid] else { return nil } let confirmed = Self.device( refreshed, - mute: .value(muted, isWritable: isWritable) + mute: .value(muted, isWritable: true) ) confirmedDevices[uid] = confirmed guard isCurrentDeviceIntent(uid, ordinal: ordinal) else { return nil } @@ -430,6 +481,8 @@ final class AudioControlCoordinator: AudioControlCoordinating, ObservableObject private func executeEffectiveDeviceControl( _ target: AudioEffectiveVolumeState, uid: String, + objectID: AudioObjectID, + lifetime: UInt64, ordinal: UInt64, debounceVolume: Bool ) async throws -> AudioOutputDeviceSnapshot? { @@ -438,19 +491,30 @@ final class AudioControlCoordinator: AudioControlCoordinating, ObservableObject if current.rawVolume != target.rawVolume { if debounceVolume { await delay(.milliseconds(75)) - guard isCurrentDeviceIntent(uid, ordinal: ordinal), + 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 let latest = confirmedDevices[uid], - case .value(_, let isWritable) = latest.volume else { return nil } + guard isCurrentDeviceLifetime(uid, objectID: objectID, lifetime: lifetime), + let latest = confirmedDevices[uid] else { return nil } confirmed = Self.device( latest, - volume: .value(volume, isWritable: isWritable) + volume: .value(volume, isWritable: true) ) confirmedDevices[uid] = confirmed guard let merged = writableDeviceState(confirmed) else { @@ -462,12 +526,18 @@ final class AudioControlCoordinator: AudioControlCoordinating, ObservableObject } } 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 let latest = confirmedDevices[uid], - case .value(_, let isWritable) = latest.mute else { return nil } + guard isCurrentDeviceLifetime(uid, objectID: objectID, lifetime: lifetime), + let latest = confirmedDevices[uid] else { return nil } confirmed = Self.device( latest, - mute: .value(muted, isWritable: isWritable) + mute: .value(muted, isWritable: true) ) confirmedDevices[uid] = confirmed guard isCurrentDeviceIntent(uid, ordinal: ordinal) else { return nil } @@ -601,20 +671,39 @@ private extension AudioControlCoordinator { func refreshDevices() { guard let devices = try? deviceProvider.outputDeviceSnapshots() else { + invalidateAllDeviceControlLifetimes() + confirmedDevices.removeAll() snapshot.devices = [] return } var refreshedDevices: [String: AudioOutputDeviceSnapshot] = [:] - snapshot.devices = devices.map { device in + 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) { let previous = Dictionary(uniqueKeysWithValues: snapshot.processes.map { ($0.id, $0) }) let audible = processProvider.audibleOutputProcesses() @@ -1272,6 +1361,7 @@ private extension AudioControlCoordinator { func handle(_ changes: Set) async { if changes.contains(.serviceRestarted) { + invalidateAllDeviceControlLifetimes() if processRuntimeWasStarted { await engine.stopAll() } diff --git a/Tests/MacActivityAppTests/AudioControlCoordinatorTests.swift b/Tests/MacActivityAppTests/AudioControlCoordinatorTests.swift index 5ded222..6e07638 100644 --- a/Tests/MacActivityAppTests/AudioControlCoordinatorTests.swift +++ b/Tests/MacActivityAppTests/AudioControlCoordinatorTests.swift @@ -2273,6 +2273,74 @@ final class AudioControlCoordinatorTests: XCTestCase { 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 @@ -2424,6 +2492,126 @@ final class AudioControlCoordinatorTests: XCTestCase { 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 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 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 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 diff --git a/Tests/MacActivityAppTests/Support/AudioControlComponentFixture.swift b/Tests/MacActivityAppTests/Support/AudioControlComponentFixture.swift index 3c4f73d..0c98789 100644 --- a/Tests/MacActivityAppTests/Support/AudioControlComponentFixture.swift +++ b/Tests/MacActivityAppTests/Support/AudioControlComponentFixture.swift @@ -332,6 +332,7 @@ final class DeviceProviderFake: AudioDeviceControlProviding, AudioRouteDevicePro var onMuteWrite: (@MainActor () -> Void)? var onRouteRead: (@MainActor () -> Void)? var routeReadError: Error? + var outputSnapshotsError: Error? var outputSnapshots: [AudioOutputDeviceSnapshot]? var routeDescriptors: [AudioRouteDevice] = [ @@ -341,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, From 2cbe09832e9f5ede8b85f6bd17659611e6e1494a Mon Sep 17 00:00:00 2001 From: bigtomcat Date: Sat, 18 Jul 2026 00:55:17 +1000 Subject: [PATCH 10/15] fix(audio): preserve readback capability downgrades --- .../Models/AudioControlCoordinator.swift | 50 +++++++--- .../AudioControlCoordinatorTests.swift | 91 +++++++++++++++++++ 2 files changed, 129 insertions(+), 12 deletions(-) diff --git a/Sources/MacActivityApp/Models/AudioControlCoordinator.swift b/Sources/MacActivityApp/Models/AudioControlCoordinator.swift index 867bf4b..ebf1f8b 100644 --- a/Sources/MacActivityApp/Models/AudioControlCoordinator.swift +++ b/Sources/MacActivityApp/Models/AudioControlCoordinator.swift @@ -136,6 +136,11 @@ final class AudioControlCoordinator: AudioControlCoordinating, ObservableObject case muteOnly(Bool) } + private enum DeviceControlReadback { + case volume(Double) + case mute(Bool) + } + @Published private(set) var snapshot: AudioControlSnapshot = .empty let supportsProcessControls: Bool @@ -468,10 +473,7 @@ final class AudioControlCoordinator: AudioControlCoordinating, ObservableObject let muted = try deviceProvider.writeMute(targetMuted, forUID: uid) guard isCurrentDeviceLifetime(uid, objectID: objectID, lifetime: lifetime), let refreshed = confirmedDevices[uid] else { return nil } - let confirmed = Self.device( - refreshed, - mute: .value(muted, isWritable: true) - ) + let confirmed = mergeSuccessfulDeviceReadback(.mute(muted), into: refreshed) confirmedDevices[uid] = confirmed guard isCurrentDeviceIntent(uid, ordinal: ordinal) else { return nil } return confirmed @@ -512,10 +514,7 @@ final class AudioControlCoordinator: AudioControlCoordinating, ObservableObject let volume = try deviceProvider.writeVolume(target.rawVolume, forUID: uid) guard isCurrentDeviceLifetime(uid, objectID: objectID, lifetime: lifetime), let latest = confirmedDevices[uid] else { return nil } - confirmed = Self.device( - latest, - volume: .value(volume, isWritable: true) - ) + confirmed = mergeSuccessfulDeviceReadback(.volume(volume), into: latest) confirmedDevices[uid] = confirmed guard let merged = writableDeviceState(confirmed) else { guard isCurrentDeviceIntent(uid, ordinal: ordinal) else { return nil } @@ -535,16 +534,43 @@ final class AudioControlCoordinator: AudioControlCoordinating, ObservableObject let muted = try deviceProvider.writeMute(target.isMuted, forUID: uid) guard isCurrentDeviceLifetime(uid, objectID: objectID, lifetime: lifetime), let latest = confirmedDevices[uid] else { return nil } - confirmed = Self.device( - latest, - mute: .value(muted, isWritable: true) - ) + 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) { values in let next = AudioEffectiveVolumeState( diff --git a/Tests/MacActivityAppTests/AudioControlCoordinatorTests.swift b/Tests/MacActivityAppTests/AudioControlCoordinatorTests.swift index 6e07638..0632c64 100644 --- a/Tests/MacActivityAppTests/AudioControlCoordinatorTests.swift +++ b/Tests/MacActivityAppTests/AudioControlCoordinatorTests.swift @@ -2522,6 +2522,34 @@ final class AudioControlCoordinatorTests: XCTestCase { 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 @@ -2552,6 +2580,34 @@ final class AudioControlCoordinatorTests: XCTestCase { 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( @@ -2589,6 +2645,41 @@ final class AudioControlCoordinatorTests: XCTestCase { 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 From 775d6bc733a6246a0760c8a945aef09eee04bb6b Mon Sep 17 00:00:00 2001 From: bigtomcat Date: Sat, 18 Jul 2026 01:16:54 +1000 Subject: [PATCH 11/15] feat(audio): animate mute controls --- .../Views/AudioDashboardView.swift | 123 ++++++++++++++++-- .../AudioDashboardViewTests.swift | 55 +++++++- 2 files changed, 163 insertions(+), 15 deletions(-) diff --git a/Sources/MacActivityApp/Views/AudioDashboardView.swift b/Sources/MacActivityApp/Views/AudioDashboardView.swift index 1c9d3ef..8601a9e 100644 --- a/Sources/MacActivityApp/Views/AudioDashboardView.swift +++ b/Sources/MacActivityApp/Views/AudioDashboardView.swift @@ -60,6 +60,105 @@ extension View { } } +struct AudioMuteGlyphPresentation: Equatable { + let isMuted: Bool + let reduceMotion: Bool + + var symbolName: String { "speaker.wave.2.fill" } + var staticSymbolName: String { isMuted ? "speaker.slash.fill" : symbolName } + var slashProgress: CGFloat { isMuted ? 1 : 0 } + var waveOpacity: Double { isMuted ? 0.55 : 1 } + var drawsSlash: Bool { !reduceMotion } +} + +struct AudioVolumeMotionPolicy: Equatable { + let isEditing: Bool + let reduceMotion: Bool + + var animatesProgrammaticChanges: Bool { !isEditing && !reduceMotion } +} + +private struct AudioMuteSlash: Shape { + func path(in rect: CGRect) -> Path { + var path = Path() + path.move(to: CGPoint(x: rect.minX + rect.width * 0.16, y: rect.maxY - rect.height * 0.18)) + path.addLine(to: CGPoint(x: rect.maxX - rect.width * 0.16, y: rect.minY + rect.height * 0.18)) + return path + } +} + +private struct AudioMuteGlyph: View { + let isMuted: Bool + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + private var presentation: AudioMuteGlyphPresentation { + .init(isMuted: isMuted, reduceMotion: reduceMotion) + } + + var body: some View { + Group { + if presentation.drawsSlash { + ZStack { + Image(systemName: presentation.symbolName) + .opacity(presentation.waveOpacity) + + AudioMuteSlash() + .trim(from: 0, to: presentation.slashProgress) + .stroke(style: StrokeStyle(lineWidth: 2.2, lineCap: .round)) + .padding(2) + } + .animation( + .spring(response: 0.22, dampingFraction: 1), + value: presentation.slashProgress + ) + .animation(.easeOut(duration: 0.1), value: presentation.waveOpacity) + } else { + ZStack { + Image(systemName: "speaker.wave.2.fill") + .opacity(isMuted ? 0 : 1) + Image(systemName: "speaker.slash.fill") + .opacity(isMuted ? 1 : 0) + } + .animation(.easeOut(duration: 0.1), 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 AudioAnimatedVolumeSlider: View { + @Binding var value: Double + let accessibility: AudioAccessibilityContract + @State private var isEditing = false + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + private var motionPolicy: AudioVolumeMotionPolicy { + .init(isEditing: isEditing, reduceMotion: reduceMotion) + } + + var body: some View { + Slider(value: $value, in: 0...1, onEditingChanged: { isEditing = $0 }) + .animation( + motionPolicy.animatesProgrammaticChanges + ? .spring(response: 0.22, dampingFraction: 1) + : nil, + value: value + ) + .audioAccessibility(accessibility) + } +} + @MainActor enum AudioDashboardControlBindings { static func deviceVolume( @@ -271,13 +370,12 @@ 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 ) - .audioAccessibility(presentation.volumeAccessibility) case .readOnly(let value): Text(value, format: .percent.precision(.fractionLength(0))) @@ -309,14 +407,13 @@ private struct AudioDeviceControlRow: View { model: model, deviceUID: snapshot.id ) } label: { - Image(systemName: isMuted ? "speaker.slash.fill" : "speaker.wave.2.fill") - .frame(width: 20) + AudioMuteGlyph(isMuted: isMuted) } - .buttonStyle(.plain) + .buttonStyle(AudioMuteButtonStyle()) .audioAccessibility(presentation.muteAccessibility) case .readOnly(let isMuted): - Image(systemName: isMuted ? "speaker.slash.fill" : "speaker.wave.2.fill") + AudioMuteGlyph(isMuted: isMuted) .foregroundStyle(.secondary) .audioAccessibility(presentation.muteAccessibility) @@ -368,20 +465,20 @@ private struct AudioProcessControlRow: View { Spacer(minLength: 12) - Slider(value: volumeBinding, in: 0...1) + AudioAnimatedVolumeSlider( + value: volumeBinding, + accessibility: presentation.volumeAccessibility + ) .frame(maxWidth: 130) - .audioAccessibility(presentation.volumeAccessibility) Button { AudioDashboardControlBindings.toggleProcessMute( model: model, processObjectID: snapshot.id ) } label: { - Image(systemName: presentation.showsMutedIcon - ? "speaker.slash.fill" : "speaker.wave.2.fill") - .frame(width: 20) + AudioMuteGlyph(isMuted: presentation.showsMutedIcon) } - .buttonStyle(.plain) + .buttonStyle(AudioMuteButtonStyle()) .audioAccessibility(presentation.muteAccessibility) routeMenu diff --git a/Tests/MacActivityAppTests/AudioDashboardViewTests.swift b/Tests/MacActivityAppTests/AudioDashboardViewTests.swift index bc78f3b..3c6c09f 100644 --- a/Tests/MacActivityAppTests/AudioDashboardViewTests.swift +++ b/Tests/MacActivityAppTests/AudioDashboardViewTests.swift @@ -37,6 +37,57 @@ final class AudioDashboardViewTests: XCTestCase { XCTAssertEqual(state.settingDisplayVolume(.nan).displayVolume, 1) } + func testMuteGlyphPresentationDrawsAndErasesSlashWithoutChangingTheSpeaker() { + let audible = AudioMuteGlyphPresentation(isMuted: false, reduceMotion: false) + let muted = AudioMuteGlyphPresentation(isMuted: true, reduceMotion: false) + + XCTAssertEqual(audible.symbolName, "speaker.wave.2.fill") + XCTAssertEqual(audible.slashProgress, 0) + XCTAssertEqual(audible.waveOpacity, 1) + XCTAssertTrue(audible.drawsSlash) + + XCTAssertEqual(muted.symbolName, "speaker.wave.2.fill") + XCTAssertEqual(muted.slashProgress, 1) + XCTAssertEqual(muted.waveOpacity, 0.55) + XCTAssertTrue(muted.drawsSlash) + } + + func testMuteGlyphPresentationUsesStaticSymbolCrossfadeForReducedMotion() { + let audible = AudioMuteGlyphPresentation(isMuted: false, reduceMotion: true) + let muted = AudioMuteGlyphPresentation(isMuted: true, reduceMotion: true) + + XCTAssertFalse(audible.drawsSlash) + XCTAssertFalse(muted.drawsSlash) + XCTAssertEqual(audible.staticSymbolName, "speaker.wave.2.fill") + XCTAssertEqual(muted.staticSymbolName, "speaker.slash.fill") + } + + func testVolumeMotionPolicyKeepsDirectManipulationAndReducedMotionStatic() { + XCTAssertFalse(AudioVolumeMotionPolicy(isEditing: true, reduceMotion: false) + .animatesProgrammaticChanges) + XCTAssertFalse(AudioVolumeMotionPolicy(isEditing: false, reduceMotion: true) + .animatesProgrammaticChanges) + XCTAssertTrue(AudioVolumeMotionPolicy(isEditing: false, reduceMotion: false) + .animatesProgrammaticChanges) + } + + func testAudioControlsUseSharedSlashGlyphAndProgrammaticVolumeMotion() throws { + let source = try audioDashboardViewSource() + + for fragment in [ + "struct AudioMuteGlyph", + "struct AudioMuteSlash", + ".trim(from: 0, to: presentation.slashProgress)", + "@Environment(\\.accessibilityReduceMotion)", + "struct AudioAnimatedVolumeSlider", + "AudioMuteButtonStyle" + ] { + XCTAssertTrue(source.contains(fragment), fragment) + } + XCTAssertEqual(source.components(separatedBy: "AudioMuteGlyph(isMuted:").count - 1, 3) + XCTAssertEqual(source.components(separatedBy: "AudioAnimatedVolumeSlider(").count - 1, 2) + } + func testRealViewWiresContractsWithoutAnAccessibilityManifest() throws { let source = try audioDashboardViewSource() @@ -44,9 +95,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), From 094946f3f159969a5578f3431dc8ae59e26a3390 Mon Sep 17 00:00:00 2001 From: bigtomcat Date: Sat, 18 Jul 2026 01:28:58 +1000 Subject: [PATCH 12/15] fix(audio): align mute motion timing --- .../Views/AudioDashboardView.swift | 161 ++++++++++++------ .../AudioDashboardViewTests.swift | 106 +++++++++--- 2 files changed, 188 insertions(+), 79 deletions(-) diff --git a/Sources/MacActivityApp/Views/AudioDashboardView.swift b/Sources/MacActivityApp/Views/AudioDashboardView.swift index 8601a9e..9271f2f 100644 --- a/Sources/MacActivityApp/Views/AudioDashboardView.swift +++ b/Sources/MacActivityApp/Views/AudioDashboardView.swift @@ -60,68 +60,85 @@ 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 symbolName: String { "speaker.wave.2.fill" } - var staticSymbolName: String { isMuted ? "speaker.slash.fill" : symbolName } - var slashProgress: CGFloat { isMuted ? 1 : 0 } - var waveOpacity: Double { isMuted ? 0.55 : 1 } - var drawsSlash: Bool { !reduceMotion } + 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 animatesProgrammaticChanges: Bool { !isEditing && !reduceMotion } -} + var usesSpring: Bool { motion == .mute || motion == .restore } -private struct AudioMuteSlash: Shape { - func path(in rect: CGRect) -> Path { - var path = Path() - path.move(to: CGPoint(x: rect.minX + rect.width * 0.16, y: rect.maxY - rect.height * 0.18)) - path.addLine(to: CGPoint(x: rect.maxX - rect.width * 0.16, y: rect.minY + rect.height * 0.18)) - return path + 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) + .init(isMuted: isMuted, reduceMotion: reduceMotion, motion: motion) } var body: some View { - Group { - if presentation.drawsSlash { - ZStack { - Image(systemName: presentation.symbolName) - .opacity(presentation.waveOpacity) - - AudioMuteSlash() - .trim(from: 0, to: presentation.slashProgress) - .stroke(style: StrokeStyle(lineWidth: 2.2, lineCap: .round)) - .padding(2) - } - .animation( - .spring(response: 0.22, dampingFraction: 1), - value: presentation.slashProgress - ) - .animation(.easeOut(duration: 0.1), value: presentation.waveOpacity) - } else { - ZStack { - Image(systemName: "speaker.wave.2.fill") - .opacity(isMuted ? 0 : 1) - Image(systemName: "speaker.slash.fill") - .opacity(isMuted ? 1 : 0) - } - .animation(.easeOut(duration: 0.1), value: isMuted) - } + 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) } } @@ -140,21 +157,32 @@ private struct AudioMuteButtonStyle: ButtonStyle { private struct AudioAnimatedVolumeSlider: View { @Binding var value: Double let accessibility: AudioAccessibilityContract + let trigger: AudioVolumeMotionTrigger? + let hasWriteFailure: Bool @State private var isEditing = false + @State private var consumedTriggerID: UInt64? @Environment(\.accessibilityReduceMotion) private var reduceMotion private var motionPolicy: AudioVolumeMotionPolicy { - .init(isEditing: isEditing, reduceMotion: reduceMotion) + .init(isEditing: isEditing, reduceMotion: reduceMotion, motion: motion) + } + + private var motion: AudioVolumeMotion { + AudioVolumeMotionSelection.resolve( + trigger: trigger, + consumedTriggerID: consumedTriggerID, + hasWriteFailure: hasWriteFailure + ) } var body: some View { Slider(value: $value, in: 0...1, onEditingChanged: { isEditing = $0 }) - .animation( - motionPolicy.animatesProgrammaticChanges - ? .spring(response: 0.22, dampingFraction: 1) - : nil, - value: value - ) + .animation(motionPolicy.animation, value: value) + .onChange(of: value) { _ in + if let trigger, trigger.id != consumedTriggerID { + consumedTriggerID = trigger.id + } + } .audioAccessibility(accessibility) } } @@ -333,8 +361,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) { @@ -374,7 +406,9 @@ private struct AudioDeviceControlRow: View { value: AudioDashboardControlBindings.deviceVolume( model: model, deviceUID: snapshot.id, fallback: value ), - accessibility: presentation.volumeAccessibility + accessibility: presentation.volumeAccessibility, + trigger: muteMotion, + hasWriteFailure: snapshot.error != nil ) case .readOnly(let value): @@ -403,17 +437,18 @@ private struct AudioDeviceControlRow: View { switch presentation.mute { case .button(let isMuted, _): Button { + recordMuteToggle(from: isMuted) AudioDashboardControlBindings.toggleDeviceMute( model: model, deviceUID: snapshot.id ) } label: { - AudioMuteGlyph(isMuted: isMuted) + AudioMuteGlyph(isMuted: isMuted, motion: muteVisualMotion) } .buttonStyle(AudioMuteButtonStyle()) .audioAccessibility(presentation.muteAccessibility) case .readOnly(let isMuted): - AudioMuteGlyph(isMuted: isMuted) + AudioMuteGlyph(isMuted: isMuted, motion: muteVisualMotion) .foregroundStyle(.secondary) .audioAccessibility(presentation.muteAccessibility) @@ -448,13 +483,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) { @@ -467,16 +513,22 @@ private struct AudioProcessControlRow: View { AudioAnimatedVolumeSlider( value: volumeBinding, - accessibility: presentation.volumeAccessibility + accessibility: presentation.volumeAccessibility, + trigger: muteMotion, + hasWriteFailure: snapshot.error != nil ) .frame(maxWidth: 130) Button { + recordMuteToggle(from: presentation.showsMutedIcon) AudioDashboardControlBindings.toggleProcessMute( model: model, processObjectID: snapshot.id ) } label: { - AudioMuteGlyph(isMuted: presentation.showsMutedIcon) + AudioMuteGlyph( + isMuted: presentation.showsMutedIcon, + motion: muteVisualMotion + ) } .buttonStyle(AudioMuteButtonStyle()) .audioAccessibility(presentation.muteAccessibility) @@ -505,6 +557,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 { diff --git a/Tests/MacActivityAppTests/AudioDashboardViewTests.swift b/Tests/MacActivityAppTests/AudioDashboardViewTests.swift index 3c6c09f..7d0ab69 100644 --- a/Tests/MacActivityAppTests/AudioDashboardViewTests.swift +++ b/Tests/MacActivityAppTests/AudioDashboardViewTests.swift @@ -37,54 +37,104 @@ final class AudioDashboardViewTests: XCTestCase { XCTAssertEqual(state.settingDisplayVolume(.nan).displayVolume, 1) } - func testMuteGlyphPresentationDrawsAndErasesSlashWithoutChangingTheSpeaker() { - let audible = AudioMuteGlyphPresentation(isMuted: false, reduceMotion: false) - let muted = AudioMuteGlyphPresentation(isMuted: true, reduceMotion: false) + 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.symbolName, "speaker.wave.2.fill") - XCTAssertEqual(audible.slashProgress, 0) XCTAssertEqual(audible.waveOpacity, 1) - XCTAssertTrue(audible.drawsSlash) + XCTAssertEqual(audible.mutedOpacity, 0) + XCTAssertEqual(audible.crossfadeDuration, 0.14) - XCTAssertEqual(muted.symbolName, "speaker.wave.2.fill") - XCTAssertEqual(muted.slashProgress, 1) - XCTAssertEqual(muted.waveOpacity, 0.55) - XCTAssertTrue(muted.drawsSlash) + XCTAssertEqual(muted.waveOpacity, 0) + XCTAssertEqual(muted.mutedOpacity, 1) + XCTAssertEqual(muted.crossfadeDuration, 0.16) + + XCTAssertEqual(reduced.crossfadeDuration, 0.1) } - func testMuteGlyphPresentationUsesStaticSymbolCrossfadeForReducedMotion() { - let audible = AudioMuteGlyphPresentation(isMuted: false, reduceMotion: true) - let muted = AudioMuteGlyphPresentation(isMuted: true, reduceMotion: true) + 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) - XCTAssertFalse(audible.drawsSlash) - XCTAssertFalse(muted.drawsSlash) - XCTAssertEqual(audible.staticSymbolName, "speaker.wave.2.fill") - XCTAssertEqual(muted.staticSymbolName, "speaker.slash.fill") + 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 testVolumeMotionPolicyKeepsDirectManipulationAndReducedMotionStatic() { - XCTAssertFalse(AudioVolumeMotionPolicy(isEditing: true, reduceMotion: false) - .animatesProgrammaticChanges) - XCTAssertFalse(AudioVolumeMotionPolicy(isEditing: false, reduceMotion: true) - .animatesProgrammaticChanges) - XCTAssertTrue(AudioVolumeMotionPolicy(isEditing: false, reduceMotion: false) - .animatesProgrammaticChanges) + 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 testAudioControlsUseSharedSlashGlyphAndProgrammaticVolumeMotion() throws { + func testAudioControlsUseSharedCrossfadeGlyphAndSourceAwareVolumeMotion() throws { let source = try audioDashboardViewSource() for fragment in [ "struct AudioMuteGlyph", - "struct AudioMuteSlash", - ".trim(from: 0, to: presentation.slashProgress)", + ".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(isMuted:").count - 1, 3) + XCTAssertEqual(source.components(separatedBy: "AudioMuteGlyph(").count - 1, 3) XCTAssertEqual(source.components(separatedBy: "AudioAnimatedVolumeSlider(").count - 1, 2) } From 2b33fe0d11c1cba480ed0679140dc80f7ca75d9a Mon Sep 17 00:00:00 2001 From: bigtomcat Date: Sat, 18 Jul 2026 01:38:22 +1000 Subject: [PATCH 13/15] fix(audio): center unavailable volume status --- Sources/MacActivityApp/Views/AudioDashboardView.swift | 2 +- Tests/MacActivityAppTests/AudioDashboardViewTests.swift | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/Sources/MacActivityApp/Views/AudioDashboardView.swift b/Sources/MacActivityApp/Views/AudioDashboardView.swift index 9271f2f..d25f56d 100644 --- a/Sources/MacActivityApp/Views/AudioDashboardView.swift +++ b/Sources/MacActivityApp/Views/AudioDashboardView.swift @@ -377,7 +377,7 @@ private struct AudioDeviceControlRow: View { Spacer(minLength: 12) volumeControl - .frame(maxWidth: 150) + .frame(width: 150, alignment: .center) muteControl } diff --git a/Tests/MacActivityAppTests/AudioDashboardViewTests.swift b/Tests/MacActivityAppTests/AudioDashboardViewTests.swift index 7d0ab69..82f1c18 100644 --- a/Tests/MacActivityAppTests/AudioDashboardViewTests.swift +++ b/Tests/MacActivityAppTests/AudioDashboardViewTests.swift @@ -138,6 +138,13 @@ final class AudioDashboardViewTests: XCTestCase { XCTAssertEqual(source.components(separatedBy: "AudioAnimatedVolumeSlider(").count - 1, 2) } + func testDeviceVolumeControlReservesAndCentersTheSliderLaneForUnavailableText() throws { + let source = try audioDashboardViewSource() + + XCTAssertTrue(source.contains(".frame(width: 150, alignment: .center)")) + XCTAssertFalse(source.contains(".frame(maxWidth: 150)")) + } + func testRealViewWiresContractsWithoutAnAccessibilityManifest() throws { let source = try audioDashboardViewSource() From 1c1db75dd8105140bfad47d3476ef80aa07d9a38 Mon Sep 17 00:00:00 2001 From: bigtomcat Date: Sat, 18 Jul 2026 01:47:48 +1000 Subject: [PATCH 14/15] feat(audio): animate mute volume transitions --- .../Views/AudioDashboardView.swift | 78 +++++++++++++++++-- .../AudioDashboardViewTests.swift | 15 +++- 2 files changed, 84 insertions(+), 9 deletions(-) diff --git a/Sources/MacActivityApp/Views/AudioDashboardView.swift b/Sources/MacActivityApp/Views/AudioDashboardView.swift index d25f56d..390e633 100644 --- a/Sources/MacActivityApp/Views/AudioDashboardView.swift +++ b/Sources/MacActivityApp/Views/AudioDashboardView.swift @@ -154,15 +154,51 @@ private struct AudioMuteButtonStyle: ButtonStyle { } } +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) } @@ -176,14 +212,40 @@ private struct AudioAnimatedVolumeSlider: View { } var body: some View { - Slider(value: $value, in: 0...1, onEditingChanged: { isEditing = $0 }) - .animation(motionPolicy.animation, value: value) - .onChange(of: value) { _ in - if let trigger, trigger.id != consumedTriggerID { - consumedTriggerID = trigger.id - } + 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 } - .audioAccessibility(accessibility) + } + .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 + } + } } } @@ -377,7 +439,7 @@ private struct AudioDeviceControlRow: View { Spacer(minLength: 12) volumeControl - .frame(width: 150, alignment: .center) + .frame(width: 150, height: 20, alignment: .center) muteControl } diff --git a/Tests/MacActivityAppTests/AudioDashboardViewTests.swift b/Tests/MacActivityAppTests/AudioDashboardViewTests.swift index 82f1c18..0f85be6 100644 --- a/Tests/MacActivityAppTests/AudioDashboardViewTests.swift +++ b/Tests/MacActivityAppTests/AudioDashboardViewTests.swift @@ -141,10 +141,23 @@ final class AudioDashboardViewTests: XCTestCase { func testDeviceVolumeControlReservesAndCentersTheSliderLaneForUnavailableText() throws { let source = try audioDashboardViewSource() - XCTAssertTrue(source.contains(".frame(width: 150, alignment: .center)")) + 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() From 32a600da3a5b48d62742e8f550654cfbd29204a7 Mon Sep 17 00:00:00 2001 From: bigtomcat Date: Sat, 18 Jul 2026 13:41:45 +1000 Subject: [PATCH 15/15] fix(audio): allow retrying failed process intent --- .../Models/AudioControlCoordinator.swift | 3 ++- .../AudioControlCoordinatorTests.swift | 22 +++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/Sources/MacActivityApp/Models/AudioControlCoordinator.swift b/Sources/MacActivityApp/Models/AudioControlCoordinator.swift index ebf1f8b..aa07cf4 100644 --- a/Sources/MacActivityApp/Models/AudioControlCoordinator.swift +++ b/Sources/MacActivityApp/Models/AudioControlCoordinator.swift @@ -894,9 +894,10 @@ private extension AudioControlCoordinator { route: row.route ) let originalValues = values + let shouldRetryFailedIntent = row.error != nil let previousRoute = values.route mutate(&values) - guard values != originalValues else { return } + guard values != originalValues || shouldRetryFailedIntent else { return } let routeOptions = values.route == previousRoute ? row.routeOptions : makeRouteOptions(for: values.route, process: row.process) diff --git a/Tests/MacActivityAppTests/AudioControlCoordinatorTests.swift b/Tests/MacActivityAppTests/AudioControlCoordinatorTests.swift index 0632c64..39f68a2 100644 --- a/Tests/MacActivityAppTests/AudioControlCoordinatorTests.swift +++ b/Tests/MacActivityAppTests/AudioControlCoordinatorTests.swift @@ -841,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()