diff --git a/DynamicIsland/MediaControllers/AmazonMusicController.swift b/DynamicIsland/MediaControllers/AmazonMusicController.swift index 9b830ca1..0e3c03d1 100644 --- a/DynamicIsland/MediaControllers/AmazonMusicController.swift +++ b/DynamicIsland/MediaControllers/AmazonMusicController.swift @@ -55,6 +55,10 @@ class FilteredNowPlayingController: ObservableObject, MediaControllerProtocol { /// True only after a stream line explicitly identified the selected app as the now playing source. private var targetSessionActive = false + /// Reused across every stream update to avoid re-allocating a formatter + /// on each (1+/second) now playing payload. + private static let iso8601Formatter = ISO8601DateFormatter() + init?(bundleIdentifier: String, controllerName: String) { self.targetBundleIdentifier = bundleIdentifier self.controllerName = controllerName @@ -136,14 +140,18 @@ class FilteredNowPlayingController: ObservableObject, MediaControllerProtocol { } func toggleShuffle() async { - MRMediaRemoteSetShuffleModeFunction(playbackState.isShuffled ? 1 : 3) - playbackState.isShuffled.toggle() + await MainActor.run { + MRMediaRemoteSetShuffleModeFunction(playbackState.isShuffled ? 1 : 3) + playbackState.isShuffled.toggle() + } } func toggleRepeat() async { - let newRepeatMode = (playbackState.repeatMode == .off) ? 3 : (playbackState.repeatMode.rawValue - 1) - playbackState.repeatMode = RepeatMode(rawValue: newRepeatMode) ?? .off - MRMediaRemoteSetRepeatModeFunction(newRepeatMode) + await MainActor.run { + let newRepeatMode = (playbackState.repeatMode == .off) ? 3 : (playbackState.repeatMode.rawValue - 1) + playbackState.repeatMode = RepeatMode(rawValue: newRepeatMode) ?? .off + MRMediaRemoteSetRepeatModeFunction(newRepeatMode) + } } private func setupNowPlayingObserver() async { @@ -214,9 +222,12 @@ class FilteredNowPlayingController: ObservableObject, MediaControllerProtocol { return state } - private func applyIdleBecauseDifferentSource() { + private func applyIdleBecauseDifferentSource() async { targetSessionActive = false - playbackState = Self.makeIdlePlaybackState(bundleIdentifier: targetBundleIdentifier) + let idleState = Self.makeIdlePlaybackState(bundleIdentifier: targetBundleIdentifier) + await MainActor.run { [weak self] in + self?.playbackState = idleState + } } private func handleAdapterUpdate(_ update: NowPlayingUpdate) async { @@ -233,12 +244,12 @@ class FilteredNowPlayingController: ObservableObject, MediaControllerProtocol { if let source = explicitSource { if source != targetBundleIdentifier { - applyIdleBecauseDifferentSource() + await applyIdleBecauseDifferentSource() return } targetSessionActive = true } else if !diff { - applyIdleBecauseDifferentSource() + await applyIdleBecauseDifferentSource() return } else if !targetSessionActive { return @@ -276,7 +287,7 @@ class FilteredNowPlayingController: ObservableObject, MediaControllerProtocol { } if let dateString = payload.timestamp, - let date = ISO8601DateFormatter().date(from: dateString) { + let date = Self.iso8601Formatter.date(from: dateString) { newPlaybackState.lastUpdated = date } else if !diff { newPlaybackState.lastUpdated = Date() @@ -288,7 +299,9 @@ class FilteredNowPlayingController: ObservableObject, MediaControllerProtocol { newPlaybackState.isPlaying = payload.playing ?? (diff ? self.playbackState.isPlaying : false) newPlaybackState.bundleIdentifier = targetBundleIdentifier - self.playbackState = newPlaybackState + await MainActor.run { [weak self] in + self?.playbackState = newPlaybackState + } } } diff --git a/DynamicIsland/MediaControllers/AppleMusicController.swift b/DynamicIsland/MediaControllers/AppleMusicController.swift index 554b3eff..1c3bf9e4 100644 --- a/DynamicIsland/MediaControllers/AppleMusicController.swift +++ b/DynamicIsland/MediaControllers/AppleMusicController.swift @@ -162,7 +162,10 @@ class AppleMusicController: MediaControllerProtocol { } updatedState.lastUpdated = Date() - self.playbackState = updatedState + let finalState = updatedState + await MainActor.run { [weak self] in + self?.playbackState = finalState + } } // MARK: - Private Methods diff --git a/DynamicIsland/MediaControllers/NowPlayingController.swift b/DynamicIsland/MediaControllers/NowPlayingController.swift index 1b863b79..1020bd99 100644 --- a/DynamicIsland/MediaControllers/NowPlayingController.swift +++ b/DynamicIsland/MediaControllers/NowPlayingController.swift @@ -43,6 +43,10 @@ final class NowPlayingController: ObservableObject, MediaControllerProtocol { private var lastMusicItem: (title: String, artist: String, album: String, duration: TimeInterval, artworkData: Data?)? + /// Reused across every stream update to avoid re-allocating a formatter + /// on each (1+/second) now playing payload. + private static let iso8601Formatter = ISO8601DateFormatter() + // MARK: - Media Remote Functions private let mediaRemoteBundle: CFBundle private let MRMediaRemoteSendCommandFunction: @convention(c) (Int, AnyObject?) -> Void @@ -134,15 +138,19 @@ final class NowPlayingController: ObservableObject, MediaControllerProtocol { func toggleShuffle() async { // MRMediaRemoteSendCommandFunction(6, nil) - MRMediaRemoteSetShuffleModeFunction(playbackState.isShuffled ? 1 : 3) - playbackState.isShuffled.toggle() + await MainActor.run { + MRMediaRemoteSetShuffleModeFunction(playbackState.isShuffled ? 1 : 3) + playbackState.isShuffled.toggle() + } } - + func toggleRepeat() async { // MRMediaRemoteSendCommandFunction(7, nil) - let newRepeatMode = (playbackState.repeatMode == .off) ? 3 : (playbackState.repeatMode.rawValue - 1) - playbackState.repeatMode = RepeatMode(rawValue: newRepeatMode) ?? .off - MRMediaRemoteSetRepeatModeFunction(newRepeatMode) + await MainActor.run { + let newRepeatMode = (playbackState.repeatMode == .off) ? 3 : (playbackState.repeatMode.rawValue - 1) + playbackState.repeatMode = RepeatMode(rawValue: newRepeatMode) ?? .off + MRMediaRemoteSetRepeatModeFunction(newRepeatMode) + } } // MARK: - Setup Methods @@ -243,7 +251,7 @@ final class NowPlayingController: ObservableObject, MediaControllerProtocol { } if let dateString = payload.timestamp, - let date = ISO8601DateFormatter().date(from: dateString) { + let date = Self.iso8601Formatter.date(from: dateString) { newPlaybackState.lastUpdated = date } else if !diff { newPlaybackState.lastUpdated = Date() @@ -258,8 +266,10 @@ final class NowPlayingController: ObservableObject, MediaControllerProtocol { payload.bundleIdentifier ?? (diff ? self.playbackState.bundleIdentifier : "") ) - - self.playbackState = newPlaybackState + + await MainActor.run { [weak self] in + self?.playbackState = newPlaybackState + } } } diff --git a/DynamicIsland/MediaControllers/SpotifyController.swift b/DynamicIsland/MediaControllers/SpotifyController.swift index 289a9e14..9d7656cd 100644 --- a/DynamicIsland/MediaControllers/SpotifyController.swift +++ b/DynamicIsland/MediaControllers/SpotifyController.swift @@ -87,7 +87,8 @@ class SpotifyController: MediaControllerProtocol { self?.artistMetadataFetchTask?.cancel() self?.artistMetadataFetchTask = nil self?.currentArtistTrackURI = nil - if let self { + Task { @MainActor [weak self] in + guard let self else { return } var updatedState = self.playbackState updatedState.liveArtworkURL = nil self.playbackState = updatedState @@ -195,7 +196,10 @@ class SpotifyController: MediaControllerProtocol { artistMetadataFetchTask = nil } - playbackState = state + let resolvedState = state + await MainActor.run { [weak self] in + self?.playbackState = resolvedState + } if !trackURI.isEmpty { if cachedArtistResult == nil { diff --git a/DynamicIsland/MediaControllers/YouTube Music Controller/YouTubeMusicController.swift b/DynamicIsland/MediaControllers/YouTube Music Controller/YouTubeMusicController.swift index 34add634..89ec4673 100644 --- a/DynamicIsland/MediaControllers/YouTube Music Controller/YouTubeMusicController.swift +++ b/DynamicIsland/MediaControllers/YouTube Music Controller/YouTubeMusicController.swift @@ -95,7 +95,9 @@ final class YouTubeMusicController: MediaControllerProtocol { func updatePlaybackInfo() async { guard isActive() else { - resetPlaybackState() + await MainActor.run { [weak self] in + self?.resetPlaybackState() + } return } @@ -161,8 +163,10 @@ final class YouTubeMusicController: MediaControllerProtocol { await webSocketClient?.disconnect() webSocketClient = nil } - - resetPlaybackState() + + await MainActor.run { [weak self] in + self?.resetPlaybackState() + } } private func initializeIfAppActive() async { @@ -237,33 +241,49 @@ final class YouTubeMusicController: MediaControllerProtocol { } guard let newPosition = position else { return } - var copied = playbackState - copied.currentTime = newPosition - copied.lastUpdated = Date() - playbackState = copied + await MainActor.run { [weak self] in + guard let self else { return } + var copied = self.playbackState + copied.currentTime = newPosition + copied.lastUpdated = Date() + self.playbackState = copied + } case .repeatChanged: guard let data = message.extractData() else { return } - var copy = playbackState + var newRepeatMode: RepeatMode? = nil if let repeatStr = data["repeat"] as? String { switch repeatStr.uppercased() { - case "NONE": copy.repeatMode = .off - case "ALL": copy.repeatMode = .all - case "ONE": copy.repeatMode = .one + case "NONE": newRepeatMode = .off + case "ALL": newRepeatMode = .all + case "ONE": newRepeatMode = .one default: break } } - copy.lastUpdated = Date() - playbackState = copy + + await MainActor.run { [weak self] in + guard let self else { return } + var copy = self.playbackState + if let newRepeatMode { copy.repeatMode = newRepeatMode } + copy.lastUpdated = Date() + self.playbackState = copy + } case .shuffleChanged: guard let data = message.extractData() else { return } - var copy = playbackState - if let shuffle = data["shuffle"] as? Bool { copy.isShuffled = shuffle } - else if let shuffle = data["isShuffled"] as? Bool { copy.isShuffled = shuffle } - copy.lastUpdated = Date() - playbackState = copy + + var newShuffle: Bool? = nil + if let shuffle = data["shuffle"] as? Bool { newShuffle = shuffle } + else if let shuffle = data["isShuffled"] as? Bool { newShuffle = shuffle } + + await MainActor.run { [weak self] in + guard let self else { return } + var copy = self.playbackState + if let newShuffle { copy.isShuffled = newShuffle } + copy.lastUpdated = Date() + self.playbackState = copy + } case .volumeChanged: break @@ -339,24 +359,40 @@ final class YouTubeMusicController: MediaControllerProtocol { ) // Lightweight endpoint-specific parsing if endpoint == "/shuffle" { - if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let shuffleState = json["state"] as? Bool { - playbackState.isShuffled = shuffleState - } else { - playbackState.isShuffled = !playbackState.isShuffled + var shuffleState: Bool? = nil + if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let state = json["state"] as? Bool { + shuffleState = state + } + await MainActor.run { [weak self] in + guard let self else { return } + if let shuffleState { + self.playbackState.isShuffled = shuffleState + } else { + self.playbackState.isShuffled = !self.playbackState.isShuffled + } } } else if endpoint == "/repeat-mode" { + var mode: String? = nil if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { - if let mode = json["mode"] as? String { updateRepeatMode(mode) } + mode = json["mode"] as? String + } + if let mode { + await MainActor.run { [weak self] in + self?.updateRepeatMode(mode) + } } } else if endpoint == "/switch-repeat" { - // Find next repeat mode - let nextMode: RepeatMode - switch playbackState.repeatMode { - case .off: nextMode = .all - case .all: nextMode = .one - case .one: nextMode = .off + await MainActor.run { [weak self] in + guard let self else { return } + // Find next repeat mode + let nextMode: RepeatMode + switch self.playbackState.repeatMode { + case .off: nextMode = .all + case .all: nextMode = .one + case .one: nextMode = .off + } + self.playbackState.repeatMode = nextMode } - playbackState.repeatMode = nextMode } else if refresh && webSocketClient == nil { try? await Task.sleep(for: .milliseconds(100)) await updatePlaybackInfo() @@ -409,7 +445,10 @@ final class YouTubeMusicController: MediaControllerProtocol { } // Always update - removed comparison since PlaybackState doesn't conform to Equatable - playbackState = newState + let resolvedState = newState + await MainActor.run { [weak self] in + self?.playbackState = resolvedState + } artworkFetchTask?.cancel() artworkFetchTask = nil diff --git a/DynamicIsland/components/Shelf/Services/ThumbnailService.swift b/DynamicIsland/components/Shelf/Services/ThumbnailService.swift index 8b206531..ff42beef 100644 --- a/DynamicIsland/components/Shelf/Services/ThumbnailService.swift +++ b/DynamicIsland/components/Shelf/Services/ThumbnailService.swift @@ -28,42 +28,71 @@ import UniformTypeIdentifiers actor ThumbnailService { static let shared = ThumbnailService() - private var cache: [String: NSImage] = [:] + private let cache: NSCache = { + let cache = NSCache() + cache.countLimit = 200 + cache.totalCostLimit = 64 * 1024 * 1024 // 64 MB + return cache + }() + // NSCache cannot enumerate its keys, so we track them separately to + // support path-based invalidation in clearCache(for:). + private var cacheKeys: Set = [] private var pendingRequests: [String: Task] = [:] private let thumbnailGenerator = QLThumbnailGenerator.shared private init() {} - + func thumbnail(for url: URL, size: CGSize) async -> NSImage? { let cacheKey = "\(url.path)_\(size.width)x\(size.height)" - - if let cached = cache[cacheKey] { + + if let cached = cache.object(forKey: cacheKey as NSString) { return cached } - + if let pending = pendingRequests[cacheKey] { return await pending.value } - + let task = Task { let thumbnail = await generateQuickLookThumbnail(for: url, size: size) if let thumbnail = thumbnail { - cache[cacheKey] = thumbnail + cache.setObject(thumbnail, forKey: cacheKey as NSString, cost: estimatedCost(of: thumbnail)) + cacheKeys.insert(cacheKey) + // NSCache evicts silently, so `cacheKeys` would grow unbounded. + // Reconcile against the cache once it exceeds the count limit, + // dropping keys whose objects are already gone. + if cacheKeys.count > 256 { + cacheKeys = cacheKeys.filter { cache.object(forKey: $0 as NSString) != nil } + } } pendingRequests[cacheKey] = nil return thumbnail } - + pendingRequests[cacheKey] = task return await task.value } - + func clearCache() { - cache.removeAll() + cache.removeAllObjects() + cacheKeys.removeAll() } - + func clearCache(for url: URL) { - cache = cache.filter { !$0.key.starts(with: url.path) } + // Keys are "_x"; match on the "_" boundary so that + // invalidating /tmp/a does not also evict /tmp/ab's thumbnails. + let prefix = "\(url.path)_" + let keysToRemove = cacheKeys.filter { $0.hasPrefix(prefix) } + for key in keysToRemove { + cache.removeObject(forKey: key as NSString) + cacheKeys.remove(key) + } + } + + /// Rough byte estimate (width * height * 4 bytes/pixel) used as the NSCache cost. + private func estimatedCost(of image: NSImage) -> Int { + let size = image.size + return max(1, Int(size.width * size.height * 4)) } // MARK: - Private Methods diff --git a/DynamicIsland/managers/BatteryActivityManager.swift b/DynamicIsland/managers/BatteryActivityManager.swift index a70d807f..742b7308 100644 --- a/DynamicIsland/managers/BatteryActivityManager.swift +++ b/DynamicIsland/managers/BatteryActivityManager.swift @@ -37,7 +37,8 @@ class BatteryActivityManager { var onTimeToFullChargeChange: ((Int) -> Void)? private var batterySource: CFRunLoopSource? - private var observers: [(BatteryEvent) -> Void] = [] + private var observers: [Int: (BatteryEvent) -> Void] = [:] + private var nextObserverToken: Int = 0 private var previousBatteryInfo: BatteryInfo? private var notificationQueue: [BatteryEvent] = [] private var isProcessingNotifications = false @@ -304,15 +305,16 @@ class BatteryActivityManager { /// - Parameter observer: The observer closure to be called on battery events /// - Returns: The ID of the observer for later removal func addObserver(_ observer: @escaping (BatteryEvent) -> Void) -> Int { - observers.append(observer) - return observers.count - 1 + let token = nextObserverToken + nextObserverToken += 1 + observers[token] = observer + return token } /// Removes an observer by its ID /// - Parameter id: The ID of the observer to be removed func removeObserver(byId id: Int) { - guard id >= 0 && id < observers.count else { return } - observers.remove(at: id) + observers.removeValue(forKey: id) } /// Notifies all observers of a battery event @@ -320,7 +322,9 @@ class BatteryActivityManager { private func notifyObservers(event: BatteryEvent) { DispatchQueue.main.async { [weak self] in guard let self = self else { return } - for observer in self.observers { + // Snapshot before invoking: an observer may add/remove observers + // from inside its callback, which would mutate `observers` mid-iteration. + for observer in Array(self.observers.values) { observer(event) } } diff --git a/DynamicIsland/managers/StatsManager.swift b/DynamicIsland/managers/StatsManager.swift index 7abe47ec..50f4a9df 100644 --- a/DynamicIsland/managers/StatsManager.swift +++ b/DynamicIsland/managers/StatsManager.swift @@ -478,7 +478,7 @@ class StatsManager: ObservableObject { diskWriteHistory = Array(repeating: 0.0, count: maxHistoryPoints) // Initialize baseline network stats - let initialStats = getNetworkStats() + let initialStats = aggregateNetworkStats(from: snapshotNetworkInterfaces()) previousNetworkStats = initialStats previousTimestamp = Date() @@ -554,7 +554,7 @@ class StatsManager: ObservableObject { print("StatsManager: Starting monitoring...") // Reset baseline for accurate measurement - let initialStats = getNetworkStats() + let initialStats = aggregateNetworkStats(from: snapshotNetworkInterfaces()) previousNetworkStats = initialStats let initialDiskStats = getDiskStats() @@ -672,8 +672,14 @@ class StatsManager: ObservableObject { let newGpuUsage = gpuSnapshot.usage let coreUsage = collectCPUCoreUsage() - // Calculate network speeds - let currentNetworkStats = getNetworkStats() + // Calculate network speeds (single getifaddrs walk feeds both totals and per-interface metrics) + let networkSnapshots = snapshotNetworkInterfaces() + // On a getifaddrs failure (nil snapshot) preserve the prior baseline + // instead of collapsing to (0,0), which would both fake a speed dip and + // wipe previousNetworkStats when it is assigned below. + let currentNetworkStats = networkSnapshots != nil + ? aggregateNetworkStats(from: networkSnapshots) + : previousNetworkStats let currentTime = Date() let timeInterval = currentTime.timeIntervalSince(previousTimestamp) @@ -771,9 +777,11 @@ class StatsManager: ObservableObject { previousNetworkStats = currentNetworkStats previousDiskStats = currentDiskStats previousTimestamp = currentTime - networkInterfaces = collectNetworkInterfaces(deltaTime: timeInterval) + networkInterfaces = collectNetworkInterfaces(from: networkSnapshots, deltaTime: timeInterval) diskDevices = collectDiskDevices() - refreshProcessStatsIfNeeded(force: true) + // Periodic path: honor the throttle so /bin/ps runs at the intended 0.5Hz, not ~1Hz. + // force: true is reserved for explicit user-triggered manual refreshes. + refreshProcessStatsIfNeeded(force: false) } private func updateHistory(value: Double, history: inout [Double]) { @@ -1027,26 +1035,29 @@ class StatsManager: ObservableObject { return usages } - private func collectNetworkInterfaces(deltaTime: TimeInterval) -> [NetworkInterfaceMetrics] { + private struct NetworkInterfaceSnapshot { + var name: String + var flags: UInt32 + var bytesIn: UInt64 + var bytesOut: UInt64 + var ipv4: String? + var ipv6: String? + } + + /// Performs a single `getifaddrs` walk and accumulates per-interface counters/addresses. + /// Returns `nil` if the syscall fails so callers can preserve prior state. + private func snapshotNetworkInterfaces() -> [NetworkInterfaceSnapshot]? { var interfacesPointer: UnsafeMutablePointer? = nil guard getifaddrs(&interfacesPointer) == 0, let startPointer = interfacesPointer else { - return networkInterfaces + return nil } defer { freeifaddrs(startPointer) } - struct InterfaceAccumulator { - var name: String - var flags: UInt32 - var bytesIn: UInt64 - var bytesOut: UInt64 - var ipv4: String? - var ipv6: String? - } - var accumulators: [String: InterfaceAccumulator] = [:] + var accumulators: [String: NetworkInterfaceSnapshot] = [:] var pointer: UnsafeMutablePointer? = startPointer while let current = pointer { let interface = current.pointee let name = String(cString: interface.ifa_name) - var accumulator = accumulators[name] ?? InterfaceAccumulator(name: name, flags: interface.ifa_flags, bytesIn: 0, bytesOut: 0, ipv4: nil, ipv6: nil) + var accumulator = accumulators[name] ?? NetworkInterfaceSnapshot(name: name, flags: interface.ifa_flags, bytesIn: 0, bytesOut: 0, ipv4: nil, ipv6: nil) if let addr = interface.ifa_addr { switch Int32(addr.pointee.sa_family) { case AF_LINK: @@ -1065,9 +1076,40 @@ class StatsManager: ObservableObject { accumulators[name] = accumulator pointer = interface.ifa_next } + return Array(accumulators.values) + } + + /// Aggregates total in/out bytes across physical (en*/Wi-Fi) interfaces from a snapshot. + private func aggregateNetworkStats(from snapshots: [NetworkInterfaceSnapshot]?) -> (bytesIn: UInt64, bytesOut: UInt64) { + var totalBytesIn: UInt64 = 0 + var totalBytesOut: UInt64 = 0 + guard let snapshots else { return (totalBytesIn, totalBytesOut) } + for snapshot in snapshots { + let name = snapshot.name + // Skip loopback and virtual interfaces, but include en0, en1, etc. and Wi-Fi interfaces + guard !name.hasPrefix("lo") && + !name.hasPrefix("gif") && + !name.hasPrefix("stf") && + !name.hasPrefix("bridge") && + !name.hasPrefix("utun") && + !name.hasPrefix("awdl") else { + continue + } + if name.hasPrefix("en") || name.contains("Wi-Fi") { + totalBytesIn += snapshot.bytesIn + totalBytesOut += snapshot.bytesOut + } + } + return (totalBytesIn, totalBytesOut) + } + + private func collectNetworkInterfaces(from snapshots: [NetworkInterfaceSnapshot]?, deltaTime: TimeInterval) -> [NetworkInterfaceMetrics] { + guard let snapshots else { + return networkInterfaces + } var results: [NetworkInterfaceMetrics] = [] var updatedCounters: [String: (bytesIn: UInt64, bytesOut: UInt64)] = [:] - for accumulator in accumulators.values { + for accumulator in snapshots { guard shouldIncludeInterface(name: accumulator.name) else { continue } let previous = previousInterfaceCounters[accumulator.name] ?? (accumulator.bytesIn, accumulator.bytesOut) let deltaIn = accumulator.bytesIn >= previous.bytesIn ? accumulator.bytesIn - previous.bytesIn : 0 @@ -1251,50 +1293,6 @@ class StatsManager: ObservableObject { } } - private func getNetworkStats() -> (bytesIn: UInt64, bytesOut: UInt64) { - // Use BSD sockets to get network interface statistics - var totalBytesIn: UInt64 = 0 - var totalBytesOut: UInt64 = 0 - - var ifaddrs: UnsafeMutablePointer? - guard getifaddrs(&ifaddrs) == 0 else { - return (totalBytesIn, totalBytesOut) - } - - defer { freeifaddrs(ifaddrs) } - - var ptr = ifaddrs - while ptr != nil { - defer { ptr = ptr?.pointee.ifa_next } - - guard let interface = ptr?.pointee, - interface.ifa_addr.pointee.sa_family == UInt8(AF_LINK) else { - continue - } - - let name = String(cString: interface.ifa_name) - // Skip loopback and virtual interfaces, but include en0, en1, etc. and Wi-Fi interfaces - guard !name.hasPrefix("lo") && - !name.hasPrefix("gif") && - !name.hasPrefix("stf") && - !name.hasPrefix("bridge") && - !name.hasPrefix("utun") && - !name.hasPrefix("awdl") else { - continue - } - - // Only count active interfaces (en0, en1, etc.) - if name.hasPrefix("en") || name.contains("Wi-Fi") { - if let data = interface.ifa_data?.assumingMemoryBound(to: if_data.self) { - totalBytesIn += UInt64(data.pointee.ifi_ibytes) - totalBytesOut += UInt64(data.pointee.ifi_obytes) - } - } - } - - return (totalBytesIn, totalBytesOut) - } - private func getDiskStats() -> (bytesRead: UInt64, bytesWritten: UInt64) { // Use IOKit to get disk I/O statistics from IOStorage service var totalBytesRead: UInt64 = 0 diff --git a/DynamicIsland/managers/SystemMediaControllers.swift b/DynamicIsland/managers/SystemMediaControllers.swift index bec76792..8145857f 100644 --- a/DynamicIsland/managers/SystemMediaControllers.swift +++ b/DynamicIsland/managers/SystemMediaControllers.swift @@ -70,8 +70,15 @@ final class SystemVolumeController { private var listenersInstalled = false private var volumeElement: AudioObjectPropertyElement? private var muteElement: AudioObjectPropertyElement? + private var volumeListenerRegistrations: [VolumeListenerRegistration] = [] private let silenceThreshold: Float = 0.001 // Treat very low values as mute requests. + private struct VolumeListenerRegistration { + let deviceID: AudioDeviceID + var address: AudioObjectPropertyAddress + let block: AudioObjectPropertyListenerBlock + } + private let candidateElements: [AudioObjectPropertyElement] = [ kAudioObjectPropertyElementMain, AudioObjectPropertyElement(1), @@ -214,21 +221,48 @@ final class SystemVolumeController { } private func installVolumeListeners(for deviceID: AudioDeviceID) { + // Remove any previously registered listeners (e.g. from a prior output + // device) before re-installing, otherwise the old HAL listeners leak and + // deliver duplicate notifications on every route change. + removeVolumeListeners() + if let element = resolveElement(selector: kAudioDevicePropertyVolumeScalar, deviceID: deviceID) { volumeElement = element - var address = makeAddress(selector: kAudioDevicePropertyVolumeScalar, element: element) - AudioObjectAddPropertyListenerBlock(deviceID, &address, callbackQueue) { [weak self] _, _ in - self?.notifyCurrentState() - } + addVolumeListener(selector: kAudioDevicePropertyVolumeScalar, element: element, deviceID: deviceID) } if let element = resolveElement(selector: kAudioDevicePropertyMute, deviceID: deviceID) { muteElement = element - var address = makeAddress(selector: kAudioDevicePropertyMute, element: element) - AudioObjectAddPropertyListenerBlock(deviceID, &address, callbackQueue) { [weak self] _, _ in - self?.notifyCurrentState() + addVolumeListener(selector: kAudioDevicePropertyMute, element: element, deviceID: deviceID) + } + } + + private func addVolumeListener(selector: AudioObjectPropertySelector, element: AudioObjectPropertyElement, deviceID: AudioDeviceID) { + var address = makeAddress(selector: selector, element: element) + let block: AudioObjectPropertyListenerBlock = { [weak self] _, _ in + self?.notifyCurrentState() + } + let status = AudioObjectAddPropertyListenerBlock(deviceID, &address, callbackQueue, block) + if status == noErr { + volumeListenerRegistrations.append(VolumeListenerRegistration(deviceID: deviceID, address: address, block: block)) + } else { + NSLog("⚠️ Failed to install volume/mute listener for selector \(selector): \(status)") + } + } + + private func removeVolumeListeners() { + for var registration in volumeListenerRegistrations { + let status = AudioObjectRemovePropertyListenerBlock( + registration.deviceID, + ®istration.address, + callbackQueue, + registration.block + ) + if status != noErr { + NSLog("⚠️ Failed to remove volume/mute listener: \(status)") } } + volumeListenerRegistrations.removeAll() } private func handleDefaultDeviceChanged() { @@ -442,6 +476,9 @@ final class SystemVolumeController { return AudioObjectSetPropertyData(currentDeviceID, &address, 0, nil, size, &data) } + // Computed on each access rather than cached: these are called synchronously + // from the public API on arbitrary threads while `refreshPropertyElements()` + // runs on `callbackQueue`, so a shared cache would race. The probe is cheap. private func volumeElements() -> [AudioObjectPropertyElement] { candidateElements.filter { element in var address = makeAddress(selector: kAudioDevicePropertyVolumeScalar, element: element) diff --git a/DynamicIsland/utils/CPUSensorCollector.swift b/DynamicIsland/utils/CPUSensorCollector.swift index 33a4721a..5e29c580 100644 --- a/DynamicIsland/utils/CPUSensorCollector.swift +++ b/DynamicIsland/utils/CPUSensorCollector.swift @@ -42,6 +42,7 @@ final class CPUSensorCollector { private var channels: CFMutableDictionary? private var subscription: IOReportSubscriptionRef? private var previousSample: (samples: CFDictionary, time: TimeInterval)? + private var cachedPrimaryTemperatureKey: String? init() { setupFrequencyChannel() @@ -55,8 +56,17 @@ final class CPUSensorCollector { func readTemperature() -> CPUTemperatureMetrics { let platform = hardware.platform - if let value = primaryTemperatureKeyCandidates.compactMap({ SMC.shared.getValue($0) }).first(where: { $0 < 110 }) { - return CPUTemperatureMetrics(celsius: value) + // Try the last key that worked first, then scan candidates with early exit so we + // stop issuing SMC reads (each a syscall) as soon as one returns a valid value. + var keysToTry = primaryTemperatureKeyCandidates + if let cached = cachedPrimaryTemperatureKey { + keysToTry = [cached] + keysToTry.filter { $0 != cached } + } + for key in keysToTry { + if let value = SMC.shared.getValue(key), value < 110 { + cachedPrimaryTemperatureKey = key + return CPUTemperatureMetrics(celsius: value) + } } let list = temperatureFallbackKeys(for: platform) var total: Double = 0