From 02b130c8f5786b735ade7672586fd69f93318c24 Mon Sep 17 00:00:00 2001 From: mehmetefeaytas Date: Thu, 23 Jul 2026 23:26:42 +0300 Subject: [PATCH 01/20] perf(shelf): bound thumbnail cache with NSCache (count + cost limits) --- .../Shelf/Services/ThumbnailService.swift | 44 ++++++++++++++----- 1 file changed, 32 insertions(+), 12 deletions(-) diff --git a/DynamicIsland/components/Shelf/Services/ThumbnailService.swift b/DynamicIsland/components/Shelf/Services/ThumbnailService.swift index 8b206531..39efe47c 100644 --- a/DynamicIsland/components/Shelf/Services/ThumbnailService.swift +++ b/DynamicIsland/components/Shelf/Services/ThumbnailService.swift @@ -28,42 +28,62 @@ 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) } 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) } + let keysToRemove = cacheKeys.filter { $0.starts(with: url.path) } + 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 From ecf38cf9dba069c53b456d90dfded1a16a74d44a Mon Sep 17 00:00:00 2001 From: mehmetefeaytas Date: Thu, 23 Jul 2026 23:27:24 +0300 Subject: [PATCH 02/20] perf(audio): fix HAL property-listener leak + reuse volume/mute element cache --- .../managers/SystemMediaControllers.swift | 68 ++++++++++++++++--- 1 file changed, 59 insertions(+), 9 deletions(-) diff --git a/DynamicIsland/managers/SystemMediaControllers.swift b/DynamicIsland/managers/SystemMediaControllers.swift index bec76792..22d46047 100644 --- a/DynamicIsland/managers/SystemMediaControllers.swift +++ b/DynamicIsland/managers/SystemMediaControllers.swift @@ -70,8 +70,17 @@ final class SystemVolumeController { private var listenersInstalled = false private var volumeElement: AudioObjectPropertyElement? private var muteElement: AudioObjectPropertyElement? + private var cachedVolumeElements: [AudioObjectPropertyElement]? + private var cachedMuteElements: [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 +223,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() { @@ -339,6 +375,10 @@ final class SystemVolumeController { private func refreshPropertyElements() { volumeElement = resolveElement(selector: kAudioDevicePropertyVolumeScalar, deviceID: currentDeviceID) muteElement = resolveElement(selector: kAudioDevicePropertyMute, deviceID: currentDeviceID) + // Invalidate the probed element-list caches so they are recomputed for + // the (potentially new) current device on next access. + cachedVolumeElements = nil + cachedMuteElements = nil } private func resolveElement(selector: AudioObjectPropertySelector, deviceID: AudioDeviceID) -> AudioObjectPropertyElement? { @@ -443,17 +483,27 @@ final class SystemVolumeController { } private func volumeElements() -> [AudioObjectPropertyElement] { - candidateElements.filter { element in + if let cached = cachedVolumeElements { + return cached + } + let elements = candidateElements.filter { element in var address = makeAddress(selector: kAudioDevicePropertyVolumeScalar, element: element) return propertyExists(deviceID: currentDeviceID, address: &address) } + cachedVolumeElements = elements + return elements } private func muteElements() -> [AudioObjectPropertyElement] { - candidateElements.filter { element in + if let cached = cachedMuteElements { + return cached + } + let elements = candidateElements.filter { element in var address = makeAddress(selector: kAudioDevicePropertyMute, element: element) return propertyExists(deviceID: currentDeviceID, address: &address) } + cachedMuteElements = elements + return elements } } From 32799c8909118a101535f47ecd2aa8c2a9001a0a Mon Sep 17 00:00:00 2001 From: mehmetefeaytas Date: Thu, 23 Jul 2026 23:31:46 +0300 Subject: [PATCH 03/20] perf(stats): fix observer-id bug, ps throttle, single getifaddrs, lazy SMC key scan Co-Authored-By: Claude Opus 4.8 (1M context) --- .../managers/BatteryActivityManager.swift | 14 ++- DynamicIsland/managers/StatsManager.swift | 119 +++++++++--------- DynamicIsland/utils/CPUSensorCollector.swift | 14 ++- 3 files changed, 76 insertions(+), 71 deletions(-) diff --git a/DynamicIsland/managers/BatteryActivityManager.swift b/DynamicIsland/managers/BatteryActivityManager.swift index a70d807f..e5618e79 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,7 @@ class BatteryActivityManager { private func notifyObservers(event: BatteryEvent) { DispatchQueue.main.async { [weak self] in guard let self = self else { return } - for observer in self.observers { + for observer in self.observers.values { observer(event) } } diff --git a/DynamicIsland/managers/StatsManager.swift b/DynamicIsland/managers/StatsManager.swift index 7abe47ec..317bde69 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,9 @@ 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() + let currentNetworkStats = aggregateNetworkStats(from: networkSnapshots) let currentTime = Date() let timeInterval = currentTime.timeIntervalSince(previousTimestamp) @@ -771,9 +772,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 +1030,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 +1071,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 +1288,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/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 From 0571428a31e7ea9a0a93d86a367e0507201c3423 Mon Sep 17 00:00:00 2001 From: mehmetefeaytas Date: Thu, 23 Jul 2026 23:32:15 +0300 Subject: [PATCH 04/20] fix(media): publish playbackState on the main actor; reuse static ISO8601 formatter Co-Authored-By: Claude Opus 4.8 (1M context) --- .../AmazonMusicController.swift | 35 ++++-- .../AppleMusicController.swift | 5 +- .../NowPlayingController.swift | 28 +++-- .../MediaControllers/SpotifyController.swift | 8 +- .../YouTubeMusicController.swift | 101 ++++++++++++------ 5 files changed, 123 insertions(+), 54 deletions(-) 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 From 62b88ce62780ce140387dd35024cf011c1191b57 Mon Sep 17 00:00:00 2001 From: mehmetefeaytas Date: Thu, 23 Jul 2026 23:41:11 +0300 Subject: [PATCH 05/20] perf(energy): add central ActivityGate (sleep/thermal/low-power) for polling suspension Co-Authored-By: Claude Opus 4.8 (1M context) --- DynamicIsland/managers/ActivityGate.swift | 96 +++++++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 DynamicIsland/managers/ActivityGate.swift diff --git a/DynamicIsland/managers/ActivityGate.swift b/DynamicIsland/managers/ActivityGate.swift new file mode 100644 index 00000000..30bbd869 --- /dev/null +++ b/DynamicIsland/managers/ActivityGate.swift @@ -0,0 +1,96 @@ +// +// ActivityGate.swift +// DynamicIsland +// +// Central "activity gate" for energy-aware background work (Perf Phase 2). +// +// Purpose: a single source of truth that lets optional background jobs +// (polling loops, periodic refreshes) suspend themselves while the screen +// is asleep, and stretch their intervals under low-power / thermal pressure. +// +// Usage from a poller: +// - Skip a tick when `ActivityGate.shared.shouldSuspendBackgroundWork` is true. +// - Multiply the base interval by `ActivityGate.shared.pollingIntervalScale`. +// - Optionally observe the @Published properties to react to changes. +// + +import AppKit +import Combine + +@MainActor +final class ActivityGate: ObservableObject { + static let shared = ActivityGate() + + /// True while the screen (or system) is asleep → optional background work + /// (polling) should be skipped to save energy. + @Published private(set) var shouldSuspendBackgroundWork: Bool = false + + /// Multiplier applied to polling intervals: normally 1.0; larger under + /// low-power mode or serious/critical thermal pressure (e.g. 3.0), 1.5 for + /// a "fair" thermal state. Pollers multiply their base interval by this. + @Published private(set) var pollingIntervalScale: Double = 1.0 + + private init() { + // Observe screen/system sleep on the AppKit workspace notification center. + let workspaceCenter = NSWorkspace.shared.notificationCenter + for (name, sleeping) in [ + (NSWorkspace.screensDidSleepNotification, true), + (NSWorkspace.screensDidWakeNotification, false), + (NSWorkspace.willSleepNotification, true), // system sleep + (NSWorkspace.didWakeNotification, false) // system wake + ] { + workspaceCenter.addObserver( + forName: name, + object: nil, + queue: .main + ) { [weak self] _ in + // Notification is delivered on .main; hop to the actor to mutate state. + MainActor.assumeIsolated { + self?.shouldSuspendBackgroundWork = sleeping + } + } + } + + // Observe low-power-mode and thermal-state changes on the default center. + let defaultCenter = NotificationCenter.default + for name in [ + NSNotification.Name.NSProcessInfoPowerStateDidChange, // low power mode toggled + ProcessInfo.thermalStateDidChangeNotification // thermal state changed + ] { + defaultCenter.addObserver( + forName: name, + object: nil, + queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { + self?.recomputePollingScale() + } + } + } + + // Seed initial values so state is correct before any notification fires. + recomputePollingScale() + } + + /// Recompute `pollingIntervalScale` from current power + thermal conditions. + private func recomputePollingScale() { + let info = ProcessInfo.processInfo + let scale: Double + + switch info.thermalState { + case .serious, .critical: + scale = 3.0 + case .fair: + // Low power always wins with the heaviest throttle. + scale = info.isLowPowerModeEnabled ? 3.0 : 1.5 + case .nominal: + scale = info.isLowPowerModeEnabled ? 3.0 : 1.0 + @unknown default: + scale = info.isLowPowerModeEnabled ? 3.0 : 1.0 + } + + if scale != pollingIntervalScale { + pollingIntervalScale = scale + } + } +} From fb83ccf64a772ea32bf4e0b8acb9f5562534b4cb Mon Sep 17 00:00:00 2001 From: mehmetefeaytas Date: Thu, 23 Jul 2026 23:47:30 +0300 Subject: [PATCH 06/20] perf(bluetooth): make observer primary, 30s gated watchdog poll, skip when asleep/off Co-Authored-By: Claude Opus 4.8 (1M context) --- .../managers/BluetoothAudioManager.swift | 58 ++++++++++++++----- 1 file changed, 44 insertions(+), 14 deletions(-) diff --git a/DynamicIsland/managers/BluetoothAudioManager.swift b/DynamicIsland/managers/BluetoothAudioManager.swift index c0b56440..1489c134 100644 --- a/DynamicIsland/managers/BluetoothAudioManager.swift +++ b/DynamicIsland/managers/BluetoothAudioManager.swift @@ -31,7 +31,13 @@ class BluetoothAudioManager: ObservableObject { // MARK: - Published Properties @Published var lastConnectedDevice: BluetoothAudioDevice? - @Published var connectedDevices: [BluetoothAudioDevice] = [] + @Published var connectedDevices: [BluetoothAudioDevice] = [] { + didSet { + // Tie the AirPods listening-mode log stream to device lifecycle: + // only keep it running while an AirPods device is actually connected. + reconcileListeningModeLogObserverForConnectedDevices() + } + } @Published var isBluetoothAudioConnected: Bool = false @Published private(set) var activeListeningModeEvent: AirPodsListeningModeEvent? @@ -190,32 +196,56 @@ class BluetoothAudioManager: ObservableObject { } } - if Defaults[.showAirPodsListeningModeChanges] { - listeningModeLogObserver.start() - } + reconcileListeningModeLogObserverForConnectedDevices() Defaults.publisher(.showAirPodsListeningModeChanges, options: []) - .sink { [weak self] change in - if change.newValue { - self?.listeningModeLogObserver.start() - } else { - self?.listeningModeLogObserver.stop() - } + .sink { [weak self] _ in + self?.reconcileListeningModeLogObserverForConnectedDevices() } .store(in: &cancellables) } + + /// Starts the AirPods listening-mode log stream only when the feature is + /// enabled AND at least one AirPods device is connected; stops it otherwise. + /// Idempotent (start/stop are internally guarded), so it is safe to call + /// from `connectedDevices.didSet` and the Defaults publisher. + private func reconcileListeningModeLogObserverForConnectedDevices() { + guard Defaults[.showAirPodsListeningModeChanges] else { + listeningModeLogObserver.stop() + return + } + + if connectedDevices.contains(where: { $0.deviceType.isAirPods }) { + listeningModeLogObserver.start() + } else { + listeningModeLogObserver.stop() + } + } - /// Starts polling for device connection changes (fallback mechanism) + /// Starts a low-frequency watchdog poll for device connection changes. + /// The event-driven DistributedNotificationCenter observers are the primary + /// source of truth; this poll is only a safety net, so a coarse 30s interval + /// (with tolerance for coalescing/power efficiency) is sufficient. private func startPollingForChanges() { - print("🎧 [BluetoothAudioManager] Starting polling timer (3s interval)...") - - pollingTimer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: true) { [weak self] _ in + print("🎧 [BluetoothAudioManager] Starting fallback watchdog timer (30s interval)...") + + let timer = Timer.scheduledTimer(withTimeInterval: 30.0, repeats: true) { [weak self] _ in self?.checkForDeviceChanges() } + timer.tolerance = 5.0 + pollingTimer = timer } /// Checks for device connection/disconnection changes private func checkForDeviceChanges() { + // Perf: skip the fallback watchdog scan while the display is asleep. + // Event-driven observers still deliver connect/disconnect while awake, + // and a wake will resume this poll. (Behavioral stand-in for + // ActivityGate.shared.shouldSuspendBackgroundWork, which is not present + // on this branch — swap to that once available.) + guard CGDisplayIsAsleep(CGMainDisplayID()) == 0 else { return } + + // Item 3: skip the scan entirely when Bluetooth is powered off. // Check if Bluetooth is powered on guard IOBluetoothHostController.default()?.powerState == kBluetoothHCIPowerStateON else { // Bluetooth is off - clear connected devices if any From c23014485385212b9a6ed9c7386b22040c3d8348 Mon Sep 17 00:00:00 2001 From: mehmetefeaytas Date: Thu, 23 Jul 2026 23:52:31 +0300 Subject: [PATCH 07/20] perf(polling): brightness event-driven/windowed, clipboard >=1s + gated + debounced persistence Co-Authored-By: Claude Opus 4.8 (1M context) --- DynamicIsland/managers/ClipboardManager.swift | 54 +++++++++++++++++-- .../managers/SystemMediaControllers.swift | 47 ++++++++++++---- 2 files changed, 88 insertions(+), 13 deletions(-) diff --git a/DynamicIsland/managers/ClipboardManager.swift b/DynamicIsland/managers/ClipboardManager.swift index d3a11a6a..5fff6041 100644 --- a/DynamicIsland/managers/ClipboardManager.swift +++ b/DynamicIsland/managers/ClipboardManager.swift @@ -175,6 +175,17 @@ class ClipboardManager: ObservableObject { private var timer: Timer? private var lastChangeCount: Int = 0 + + // Polling cadence for pasteboard change detection. 1s (with tolerance) is + // ample for clipboard capture while being far cheaper than sub-second polls. + private let pollInterval: TimeInterval = 1.0 + + // Debounced persistence: coalesce rapid history mutations into a single + // JSON encode + UserDefaults write instead of writing the whole history + // on every change. + private var saveHistoryDebounceTimer: Timer? + private let saveHistoryDebounceInterval: TimeInterval = 0.75 + private var didRegisterTerminationFlush = false // Use configurable history size from settings private var maxHistoryItems: Int { @@ -215,17 +226,38 @@ class ClipboardManager: ObservableObject { func startMonitoring() { guard !isMonitoring else { return } - + isMonitoring = true - timer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] _ in - self?.checkClipboard() + registerTerminationFlush() + let timer = Timer.scheduledTimer(withTimeInterval: pollInterval, repeats: true) { [weak self] _ in + guard let self else { return } + // Energy gate: skip ticks while the screen/system is asleep. + if MainActor.assumeIsolated({ ActivityGate.shared.shouldSuspendBackgroundWork }) { return } + self.checkClipboard() } + timer.tolerance = pollInterval * 0.2 + self.timer = timer } - + func stopMonitoring() { isMonitoring = false timer?.invalidate() timer = nil + // Flush any pending debounced history write so nothing is lost. + persistHistoryNow() + } + + /// Flush a pending debounced history write before the app terminates. + private func registerTerminationFlush() { + guard !didRegisterTerminationFlush else { return } + didRegisterTerminationFlush = true + NotificationCenter.default.addObserver( + forName: NSApplication.willTerminateNotification, + object: nil, + queue: .main + ) { [weak self] _ in + self?.persistHistoryNow() + } } func copyToClipboard(_ item: ClipboardItem) { @@ -517,6 +549,20 @@ class ClipboardManager: ObservableObject { // MARK: - Persistence private func saveHistoryToDefaults() { + // Debounce: many mutations can arrive in a burst (e.g. dedup + trim on a + // single copy). Coalesce them into one encode + write after a short quiet + // period instead of serializing the whole history on every change. + saveHistoryDebounceTimer?.invalidate() + saveHistoryDebounceTimer = Timer.scheduledTimer(withTimeInterval: saveHistoryDebounceInterval, repeats: false) { [weak self] _ in + self?.persistHistoryNow() + } + } + + /// Immediately encode and persist the clipboard history, cancelling any + /// pending debounced write. + private func persistHistoryNow() { + saveHistoryDebounceTimer?.invalidate() + saveHistoryDebounceTimer = nil if let encoded = try? JSONEncoder().encode(clipboardHistory) { UserDefaults.standard.set(encoded, forKey: "ClipboardHistory") } diff --git a/DynamicIsland/managers/SystemMediaControllers.swift b/DynamicIsland/managers/SystemMediaControllers.swift index 22d46047..49fc0805 100644 --- a/DynamicIsland/managers/SystemMediaControllers.swift +++ b/DynamicIsland/managers/SystemMediaControllers.swift @@ -529,7 +529,13 @@ final class SystemBrightnessController { private var pendingAdjustTarget: Float? private let coreBrightnessClient = CoreBrightnessDisplayClient.shared private var pollTimer: Timer? + // Fast, self-terminating poll used only inside the user-initiated window + // (after a brightness key press) to capture the settled value. private let pollInterval: TimeInterval = 0.15 + // Slower continuous poll used only as a fallback when CoreBrightness + // notifications are unavailable. Gated by ActivityGate per tick. + private let fallbackPollInterval: TimeInterval = 0.5 + private var continuousFallbackPolling = false private let pollChangeThreshold: Float = 0.005 // MARK: - User-initiated brightness gate @@ -557,11 +563,14 @@ final class SystemBrightnessController { NSLog("⚠️ SystemBrightnessController: CoreBrightnessDisplayClient unavailable; will rely on DisplayServices / IODisplay + polling fallback") } notifyCurrentBrightness() - // Only start polling as a fallback when CoreBrightness notifications - // are unavailable. When CoreBrightness IS available the distributed - // notifications (registerExternalNotifications) handle detection. + // Only start continuous polling as a fallback when CoreBrightness + // notifications are unavailable. When CoreBrightness IS available the + // distributed notifications (registerExternalNotifications) handle + // detection and we stay fully event-driven — a short windowed poll is + // started on demand from markUserInitiated() after a key press. if !coreBrightnessClient.isAvailable { - startPolling() + continuousFallbackPolling = true + startPolling(interval: fallbackPollInterval) } } @@ -574,6 +583,7 @@ final class SystemBrightnessController { userInitiatedResetTimer?.invalidate() userInitiatedResetTimer = nil userInitiatedBrightnessChange = false + continuousFallbackPolling = false pendingAdjustTarget = nil } @@ -609,9 +619,23 @@ final class SystemBrightnessController { /// Automatically resets after `userInitiatedWindow` seconds. private func markUserInitiated() { userInitiatedBrightnessChange = true + // In the event-driven (CoreBrightness) path we normally never poll. + // Run a short, self-terminating poll during the user window so the + // brightness the display settles on is still captured for the HUD, + // then stop again. The continuous fallback poll (if active) is left + // untouched. + if !continuousFallbackPolling { + startPolling(interval: pollInterval) + } userInitiatedResetTimer?.invalidate() userInitiatedResetTimer = Timer.scheduledTimer(withTimeInterval: userInitiatedWindow, repeats: false) { [weak self] _ in - self?.userInitiatedBrightnessChange = false + guard let self else { return } + self.userInitiatedBrightnessChange = false + // Tear down the windowed poll; keep the continuous fallback running. + if !self.continuousFallbackPolling { + self.pollTimer?.invalidate() + self.pollTimer = nil + } } } @@ -822,11 +846,14 @@ final class SystemBrightnessController { notificationsInstalled = true } - private func startPolling() { + private func startPolling(interval: TimeInterval) { guard pollTimer == nil else { return } - NSLog("ℹ️ SystemBrightnessController: Starting polling-driven brightness detection as fallback (interval: %.2fs)", pollInterval) - pollTimer = Timer.scheduledTimer(withTimeInterval: pollInterval, repeats: true) { [weak self] _ in + NSLog("ℹ️ SystemBrightnessController: Starting %@ brightness polling (interval: %.2fs)", + continuousFallbackPolling ? "fallback" : "windowed", interval) + let timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in guard let self else { return } + // Energy gate: skip ticks while the screen/system is asleep. + if MainActor.assumeIsolated({ ActivityGate.shared.shouldSuspendBackgroundWork }) { return } // Skip polling while an animation is actively running — the // animation timer already handles emission during key presses. guard self.brightnessAnimationTimer == nil else { return } @@ -836,7 +863,7 @@ final class SystemBrightnessController { if self.userInitiatedBrightnessChange { // User recently pressed a brightness key — show the HUD. if !self.didLogPollingFallback { - NSLog("ℹ️ SystemBrightnessController: Brightness change detected via polling fallback (value: %.3f)", system) + NSLog("ℹ️ SystemBrightnessController: Brightness change detected via polling (value: %.3f)", system) self.didLogPollingFallback = true } self.emitBrightnessChange(value: system) @@ -845,6 +872,8 @@ final class SystemBrightnessController { self.lastEmittedBrightness = max(0, min(1, system)) } } + timer.tolerance = interval * 0.2 + pollTimer = timer } deinit { From f47cc42095a9fa72c55a7df228dd1a19e9bdbc56 Mon Sep 17 00:00:00 2001 From: mehmetefeaytas Date: Thu, 23 Jul 2026 23:54:36 +0300 Subject: [PATCH 08/20] perf(bg): DND FS-events, gated AX ticker, conditional media-key tap (S2-d, YTM omitted to keep phase-1 change) --- .../managers/DoNotDisturbManager.swift | 88 +++++++++++++++++-- .../managers/MediaKeyInterceptor.swift | 23 ++++- .../managers/SystemTimerBridge.swift | 11 ++- 3 files changed, 112 insertions(+), 10 deletions(-) diff --git a/DynamicIsland/managers/DoNotDisturbManager.swift b/DynamicIsland/managers/DoNotDisturbManager.swift index 1bb8a1b8..5a0d2f37 100644 --- a/DynamicIsland/managers/DoNotDisturbManager.swift +++ b/DynamicIsland/managers/DoNotDisturbManager.swift @@ -44,7 +44,8 @@ final class DoNotDisturbManager: ObservableObject { private let focusLogStream = FocusLogStream() private let assertionsURL = FileManager.default.homeDirectoryForCurrentUser .appendingPathComponent("Library/DoNotDisturb/DB/Assertions.json") - private var pollingSource: DispatchSourceTimer? + private var assertionsFileSource: DispatchSourceFileSystemObject? + private var assertionsDirSource: DispatchSourceFileSystemObject? private var lastAssertionsModificationDate: Date? private var modeCancellable: AnyCancellable? /// Periodic task that verifies focus is still active when `isDoNotDisturbActive` is true. @@ -585,21 +586,92 @@ private extension DoNotDisturbManager { stopAssertionsPolling() lastAssertionsModificationDate = nil - let timer = DispatchSource.makeTimerSource(queue: pollingQueue) - timer.schedule(deadline: .now() + .seconds(1), repeating: .seconds(2), leeway: .milliseconds(250)) - timer.setEventHandler { [weak self] in + // Watch the Assertions.json file for filesystem events instead of waking every 2s to + // compare its modification date. This yields zero wake-ups while idle and reacts the + // instant Focus state is written. A directory-watch fallback re-arms the file watch if + // the file is currently absent or gets rotated away. + beginWatchingAssertionsFile() + + // Read the current state immediately so an already-active Focus is reflected on start. + pollingQueue.async { [weak self] in self?.pollAssertionsState() } - timer.resume() - pollingSource = timer } func stopAssertionsPolling() { - pollingSource?.cancel() - pollingSource = nil + assertionsFileSource?.cancel() + assertionsFileSource = nil + assertionsDirSource?.cancel() + assertionsDirSource = nil lastAssertionsModificationDate = nil } + /// Arms a filesystem-object watch on `Assertions.json`. If the file doesn't exist yet, falls + /// back to watching its parent directory until the file appears. Re-arms itself when the file + /// is deleted or renamed (macOS rewrites this file atomically on Focus changes). + private func beginWatchingAssertionsFile() { + assertionsFileSource?.cancel() + assertionsFileSource = nil + + let fd = open(assertionsURL.path, O_EVTONLY) + guard fd >= 0 else { + beginWatchingAssertionsDirectory() + return + } + + // File is present now; drop any directory fallback watch. + assertionsDirSource?.cancel() + assertionsDirSource = nil + + let source = DispatchSource.makeFileSystemObjectSource( + fileDescriptor: fd, + eventMask: [.write, .extend, .attrib, .rename, .delete], + queue: pollingQueue + ) + source.setEventHandler { [weak self] in + guard let self, let current = self.assertionsFileSource else { return } + let flags = current.data + if flags.contains(.delete) || flags.contains(.rename) { + // File was rotated/removed: re-evaluate (defaults to inactive) then re-arm the watch. + self.lastAssertionsModificationDate = nil + self.pollAssertionsState() + self.beginWatchingAssertionsFile() + } else { + self.pollAssertionsState() + } + } + source.setCancelHandler { close(fd) } + assertionsFileSource = source + source.resume() + } + + /// Fallback used when `Assertions.json` is missing: watch the containing directory and switch + /// to the file watch as soon as the file is created. + private func beginWatchingAssertionsDirectory() { + assertionsDirSource?.cancel() + assertionsDirSource = nil + + let directoryURL = assertionsURL.deletingLastPathComponent() + let fd = open(directoryURL.path, O_EVTONLY) + guard fd >= 0 else { return } + + let source = DispatchSource.makeFileSystemObjectSource( + fileDescriptor: fd, + eventMask: [.write, .extend, .attrib, .rename, .delete], + queue: pollingQueue + ) + source.setEventHandler { [weak self] in + guard let self else { return } + guard FileManager.default.fileExists(atPath: self.assertionsURL.path) else { return } + self.lastAssertionsModificationDate = nil + self.beginWatchingAssertionsFile() + self.pollAssertionsState() + } + source.setCancelHandler { close(fd) } + assertionsDirSource = source + source.resume() + } + func pollAssertionsState() { guard FullDiskAccessAuthorization.hasPermission() else { return } diff --git a/DynamicIsland/managers/MediaKeyInterceptor.swift b/DynamicIsland/managers/MediaKeyInterceptor.swift index 0b1b36a5..97addb3a 100644 --- a/DynamicIsland/managers/MediaKeyInterceptor.swift +++ b/DynamicIsland/managers/MediaKeyInterceptor.swift @@ -108,6 +108,12 @@ final class MediaKeyInterceptor { return true } + // Don't install a global CGEvent tap unless at least one interception feature is enabled. + // An idle tap still routes every system-defined key event through this process for nothing. + guard shouldEnableTap else { + return true + } + #if canImport(ApplicationServices) requestAccessibilityPermissionIfNeeded() #endif @@ -173,8 +179,23 @@ final class MediaKeyInterceptor { } private func updateTapState() { - guard let tap = eventTap else { return } let shouldEnable = shouldEnableTap + + guard let tap = eventTap else { + // No tap installed. Install one now if a feature was just enabled. + if shouldEnable { + start() + } + return + } + + if !shouldEnable { + // All interception features are off — tear the tap down entirely so it stops + // waking this process on every media key. + stop() + return + } + if shouldEnable != isTapEnabled { CGEvent.tapEnable(tap: tap, enable: shouldEnable) isTapEnabled = shouldEnable diff --git a/DynamicIsland/managers/SystemTimerBridge.swift b/DynamicIsland/managers/SystemTimerBridge.swift index a5e34685..65405ea1 100644 --- a/DynamicIsland/managers/SystemTimerBridge.swift +++ b/DynamicIsland/managers/SystemTimerBridge.swift @@ -216,7 +216,8 @@ final class SystemTimerBridge { private func setupTicker() { let timer = DispatchSource.makeTimerSource(queue: queue) - timer.schedule(deadline: .now(), repeating: .seconds(1), leeway: .milliseconds(150)) + // Larger leeway lets the scheduler coalesce this fallback ticker with other wake-ups. + timer.schedule(deadline: .now(), repeating: .seconds(1), leeway: .milliseconds(500)) timer.setEventHandler { [weak self] in self?.pollMenuExtra() } @@ -417,6 +418,14 @@ final class SystemTimerBridge { return } + // Only walk the AX tree while a system timer is actually active in the UI. The preferences + // file monitor sets `metadata` the moment a timer is created (the plist is written on + // start), so a freshly started timer is still picked up on the next tick — but an idle + // machine no longer pays for a system-wide AX search every second. + guard metadata != nil || menuExtra != nil || TimerManager.shared.isExternalTimerActive else { + return + } + if logProcess != nil, logIdentifier != nil { logDebug("Skipping AX poll: log stream already tracking timer") return From 1230c28a1598603d293fb5ae0480ef830a31f5e3 Mon Sep 17 00:00:00 2001 From: mehmetefeaytas Date: Thu, 23 Jul 2026 23:56:34 +0300 Subject: [PATCH 09/20] docs(bluetooth): clarify sleep-gate uses thread-safe CGDisplayIsAsleep (off-main poll) --- DynamicIsland/managers/BluetoothAudioManager.swift | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/DynamicIsland/managers/BluetoothAudioManager.swift b/DynamicIsland/managers/BluetoothAudioManager.swift index 1489c134..72f04028 100644 --- a/DynamicIsland/managers/BluetoothAudioManager.swift +++ b/DynamicIsland/managers/BluetoothAudioManager.swift @@ -240,9 +240,8 @@ class BluetoothAudioManager: ObservableObject { private func checkForDeviceChanges() { // Perf: skip the fallback watchdog scan while the display is asleep. // Event-driven observers still deliver connect/disconnect while awake, - // and a wake will resume this poll. (Behavioral stand-in for - // ActivityGate.shared.shouldSuspendBackgroundWork, which is not present - // on this branch — swap to that once available.) + // and a wake resumes this poll. Uses the thread-safe CoreGraphics check + // (this runs off the main actor) rather than the @MainActor ActivityGate. guard CGDisplayIsAsleep(CGMainDisplayID()) == 0 else { return } // Item 3: skip the scan entirely when Bluetooth is powered off. From e1e0d1eabeaa03cffff192777cc09f242e8f0a65 Mon Sep 17 00:00:00 2001 From: mehmetefeaytas Date: Fri, 24 Jul 2026 00:07:46 +0300 Subject: [PATCH 10/20] perf(visualizer): drive animation with TimelineView/visibility gating; hoist Defaults reads Co-Authored-By: Claude Opus 4.8 (1M context) --- .../audio/RealTimeAudioSpectrum.swift | 8 +- .../components/Music/MusicVisualizer.swift | 6 +- .../Music/RealTimeWaveformScrubberView.swift | 124 +++++++++--------- 3 files changed, 74 insertions(+), 64 deletions(-) diff --git a/DynamicIsland/audio/RealTimeAudioSpectrum.swift b/DynamicIsland/audio/RealTimeAudioSpectrum.swift index 989df295..baf4ceb8 100644 --- a/DynamicIsland/audio/RealTimeAudioSpectrum.swift +++ b/DynamicIsland/audio/RealTimeAudioSpectrum.swift @@ -83,10 +83,14 @@ class RealTimeAudioSpectrum: NSView { private func startAnimating() { guard animationTimer == nil else { return } - // Use a timer at ~30fps for smooth animation - animationTimer = Timer.scheduledTimer(withTimeInterval: 1.0/30.0, repeats: true) { [weak self] _ in + // Use a timer at ~30fps for smooth animation. + // The timer only runs while the view is in a window (see viewDidMoveToWindow) + // and playback is active; tolerance lets the OS coalesce wakeups to save CPU. + let timer = Timer.scheduledTimer(withTimeInterval: 1.0/30.0, repeats: true) { [weak self] _ in self?.updateBarsFromAudio() } + timer.tolerance = 1.0/60.0 + animationTimer = timer } private func stopAnimating() { diff --git a/DynamicIsland/components/Music/MusicVisualizer.swift b/DynamicIsland/components/Music/MusicVisualizer.swift index 0a9d69cf..fe7b7ad8 100644 --- a/DynamicIsland/components/Music/MusicVisualizer.swift +++ b/DynamicIsland/components/Music/MusicVisualizer.swift @@ -82,9 +82,13 @@ class AudioSpectrum: NSView { private func startAnimating() { guard animationTimer == nil else { return } - animationTimer = Timer.scheduledTimer(withTimeInterval: 0.3, repeats: true) { [weak self] _ in + // The timer only runs while the view is in a window (see viewDidMoveToWindow) + // and playback is active; tolerance lets the OS coalesce wakeups to save CPU. + let timer = Timer.scheduledTimer(withTimeInterval: 0.3, repeats: true) { [weak self] _ in self?.updateBars() } + timer.tolerance = 0.1 + animationTimer = timer } private func stopAnimating() { diff --git a/DynamicIsland/components/Music/RealTimeWaveformScrubberView.swift b/DynamicIsland/components/Music/RealTimeWaveformScrubberView.swift index bb9d3c67..cc4c6f8c 100644 --- a/DynamicIsland/components/Music/RealTimeWaveformScrubberView.swift +++ b/DynamicIsland/components/Music/RealTimeWaveformScrubberView.swift @@ -1,77 +1,79 @@ import SwiftUI import Defaults +/// Applies exponential smoothing to AudioTap magnitudes across frames. +/// +/// A reference type so `TimelineView` can advance it during body evaluation +/// without triggering SwiftUI "modifying state during view update" warnings. +private final class WaveformSmoother { + private var magnitudes: [Float] = Array(repeating: 0.1, count: 6) + + func next(barCount: Int) -> [Float] { + let tapMagnitudes = AudioTap.shared.getSmoothedMagnitudes() + let newMags: [Float] = tapMagnitudes.count >= barCount + ? Array(tapMagnitudes.prefix(barCount)) + : tapMagnitudes + + var smoothedMags = [Float](repeating: 0.1, count: newMags.count) + for i in 0..= barCount { - newMags = Array(tapMagnitudes.prefix(barCount)) - } else { - newMags = tapMagnitudes - } - - var smoothedMags = [Float](repeating: 0.1, count: newMags.count) - for i in 0.. Date: Fri, 24 Jul 2026 00:13:50 +0300 Subject: [PATCH 11/20] perf(lockscreen/llm): cache weather hosting view, memoize next event, async file reads Co-Authored-By: Claude Opus 4.8 (1M context) --- .../LockScreen/LockScreenWeatherWidget.swift | 55 ++++++++++++++++++- .../managers/LLMUsage/JSONLUsageParser.swift | 38 +++++++++++-- .../LLMUsage/ModelPricingManager.swift | 42 +++++++------- .../LockScreenWeatherPanelManager.swift | 44 ++++++++++++--- 4 files changed, 143 insertions(+), 36 deletions(-) diff --git a/DynamicIsland/components/LockScreen/LockScreenWeatherWidget.swift b/DynamicIsland/components/LockScreen/LockScreenWeatherWidget.swift index 60e2ec19..1d245823 100644 --- a/DynamicIsland/components/LockScreen/LockScreenWeatherWidget.swift +++ b/DynamicIsland/components/LockScreen/LockScreenWeatherWidget.swift @@ -44,6 +44,7 @@ struct LockScreenWeatherWidget: View { @Default(.reminderSneakPeekDuration) private var reminderSneakPeekDuration @State private var currentTime = Date() + @State private var eventCache = NextCalendarEventCache() @State private var calendarRowVisible: Bool = false @State private var lastCalendarLine: String = "" @State private var calendarRowRenderToken: Int = 0 @@ -347,11 +348,59 @@ struct LockScreenWeatherWidget: View { return allowed.contains(event.calendar.id) } + /// Reference-type memoization cache for `nextCalendarEvent`. Held via `@State` + /// so it survives re-renders; mutating its fields never triggers a view update. + private final class NextCalendarEventCache { + var isValid = false + var events: [EventModel] = [] + var time: Date = .distantPast + var entireDuration = false + var afterStartEnabled = false + var afterStartWindowRaw = "" + var lookaheadRaw = "" + var selectionMode = "" + var selectedIDs: Set = [] + var result: EventModel? + } + + /// `nextCalendarEvent` is read many times per render (row ordering, visibility, + /// accessibility, icon config…). Recomputing the `filter`+`sorted` pipeline each + /// time is O(n log n) per access. Memoize on the full set of inputs so the work + /// happens at most once per unique input combination (i.e. once per render pass). private var nextCalendarEvent: EventModel? { + let events = calendarManager.lockScreenEvents + let cache = eventCache + if cache.isValid, + cache.time == currentTime, + cache.entireDuration == lockScreenShowCalendarEventEntireDuration, + cache.afterStartEnabled == lockScreenShowCalendarEventAfterStartEnabled, + cache.afterStartWindowRaw == lockScreenShowCalendarEventAfterStartWindow, + cache.lookaheadRaw == lockScreenCalendarEventLookaheadWindow, + cache.selectionMode == lockScreenCalendarSelectionMode, + cache.selectedIDs == lockScreenSelectedCalendarIDs, + cache.events == events { + return cache.result + } + + let result = computeNextCalendarEvent(from: events) + cache.isValid = true + cache.events = events + cache.time = currentTime + cache.entireDuration = lockScreenShowCalendarEventEntireDuration + cache.afterStartEnabled = lockScreenShowCalendarEventAfterStartEnabled + cache.afterStartWindowRaw = lockScreenShowCalendarEventAfterStartWindow + cache.lookaheadRaw = lockScreenCalendarEventLookaheadWindow + cache.selectionMode = lockScreenCalendarSelectionMode + cache.selectedIDs = lockScreenSelectedCalendarIDs + cache.result = result + return result + } + + private func computeNextCalendarEvent(from events: [EventModel]) -> EventModel? { let now = currentTime if lockScreenShowCalendarEventEntireDuration { - let candidates = calendarManager.lockScreenEvents + let candidates = events .filter { $0.end >= now } .filter(passesLockScreenCalendarFilter) .filter { isEventEligibleForLookahead($0, now: now) } @@ -368,7 +417,7 @@ struct LockScreenWeatherWidget: View { Calendar.current.date(byAdding: .minute, value: graceMinutes, to: event.start) ?? event.start } - let activeCandidates = calendarManager.lockScreenEvents + let activeCandidates = events .filter { !$0.isAllDay } .filter(passesLockScreenCalendarFilter) .filter { $0.start <= now && $0.end >= now } @@ -380,7 +429,7 @@ struct LockScreenWeatherWidget: View { } } - let upcoming = calendarManager.lockScreenEvents + let upcoming = events .filter { $0.start > now } .filter(passesLockScreenCalendarFilter) .filter { isEventEligibleForLookahead($0, now: now) } diff --git a/DynamicIsland/managers/LLMUsage/JSONLUsageParser.swift b/DynamicIsland/managers/LLMUsage/JSONLUsageParser.swift index 7d6f7664..54a123e2 100644 --- a/DynamicIsland/managers/LLMUsage/JSONLUsageParser.swift +++ b/DynamicIsland/managers/LLMUsage/JSONLUsageParser.swift @@ -55,14 +55,15 @@ struct JSONLUsageParser { let weekStart = now.addingTimeInterval(-7 * 86400) for file in files { - guard let content = try? String(contentsOf: file, encoding: .utf8) else { continue } - for line in content.split(separator: "\n") { - guard let rec = parseLine(String(line)) else { continue } + // Stream the file line-by-line instead of loading the whole JSONL log + // (potentially many MB) into a String plus a full array of substrings. + enumerateLines(of: file) { line in + guard let rec = parseLine(line) else { return } if let key = rec.dedupKey { - if seen.contains(key) { continue } + if seen.contains(key) { return } seen.insert(key) } - guard rec.timestamp >= weekStart else { continue } + guard rec.timestamp >= weekStart else { return } let cost = ModelPricing.cost(model: rec.model, inputTokens: rec.inputTokens, outputTokens: rec.outputTokens) func add(_ t: inout UsageTotals) { t.inputTokens += rec.inputTokens @@ -83,4 +84,31 @@ struct JSONLUsageParser { snapshot.lastUpdated = now return snapshot } + + /// Reads a file in fixed-size chunks and invokes `handler` once per non-empty + /// line, without ever holding the entire file in memory. Runs synchronously on + /// the caller's thread (aggregation already happens off the main thread). + private static func enumerateLines(of file: URL, _ handler: (String) -> Void) { + guard let fh = try? FileHandle(forReadingFrom: file) else { return } + defer { try? fh.close() } + + let chunkSize = 1 << 16 // 64 KB + let newline = UInt8(0x0A) + var buffer = Data() + + while let chunk = try? fh.read(upToCount: chunkSize), !chunk.isEmpty { + buffer.append(chunk) + while let newlineIndex = buffer.firstIndex(of: newline) { + let lineData = buffer.subdata(in: buffer.startIndex.. ModelPricingData? in + let localURL = Bundle.main.url(forResource: "pricing", withExtension: "json", subdirectory: "DynamicIsland/managers/LLMUsage") + ?? Bundle.main.url(forResource: "pricing", withExtension: "json") + guard let localURL else { + print("❌ ModelPricingManager: Bundled pricing not found") + return nil + } do { let data = try Data(contentsOf: localURL) - self.pricingData = try JSONDecoder().decode(ModelPricingData.self, from: data) - print("✅ ModelPricingManager: Loaded bundled pricing fallback") + return try JSONDecoder().decode(ModelPricingData.self, from: data) } catch { print("❌ ModelPricingManager: Failed to load bundled pricing: \(error)") + return nil } - } else { - // Check flat manager path if subdirectory lookup fails - if let localURL = Bundle.main.url(forResource: "pricing", withExtension: "json") { - do { - let data = try Data(contentsOf: localURL) - self.pricingData = try JSONDecoder().decode(ModelPricingData.self, from: data) - print("✅ ModelPricingManager: Loaded bundled pricing from flat path") - } catch { - print("❌ ModelPricingManager: Failed to load bundled pricing (flat): \(error)") - } - } + }.value + + guard let decoded else { return } + await MainActor.run { + self.pricingData = decoded + print("✅ ModelPricingManager: Loaded bundled pricing fallback") } } diff --git a/DynamicIsland/managers/LockScreenWeatherPanelManager.swift b/DynamicIsland/managers/LockScreenWeatherPanelManager.swift index 493b5b99..7dcf4b85 100644 --- a/DynamicIsland/managers/LockScreenWeatherPanelManager.swift +++ b/DynamicIsland/managers/LockScreenWeatherPanelManager.swift @@ -27,6 +27,7 @@ final class LockScreenWeatherPanelManager { static let shared = LockScreenWeatherPanelManager() private var window: NSWindow? + private var hostingView: NSHostingView? private var hasDelegated = false private(set) var latestFrame: NSRect? private var lastSnapshot: LockScreenWeatherSnapshot? @@ -64,19 +65,32 @@ final class LockScreenWeatherPanelManager { return } - let view = LockScreenWeatherWidget(snapshot: snapshot) - let hostingView = NSHostingView(rootView: view) - let fittingSize = hostingView.fittingSize - if snapshot.widgetStyle == .inline { - lastInlineBaselineHeight = max(lastInlineBaselineHeight, fittingSize.height) + let window = ensureWindow() + + // Reuse a single cached hosting view and only swap its rootView. Recreating + // the NSHostingView on every update remounted the SwiftUI tree, re-firing + // `.onAppear` (timer/observer re-setup + forced calendar reload). Assigning + // `rootView` performs a lightweight diff instead. + let hosting = ensureHostingView(in: window, snapshot: snapshot) + hosting.rootView = LockScreenWeatherWidget(snapshot: snapshot) + + // `fittingSize` forces a synchronous layout pass. The window frame is only + // ever driven by snapshot changes here (internal widget ticks relayout + // within the existing bounds), so only re-measure when the snapshot changes. + let fittingSize: CGSize + if let cachedSize = lastContentSize, snapshot == lastSnapshot { + fittingSize = cachedSize + } else { + fittingSize = hosting.fittingSize + if snapshot.widgetStyle == .inline { + lastInlineBaselineHeight = max(lastInlineBaselineHeight, fittingSize.height) + } } - hostingView.frame = NSRect(origin: .zero, size: fittingSize) + hosting.frame = NSRect(origin: .zero, size: fittingSize) let targetFrame = frame(for: fittingSize, snapshot: snapshot, on: screen) - let window = ensureWindow() window.setFrame(targetFrame, display: true) latestFrame = targetFrame - window.contentView = hostingView lastSnapshot = snapshot lastContentSize = fittingSize @@ -85,6 +99,20 @@ final class LockScreenWeatherPanelManager { } } + private func ensureHostingView(in window: NSWindow, snapshot: LockScreenWeatherSnapshot) -> NSHostingView { + if let hostingView { + if window.contentView !== hostingView { + window.contentView = hostingView + } + return hostingView + } + + let view = NSHostingView(rootView: LockScreenWeatherWidget(snapshot: snapshot)) + hostingView = view + window.contentView = view + return view + } + private func ensureWindow() -> NSWindow { if let window { return window From 3b7ae7dc7a6d16f75849b80057629f4f7c5e97b6 Mon Sep 17 00:00:00 2001 From: mehmetefeaytas Date: Fri, 24 Jul 2026 00:15:48 +0300 Subject: [PATCH 12/20] perf(media/shelf): lazy now-playing subprocess; move icon/preview rendering off the main thread + cache Co-Authored-By: Claude Opus 4.8 (1M context) --- .../NowPlayingController.swift | 161 +++++++++++++++++- .../Shelf/ViewModels/ShelfItemViewModel.swift | 24 ++- .../Shelf/Views/ShelfItemView.swift | 37 ++-- DynamicIsland/helpers/AppIcons.swift | 39 +++-- 4 files changed, 215 insertions(+), 46 deletions(-) diff --git a/DynamicIsland/MediaControllers/NowPlayingController.swift b/DynamicIsland/MediaControllers/NowPlayingController.swift index 1020bd99..15eeaf95 100644 --- a/DynamicIsland/MediaControllers/NowPlayingController.swift +++ b/DynamicIsland/MediaControllers/NowPlayingController.swift @@ -58,6 +58,25 @@ final class NowPlayingController: ObservableObject, MediaControllerProtocol { private var pipeHandler: JSONLinesPipeHandler? private var streamTask: Task? + // MARK: - Lazy-start gating + /// Optional MediaRemote functions used to detect whether a now-playing + /// session exists without keeping the perl `stream` subprocess alive. + /// When available we only launch the subprocess while a now-playing app is + /// present, and tear it down after a long idle period. When unavailable we + /// fall back to the original eager-start behaviour so nothing regresses. + private let MRMediaRemoteRegisterForNowPlayingNotificationsFunction: + (@convention(c) (DispatchQueue) -> Void)? + private let MRMediaRemoteUnregisterForNowPlayingNotificationsFunction: + (@convention(c) () -> Void)? + private let MRMediaRemoteGetNowPlayingApplicationPIDFunction: + (@convention(c) (DispatchQueue, @escaping @convention(block) (Int32) -> Void) -> Void)? + + private var nowPlayingObservers: [NSObjectProtocol] = [] + private var idleStopTask: Task? + /// How long a now-playing session may be absent before the subprocess is + /// torn down. Kept generous so paused/idle sessions keep working. + private static let idleStopDelay: Duration = .seconds(180) + // MARK: - Initialization init?() { guard @@ -72,7 +91,7 @@ final class NowPlayingController: ObservableObject, MediaControllerProtocol { bundle, "MRMediaRemoteSetShuffleMode" as CFString), let MRMediaRemoteSetRepeatModePointer = CFBundleGetFunctionPointerForName( bundle, "MRMediaRemoteSetRepeatMode" as CFString) - + else { return nil } mediaRemoteBundle = bundle @@ -85,17 +104,55 @@ final class NowPlayingController: ObservableObject, MediaControllerProtocol { MRMediaRemoteSetRepeatModeFunction = unsafeBitCast( MRMediaRemoteSetRepeatModePointer, to: (@convention(c) (Int) -> Void).self) - Task { await setupNowPlayingObserver() } + // Optional presence-detection functions (may be absent on some macOS builds). + if let ptr = CFBundleGetFunctionPointerForName( + bundle, "MRMediaRemoteRegisterForNowPlayingNotifications" as CFString) { + MRMediaRemoteRegisterForNowPlayingNotificationsFunction = unsafeBitCast( + ptr, to: (@convention(c) (DispatchQueue) -> Void).self) + } else { + MRMediaRemoteRegisterForNowPlayingNotificationsFunction = nil + } + if let ptr = CFBundleGetFunctionPointerForName( + bundle, "MRMediaRemoteUnregisterForNowPlayingNotifications" as CFString) { + MRMediaRemoteUnregisterForNowPlayingNotificationsFunction = unsafeBitCast( + ptr, to: (@convention(c) () -> Void).self) + } else { + MRMediaRemoteUnregisterForNowPlayingNotificationsFunction = nil + } + if let ptr = CFBundleGetFunctionPointerForName( + bundle, "MRMediaRemoteGetNowPlayingApplicationPID" as CFString) { + MRMediaRemoteGetNowPlayingApplicationPIDFunction = unsafeBitCast( + ptr, + to: (@convention(c) (DispatchQueue, @escaping @convention(block) (Int32) -> Void) -> Void).self) + } else { + MRMediaRemoteGetNowPlayingApplicationPIDFunction = nil + } + + let canGate = MRMediaRemoteRegisterForNowPlayingNotificationsFunction != nil + && MRMediaRemoteGetNowPlayingApplicationPIDFunction != nil + + if canGate { + // Lazy: only spin up the perl subprocess once a now-playing app exists. + Task { @MainActor [weak self] in self?.beginLazyObservation() } + } else { + // Fallback: preserve original eager-start behaviour. + Task { @MainActor [weak self] in await self?.startStreamingIfNeeded() } + } } deinit { + MRMediaRemoteUnregisterForNowPlayingNotificationsFunction?() + for observer in nowPlayingObservers { + NotificationCenter.default.removeObserver(observer) + } + idleStopTask?.cancel() streamTask?.cancel() - + if let pipeHandler = self.pipeHandler { Task { await pipeHandler.close() } } - + if let process = self.process { if process.isRunning { process.terminate() @@ -107,6 +164,67 @@ final class NowPlayingController: ObservableObject, MediaControllerProtocol { self.pipeHandler = nil } + // MARK: - Lazy Observation + /// Registers for MediaRemote now-playing notifications (lightweight, no + /// subprocess) and starts/stops the perl stream based on session presence. + @MainActor + private func beginLazyObservation() { + MRMediaRemoteRegisterForNowPlayingNotificationsFunction?(DispatchQueue.main) + + let center = NotificationCenter.default + let names = [ + "kMRMediaRemoteNowPlayingApplicationDidChangeNotification", + "kMRMediaRemoteNowPlayingApplicationIsPlayingDidChangeNotification", + ] + for name in names { + let token = center.addObserver( + forName: Notification.Name(name), object: nil, queue: .main + ) { [weak self] _ in + self?.evaluateNowPlayingPresence() + } + nowPlayingObservers.append(token) + } + + // Initial check: start immediately if a session already exists at launch. + evaluateNowPlayingPresence() + } + + /// Queries whether a now-playing app is present and starts or schedules a + /// teardown of the perl stream accordingly. + @MainActor + private func evaluateNowPlayingPresence() { + guard let getPID = MRMediaRemoteGetNowPlayingApplicationPIDFunction else { + Task { @MainActor [weak self] in await self?.startStreamingIfNeeded() } + return + } + getPID(DispatchQueue.main) { [weak self] pid in + // Callback is delivered on the main queue. + Task { @MainActor [weak self] in + guard let self else { return } + if pid != 0 { + self.idleStopTask?.cancel() + self.idleStopTask = nil + await self.startStreamingIfNeeded() + } else { + self.scheduleIdleStop() + } + } + } + } + + /// Tears down the subprocess after a grace period if no session reappears. + @MainActor + private func scheduleIdleStop() { + guard process != nil else { return } // nothing running + guard idleStopTask == nil else { return } // already scheduled + idleStopTask = Task { [weak self] in + try? await Task.sleep(for: NowPlayingController.idleStopDelay) + guard let self, !Task.isCancelled else { return } + self.stopStreaming() + self.idleStopTask = nil + } + } + // MARK: - Protocol Implementation func play() async { MRMediaRemoteSendCommandFunction(0, nil) @@ -154,7 +272,12 @@ final class NowPlayingController: ObservableObject, MediaControllerProtocol { } // MARK: - Setup Methods - private func setupNowPlayingObserver() async { + /// Idempotently launches the perl `stream` subprocess. Safe to call + /// repeatedly — a no-op while a process is already running. + @MainActor + private func startStreamingIfNeeded() async { + guard process == nil else { return } + let process = Process() guard let scriptURL = Bundle.main.url(forResource: "mediaremote-adapter", withExtension: "pl"), @@ -168,10 +291,10 @@ final class NowPlayingController: ObservableObject, MediaControllerProtocol { assertionFailure("Could not find mediaremote-adapter.pl script or framework path") return } - + process.executableURL = URL(fileURLWithPath: "/usr/bin/perl") process.arguments = [scriptURL.path, frameworkPath, "stream"] - + let pipeHandler = JSONLinesPipeHandler() process.standardOutput = await pipeHandler.getPipe() @@ -187,7 +310,7 @@ final class NowPlayingController: ObservableObject, MediaControllerProtocol { else { return } print("NowPlayingController [stderr]: \(message)") } - + self.process = process self.pipeHandler = pipeHandler @@ -197,10 +320,32 @@ final class NowPlayingController: ObservableObject, MediaControllerProtocol { await self?.processJSONStream() } } catch { + // Reset so a later demand can retry cleanly. + self.process = nil + self.pipeHandler = nil assertionFailure("Failed to launch mediaremote-adapter.pl: \(error)") } } + /// Idempotently tears down the perl `stream` subprocess. Safe to call + /// repeatedly — a no-op when nothing is running. + @MainActor + private func stopStreaming() { + streamTask?.cancel() + streamTask = nil + + if let pipeHandler = self.pipeHandler { + Task { await pipeHandler.close() } + } + + if let process = self.process, process.isRunning { + process.terminate() + } + + self.process = nil + self.pipeHandler = nil + } + // MARK: - Async Stream Processing private func processJSONStream() async { guard let pipeHandler = self.pipeHandler else { return } diff --git a/DynamicIsland/components/Shelf/ViewModels/ShelfItemViewModel.swift b/DynamicIsland/components/Shelf/ViewModels/ShelfItemViewModel.swift index 6fe1d035..3added3b 100644 --- a/DynamicIsland/components/Shelf/ViewModels/ShelfItemViewModel.swift +++ b/DynamicIsland/components/Shelf/ViewModels/ShelfItemViewModel.swift @@ -124,8 +124,22 @@ final class ShelfItemViewModel: ObservableObject { await MainActor.run { self.icon = image } } + /// Cache of file-icon and rendered app-icon images. `NSCache` is + /// thread-safe, so it is shared between the detached background work below + /// and the main-actor menu code. + private static let iconCache = NSCache() + private func loadIconFromURL(_ url: URL) async -> NSImage { - return NSWorkspace.shared.icon(forFile: url.path) + let path = url.path + if let cached = Self.iconCache.object(forKey: path as NSString) { + return cached + } + // Resolve the icon off the main thread so opening the shelf doesn't hitch. + let image = await Task.detached(priority: .utility) { + NSWorkspace.shared.icon(forFile: path) + }.value + Self.iconCache.setObject(image, forKey: path as NSString) + return image } // Async version to resolve file URL without blocking main thread @@ -1201,6 +1215,13 @@ final class ShelfItemViewModel: ObservableObject { } private func nsAppIcon(for appURL: URL, size: CGFloat) -> NSImage? { + // Cache the rendered result so repeatedly opening the context menu + // doesn't re-fetch and re-render the same icon on the main thread. + let cacheKey = "\(appURL.path)@\(size)" as NSString + if let cached = Self.iconCache.object(forKey: cacheKey) { + return cached + } + let baseIcon = NSWorkspace.shared.icon(forFile: appURL.path) baseIcon.isTemplate = false @@ -1214,6 +1235,7 @@ final class ShelfItemViewModel: ObservableObject { } rendered.size = targetSize + Self.iconCache.setObject(rendered, forKey: cacheKey) return rendered } diff --git a/DynamicIsland/components/Shelf/Views/ShelfItemView.swift b/DynamicIsland/components/Shelf/Views/ShelfItemView.swift index 6871a6c4..4c0084f3 100644 --- a/DynamicIsland/components/Shelf/Views/ShelfItemView.swift +++ b/DynamicIsland/components/Shelf/Views/ShelfItemView.swift @@ -87,9 +87,11 @@ struct ShelfItemView: View { } } .onAppear { - // Metadata loading is now done in ViewModel.init via loadMetadata() - // Pre-render drag preview once on appear - Task { + // Metadata loading is now done in ViewModel.init via loadMetadata(). + // Render the composed drag preview lazily and cache it. The + // ImageRenderer pass is @MainActor, so it is kept off the shelf-open + // critical path (deferred, low priority, and only produced once). + Task(priority: .utility) { if cachedPreviewImage == nil { cachedPreviewImage = await renderDragPreview() } @@ -98,12 +100,6 @@ struct ShelfItemView: View { quickLookService.show(urls: urls, selectFirst: true) } } - .onChange(of: viewModel.thumbnail) { _, _ in - // Invalidate cached preview when thumbnail changes - Task { - cachedPreviewImage = await renderDragPreview() - } - } .quickLookPresenter(using: quickLookService) } @@ -196,12 +192,16 @@ private struct DraggableClickHandler: NSViewRepresentable { let view = DraggableClickView() view.item = item view.viewModel = viewModel - view.dragPreviewImage = cachedPreviewImage ?? renderDragPreview() + // Avoid a synchronous ImageRenderer pass during view creation (it would + // hitch when many shelf items appear at once). Use the cached composed + // preview if it's ready, otherwise fall back to the plain thumbnail/icon; + // the composed preview is filled in asynchronously via the binding. + view.dragPreviewImage = cachedPreviewImage ?? viewModel.thumbnail ?? viewModel.icon view.onRightClick = onRightClick view.onClick = onClick return view } - + func updateNSView(_ nsView: DraggableClickView, context: Context) { nsView.item = item nsView.viewModel = viewModel @@ -212,20 +212,7 @@ private struct DraggableClickHandler: NSViewRepresentable { nsView.onRightClick = onRightClick nsView.onClick = onClick } - - private func renderDragPreview() -> NSImage { - let content = dragPreviewContent() - let renderer = ImageRenderer(content: content) - renderer.scale = NSScreen.main?.backingScaleFactor ?? 2.0 - - if let nsImage = renderer.nsImage { - return nsImage - } - - // Fallback to icon if rendering fails - return viewModel.thumbnail ?? viewModel.icon ?? NSImage() - } - + final class DraggableClickView: NSView, NSDraggingSource { var item: ShelfItem! weak var viewModel: ShelfItemViewModel? diff --git a/DynamicIsland/helpers/AppIcons.swift b/DynamicIsland/helpers/AppIcons.swift index 0b563c45..59bb0f47 100644 --- a/DynamicIsland/helpers/AppIcons.swift +++ b/DynamicIsland/helpers/AppIcons.swift @@ -20,21 +20,38 @@ import SwiftUI import AppKit import Defaults +/// Process-wide cache of resolved app icons keyed by file path. +/// `NSCache` is thread-safe, so it can be shared across the synchronous +/// call sites below (which run on the main thread during SwiftUI renders) +/// without extra locking. App icons rarely change during a session, so +/// caching removes redundant, main-thread `NSWorkspace` lookups on every +/// view refresh. +private let appIconPathCache = NSCache() + +private func cachedIcon(forFile path: String) -> NSImage { + if let cached = appIconPathCache.object(forKey: path as NSString) { + return cached + } + let icon = NSWorkspace.shared.icon(forFile: path) + appIconPathCache.setObject(icon, forKey: path as NSString) + return icon +} + struct AppIcons { - + func getIcon(file path: String) -> NSImage? { guard FileManager.default.fileExists(atPath: path) else { return nil } - - return NSWorkspace.shared.icon(forFile: path) + + return cachedIcon(forFile: path) } - + func getIcon(bundleID: String) -> NSImage? { guard let path = NSWorkspace.shared.urlForApplication( withBundleIdentifier: bundleID )?.absoluteString else { return nil } - + return getIcon(file: path) } @@ -50,22 +67,20 @@ struct AppIcons { func AppIcon(for bundleID: String) -> Image { let workspace = NSWorkspace.shared - + if let appURL = workspace.urlForApplication(withBundleIdentifier: bundleID) { - let appIcon = workspace.icon(forFile: appURL.path) - return Image(nsImage: appIcon) + return Image(nsImage: cachedIcon(forFile: appURL.path)) } - + return Image(nsImage: workspace.icon(for: .applicationBundle)) } func AppIconAsNSImage(for bundleID: String) -> NSImage? { let workspace = NSWorkspace.shared - + if let appURL = workspace.urlForApplication(withBundleIdentifier: bundleID) { - let appIcon = workspace.icon(forFile: appURL.path) - return appIcon + return cachedIcon(forFile: appURL.path) } return nil } From cd8b827072e97936efb15fddc28d5817d43cf8f0 Mon Sep 17 00:00:00 2001 From: mehmetefeaytas Date: Fri, 24 Jul 2026 00:16:23 +0300 Subject: [PATCH 13/20] perf(stats): collect system metrics off the main actor; throttle GPU/disk enumeration Co-Authored-By: Claude Opus 4.8 (1M context) --- DynamicIsland/managers/StatsManager.swift | 543 +++++++++++++--------- 1 file changed, 333 insertions(+), 210 deletions(-) diff --git a/DynamicIsland/managers/StatsManager.swift b/DynamicIsland/managers/StatsManager.swift index 317bde69..ea4d8f60 100644 --- a/DynamicIsland/managers/StatsManager.swift +++ b/DynamicIsland/managers/StatsManager.swift @@ -264,7 +264,7 @@ private final class GPUInfoCollector { } } -struct GPUBreakdown: Equatable { +struct GPUBreakdown: Equatable, Sendable { let render: Double let compute: Double let video: Double @@ -308,7 +308,7 @@ struct NetworkInterfaceMetrics: Identifiable, Equatable { var id: String { name } } -struct DiskDeviceMetrics: Identifiable, Equatable { +struct DiskDeviceMetrics: Identifiable, Equatable, Sendable { let id: String let name: String let path: URL @@ -327,7 +327,7 @@ struct DiskDeviceMetrics: Identifiable, Equatable { } } -struct GPUDeviceMetrics: Identifiable, Equatable { +struct GPUDeviceMetrics: Identifiable, Equatable, Sendable { let id: String let vendor: String? let model: String @@ -443,8 +443,8 @@ class StatsManager: ObservableObject { private var previousNetworkStats: (bytesIn: UInt64, bytesOut: UInt64) = (0, 0) private var previousTimestamp: Date = Date() - // Disk monitoring state - private var previousDiskStats: (bytesRead: UInt64, bytesWritten: UInt64) = (0, 0) + // Disk delta state now lives inside `slowCollector` (see below) so the heavy + // IOStorage walk runs off the main actor without racing the fast path. private var previousCPULoadInfo: host_cpu_load_info? private var previousCpuInfo: processor_info_array_t? private var previousCpuInfoCount: mach_msg_type_number_t = 0 @@ -458,13 +458,20 @@ class StatsManager: ObservableObject { private let processStatsUpdateInterval: TimeInterval = 2.0 private let maxProcessEntries: Int = 20 private var isProcessRefreshInFlight = false - private let gpuCollector = GPUInfoCollector() - private let cpuSensorCollector = CPUSensorCollector() private var cancellables = Set() private let minUpdateInterval: TimeInterval = 1.0 private let maxUpdateInterval: TimeInterval = 60.0 private let notchCloseStopDelay: TimeInterval = 3.0 private let tabSwitchStopDelay: TimeInterval = 0.1 + + // Heavy/slow metrics (GPU, disk I/O + volumes, thermal + frequency sensors) are + // gathered by a dedicated background actor and refreshed on a slower cadence than + // the 1s CPU/memory path. Only the final @Published assignments happen on main. + private let slowCollector = SystemSlowMetricsCollector() + private let slowMetricsInterval: TimeInterval = 5.0 + private var lastSlowMetricsUpdate: Date = .distantPast + private var slowMetricsNeedsBaseline = true + private var isSlowRefreshInFlight = false // MARK: - Initialization private init() { @@ -481,10 +488,7 @@ class StatsManager: ObservableObject { let initialStats = aggregateNetworkStats(from: snapshotNetworkInterfaces()) previousNetworkStats = initialStats previousTimestamp = Date() - - // Initialize baseline disk stats - let initialDiskStats = getDiskStats() - previousDiskStats = initialDiskStats + // Disk baseline is established by `slowCollector` on the first refresh. Defaults.publisher(.statsUpdateInterval, options: []).sink { [weak self] change in self?.handleUpdateIntervalChange(change.newValue) @@ -556,17 +560,19 @@ class StatsManager: ObservableObject { // Reset baseline for accurate measurement let initialStats = aggregateNetworkStats(from: snapshotNetworkInterfaces()) previousNetworkStats = initialStats - - let initialDiskStats = getDiskStats() - previousDiskStats = initialDiskStats - + previousTimestamp = Date() - + isMonitoring = true lastUpdated = Date() networkTotals = .zero diskTotals = .zero - + // Force the slow path to re-establish its disk baseline on the first tick so a + // paused-then-resumed session does not report one huge accumulated delta. + slowMetricsNeedsBaseline = true + lastSlowMetricsUpdate = .distantPast + isSlowRefreshInFlight = false + scheduleMonitoringTimer() Task { @MainActor in @@ -606,6 +612,12 @@ class StatsManager: ObservableObject { interfaceTotals.removeAll() cpuTemperature = CPUTemperatureMetrics(celsius: nil) cpuFrequency = nil + // Reset slow-path scheduling so the next session rebaselines cleanly. Any + // in-flight slow refresh that lands after this is dropped by the + // `isMonitoring` guard in `applySlowMetrics(_:)`. + slowMetricsNeedsBaseline = true + lastSlowMetricsUpdate = .distantPast + isSlowRefreshInFlight = false } private func scheduleMonitoringTimer() { @@ -662,57 +674,41 @@ class StatsManager: ObservableObject { } // MARK: - Private Methods + /// Fast path (runs at the configured interval, 1s by default). Only cheap syscalls + /// (CPU load, memory, per-core, network via a single getifaddrs walk) run here on the + /// main actor. The expensive GPU/disk/sensor enumerations are handled off-main by + /// `maybeRefreshSlowMetrics()` on a slower cadence. @MainActor private func updateSystemStats() { let cpuMetrics = getCPULoadBreakdown() let newCpuUsage = cpuMetrics.activeUsage let memorySnapshot = getMemorySnapshot() let newMemoryUsage = memorySnapshot.usage - let gpuSnapshot = getGPUMetrics() - let newGpuUsage = gpuSnapshot.usage let coreUsage = collectCPUCoreUsage() - + // Calculate network speeds (single getifaddrs walk feeds both totals and per-interface metrics) let networkSnapshots = snapshotNetworkInterfaces() let currentNetworkStats = aggregateNetworkStats(from: networkSnapshots) let currentTime = Date() let timeInterval = currentTime.timeIntervalSince(previousTimestamp) - + var downloadSpeed: Double = 0.0 var uploadSpeed: Double = 0.0 var bytesDownloaded: UInt64 = 0 var bytesUploaded: UInt64 = 0 - + // Only calculate speeds if we have a reasonable time interval and this isn't the first run if timeInterval > 0.1 && (previousNetworkStats.bytesIn > 0 || previousNetworkStats.bytesOut > 0) { - bytesDownloaded = currentNetworkStats.bytesIn > previousNetworkStats.bytesIn ? + bytesDownloaded = currentNetworkStats.bytesIn > previousNetworkStats.bytesIn ? currentNetworkStats.bytesIn - previousNetworkStats.bytesIn : 0 - bytesUploaded = currentNetworkStats.bytesOut > previousNetworkStats.bytesOut ? + bytesUploaded = currentNetworkStats.bytesOut > previousNetworkStats.bytesOut ? currentNetworkStats.bytesOut - previousNetworkStats.bytesOut : 0 - + downloadSpeed = Double(bytesDownloaded) / timeInterval / 1_048_576 // Convert to MB/s uploadSpeed = Double(bytesUploaded) / timeInterval / 1_048_576 // Convert to MB/s } - - // Calculate disk speeds - let currentDiskStats = getDiskStats() - var readSpeed: Double = 0.0 - var writeSpeed: Double = 0.0 - var bytesRead: UInt64 = 0 - var bytesWritten: UInt64 = 0 - - // Only calculate speeds if we have a reasonable time interval and this isn't the first run - if timeInterval > 0.1 && (previousDiskStats.bytesRead > 0 || previousDiskStats.bytesWritten > 0) { - bytesRead = currentDiskStats.bytesRead > previousDiskStats.bytesRead ? - currentDiskStats.bytesRead - previousDiskStats.bytesRead : 0 - bytesWritten = currentDiskStats.bytesWritten > previousDiskStats.bytesWritten ? - currentDiskStats.bytesWritten - previousDiskStats.bytesWritten : 0 - - readSpeed = Double(bytesRead) / timeInterval / 1_048_576 // Convert to MB/s - writeSpeed = Double(bytesWritten) / timeInterval / 1_048_576 // Convert to MB/s - } - - // Update cumulative transfer totals + + // Update cumulative transfer totals (network). Disk totals accumulate on the slow path. if bytesDownloaded > 0 { var updatedTotals = networkTotals updatedTotals.downloadedMB += Double(bytesDownloaded) / 1_048_576 @@ -723,61 +719,105 @@ class StatsManager: ObservableObject { updatedTotals.uploadedMB += Double(bytesUploaded) / 1_048_576 networkTotals = updatedTotals } - if bytesRead > 0 { - var updatedDiskTotals = diskTotals - updatedDiskTotals.readMB += Double(bytesRead) / 1_048_576 - diskTotals = updatedDiskTotals - } - if bytesWritten > 0 { - var updatedDiskTotals = diskTotals - updatedDiskTotals.writtenMB += Double(bytesWritten) / 1_048_576 - diskTotals = updatedDiskTotals - } - - // Update current values + + // Update current values (fast path) cpuUsage = newCpuUsage - gpuUsage = newGpuUsage memoryUsage = newMemoryUsage networkDownload = max(0.0, downloadSpeed) networkUpload = max(0.0, uploadSpeed) - diskRead = max(0.0, readSpeed) - diskWrite = max(0.0, writeSpeed) lastUpdated = Date() cpuBreakdown = cpuMetrics memoryBreakdown = memorySnapshot.breakdown cpuLoadAverage = getLoadAverage() - gpuBreakdown = gpuSnapshot.breakdown - if gpuDevices != gpuSnapshot.devices { - gpuDevices = gpuSnapshot.devices - } if cpuCoreUsage != coreUsage { cpuCoreUsage = coreUsage } cpuUptime = ProcessInfo.processInfo.systemUptime - cpuTemperature = cpuSensorCollector.readTemperature() - if let frequencyMetrics = cpuSensorCollector.readFrequency() { - cpuFrequency = frequencyMetrics - } - - // Update history arrays (sliding window) + + // Update history arrays (sliding window). GPU/disk reuse the most recent + // slow-path values so every series keeps advancing at the fast cadence. updateHistory(value: newCpuUsage, history: &cpuHistory) updateHistory(value: newMemoryUsage, history: &memoryHistory) - updateHistory(value: newGpuUsage, history: &gpuHistory) + updateHistory(value: gpuUsage, history: &gpuHistory) updateHistory(value: downloadSpeed, history: &networkDownloadHistory) updateHistory(value: uploadSpeed, history: &networkUploadHistory) - updateHistory(value: readSpeed, history: &diskReadHistory) - updateHistory(value: writeSpeed, history: &diskWriteHistory) - + updateHistory(value: diskRead, history: &diskReadHistory) + updateHistory(value: diskWrite, history: &diskWriteHistory) + // Update previous stats for next calculation previousNetworkStats = currentNetworkStats - previousDiskStats = currentDiskStats previousTimestamp = currentTime networkInterfaces = collectNetworkInterfaces(from: networkSnapshots, deltaTime: timeInterval) - diskDevices = collectDiskDevices() + + // Off-main heavy metrics (GPU, disk I/O + volumes, thermal/frequency), throttled. + maybeRefreshSlowMetrics() + // 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) } + + /// Kicks off an off-main collection of the heavy metrics when due (~every + /// `slowMetricsInterval`, or immediately on the first tick after start). Guarded so at + /// most one refresh is in flight; the final @Published assignments happen on main via + /// `applySlowMetrics(_:)`. + @MainActor + private func maybeRefreshSlowMetrics() { + let now = Date() + let isDue = slowMetricsNeedsBaseline || now.timeIntervalSince(lastSlowMetricsUpdate) >= slowMetricsInterval + guard isDue, !isSlowRefreshInFlight else { return } + + let resetBaseline = slowMetricsNeedsBaseline + slowMetricsNeedsBaseline = false + lastSlowMetricsUpdate = now + isSlowRefreshInFlight = true + + Task { [weak self] in + guard let self else { return } + let snapshot = await self.slowCollector.collect(resetBaseline: resetBaseline) + await MainActor.run { + self.applySlowMetrics(snapshot) + self.isSlowRefreshInFlight = false + } + } + } + + /// Applies a slow-metrics snapshot to the @Published properties on the main actor. + /// Mirrors the assignment logic the fast path used to perform inline. + @MainActor + private func applySlowMetrics(_ snapshot: SlowMetricsSnapshot) { + // Drop stale results that land after monitoring stopped. + guard isMonitoring else { return } + + gpuUsage = snapshot.gpuUsage + gpuBreakdown = snapshot.gpuBreakdown + if gpuDevices != snapshot.gpuDevices { + gpuDevices = snapshot.gpuDevices + } + + diskRead = max(0.0, snapshot.diskRead) + diskWrite = max(0.0, snapshot.diskWrite) + if snapshot.bytesRead > 0 { + var updatedDiskTotals = diskTotals + updatedDiskTotals.readMB += Double(snapshot.bytesRead) / 1_048_576 + diskTotals = updatedDiskTotals + } + if snapshot.bytesWritten > 0 { + var updatedDiskTotals = diskTotals + updatedDiskTotals.writtenMB += Double(snapshot.bytesWritten) / 1_048_576 + diskTotals = updatedDiskTotals + } + // Preserve prior devices if enumeration returned nothing (matches the previous + // fallback where `collectDiskDevices()` returned the existing list on failure). + if !snapshot.diskDevices.isEmpty { + diskDevices = snapshot.diskDevices + } + + cpuTemperature = snapshot.temperature + if let frequencyMetrics = snapshot.frequency { + cpuFrequency = frequencyMetrics + } + } private func updateHistory(value: Double, history: inout [Double]) { // Remove first element and append new value @@ -944,45 +984,6 @@ class StatsManager: ObservableObject { return (min(100.0, max(0.0, usage)), breakdown) } - private func getGPUMetrics() -> GPUMetricsSnapshot { - let devices = gpuCollector.collectDevices() - guard !devices.isEmpty else { - return .zero - } - let utilizationValues = devices.compactMap { $0.utilization } - let usage: Double - if utilizationValues.isEmpty { - usage = 0 - } else { - usage = utilizationValues.reduce(0, +) / Double(utilizationValues.count) - } - let breakdown = makeGPUBreakdown(from: devices) - return GPUMetricsSnapshot(usage: usage, breakdown: breakdown, devices: devices) - } - - private func makeGPUBreakdown(from devices: [GPUDeviceMetrics]) -> GPUBreakdown { - guard let primary = devices.first(where: { ($0.utilization ?? 0) > 0 || ($0.renderUtilization ?? 0) > 0 || ($0.tilerUtilization ?? 0) > 0 }) else { - return .zero - } - let fallbackTotal = max((primary.renderUtilization ?? 0) + (primary.tilerUtilization ?? 0), 0) - let total = max(primary.utilization ?? fallbackTotal, 0) - if total.isZero { - return GPUBreakdown( - render: primary.renderUtilization ?? 0, - compute: primary.tilerUtilization ?? 0, - video: 0, - other: 0 - ) - } - let render = min(primary.renderUtilization ?? 0, total) - let tiler = min(primary.tilerUtilization ?? 0, max(total - render, 0)) - let remaining = max(total - render - tiler, 0) - let compute = remaining * 0.6 - let video = remaining * 0.25 - let other = max(remaining - compute - video, 0) - return GPUBreakdown(render: render, compute: compute, video: video, other: other) - } - private func collectCPUCoreUsage() -> [CPUCoreUsage] { var cpuInfo: processor_info_array_t? var numCpuInfo: mach_msg_type_number_t = 0 @@ -1140,57 +1141,6 @@ class StatsManager: ObservableObject { } } - private func collectDiskDevices() -> [DiskDeviceMetrics] { - let keys: [URLResourceKey] = [ - .volumeNameKey, - .volumeTotalCapacityKey, - .volumeAvailableCapacityKey, - .volumeAvailableCapacityForImportantUsageKey, - .volumeAvailableCapacityForOpportunisticUsageKey, - .volumeIsRootFileSystemKey, - .volumeIsRemovableKey - ] - guard let urls = FileManager.default.mountedVolumeURLs(includingResourceValuesForKeys: keys, options: [.skipHiddenVolumes]) else { - return diskDevices - } - var devices: [DiskDeviceMetrics] = [] - for url in urls { - guard let values = try? url.resourceValues(forKeys: Set(keys)) else { continue } - guard let totalCapacity = values.volumeTotalCapacity.flatMap({ Int64($0) }), totalCapacity > 0 else { continue } - let name = values.volumeName ?? url.lastPathComponent - let freeCapacityValue: Int64 - if let free = values.volumeAvailableCapacity { - freeCapacityValue = Int64(free) - } else if let important = values.volumeAvailableCapacityForImportantUsage { - freeCapacityValue = important - } else if let opportunistic = values.volumeAvailableCapacityForOpportunisticUsage { - freeCapacityValue = opportunistic - } else { - freeCapacityValue = 0 - } - let clampedFree = max(freeCapacityValue, 0) - let device = DiskDeviceMetrics( - id: url.path, - name: name, - path: url, - totalBytes: UInt64(totalCapacity), - freeBytes: UInt64(clampedFree), - isRoot: values.volumeIsRootFileSystem ?? false, - isRemovable: values.volumeIsRemovable ?? false - ) - devices.append(device) - } - return devices.sorted { lhs, rhs in - if lhs.isRoot != rhs.isRoot { - return lhs.isRoot - } - if lhs.isRemovable != rhs.isRemovable { - return !lhs.isRemovable - } - return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending - } - } - private func stringFromSockaddr(_ addressPointer: UnsafePointer?) -> String? { guard let addressPointer else { return nil } let family = Int32(addressPointer.pointee.sa_family) @@ -1288,50 +1238,6 @@ class StatsManager: ObservableObject { } } - private func getDiskStats() -> (bytesRead: UInt64, bytesWritten: UInt64) { - // Use IOKit to get disk I/O statistics from IOStorage service - var totalBytesRead: UInt64 = 0 - var totalBytesWritten: UInt64 = 0 - - let matchingDict = IOServiceMatching("IOStorage") - var iterator: io_iterator_t = 0 - let result = IOServiceGetMatchingServices(kIOMainPortDefault, matchingDict, &iterator) - - guard result == KERN_SUCCESS else { - return (totalBytesRead, totalBytesWritten) - } - - defer { IOObjectRelease(iterator) } - - var service: io_registry_entry_t = IOIteratorNext(iterator) - while service != 0 { - defer { - IOObjectRelease(service) - service = IOIteratorNext(iterator) - } - - var properties: Unmanaged? - let propertiesResult = IORegistryEntryCreateCFProperties(service, &properties, kCFAllocatorDefault, 0) - - guard propertiesResult == KERN_SUCCESS, - let props = properties?.takeRetainedValue() as? [String: Any], - let statistics = props["Statistics"] as? [String: Any] else { - continue - } - - // Use the correct property names for APFS/modern filesystems - if let bytesRead = statistics["Bytes read from block device"] as? UInt64 { - totalBytesRead += bytesRead - } - - if let bytesWritten = statistics["Bytes written to block device"] as? UInt64 { - totalBytesWritten += bytesWritten - } - } - - return (totalBytesRead, totalBytesWritten) - } - // MARK: - Computed Properties for UI var cpuUsageString: String { return String(format: "%.1f%%", cpuUsage) @@ -1535,3 +1441,220 @@ class StatsManager: ObservableObject { return taskInfo.pti_resident_size } } + +// MARK: - Slow (off-main) system metrics + +/// Immutable, `Sendable` payload carrying the heavy metrics from the background actor +/// back to the main actor for publishing. +private struct SlowMetricsSnapshot: Sendable { + let gpuUsage: Double + let gpuBreakdown: GPUBreakdown + let gpuDevices: [GPUDeviceMetrics] + let diskRead: Double + let diskWrite: Double + let bytesRead: UInt64 + let bytesWritten: UInt64 + let diskDevices: [DiskDeviceMetrics] + let temperature: CPUTemperatureMetrics + let frequency: CPUFrequencyMetrics? +} + +/// Owns the expensive system enumerations (GPU accelerators via IOKit, IOStorage disk I/O +/// counters, mounted-volume capacities, SMC temperature, IOReport frequency) and their +/// delta state. Being an `actor` serializes every access, so the non-thread-safe collectors +/// (`GPUInfoCollector`, `CPUSensorCollector`, `SMC.shared`) are only ever touched from this +/// single isolation domain — never on the main thread and never concurrently. The calculation +/// logic is copied verbatim from the former main-actor code; only the execution context moved. +private actor SystemSlowMetricsCollector { + private let gpuCollector = GPUInfoCollector() + private let cpuSensorCollector = CPUSensorCollector() + private var previousDiskStats: (bytesRead: UInt64, bytesWritten: UInt64) = (0, 0) + private var previousTimestamp: Date? + + /// Collects one slow snapshot. When `resetBaseline` is true the disk delta baseline is + /// re-primed and throughput is reported as zero (used on the first tick after start so a + /// paused-then-resumed session does not emit a single huge accumulated delta). + func collect(resetBaseline: Bool) -> SlowMetricsSnapshot { + let gpuSnapshot = getGPUMetrics() + let diskDevices = collectDiskDevices() + let temperature = cpuSensorCollector.readTemperature() + let frequency = cpuSensorCollector.readFrequency() + + let currentDiskStats = getDiskStats() + let currentTime = Date() + + var readSpeed: Double = 0.0 + var writeSpeed: Double = 0.0 + var bytesRead: UInt64 = 0 + var bytesWritten: UInt64 = 0 + + // Only calculate speeds if we have a reasonable time interval and this isn't the first run + if !resetBaseline, + let previousTime = previousTimestamp { + let timeInterval = currentTime.timeIntervalSince(previousTime) + if timeInterval > 0.1 && (previousDiskStats.bytesRead > 0 || previousDiskStats.bytesWritten > 0) { + bytesRead = currentDiskStats.bytesRead > previousDiskStats.bytesRead ? + currentDiskStats.bytesRead - previousDiskStats.bytesRead : 0 + bytesWritten = currentDiskStats.bytesWritten > previousDiskStats.bytesWritten ? + currentDiskStats.bytesWritten - previousDiskStats.bytesWritten : 0 + + readSpeed = Double(bytesRead) / timeInterval / 1_048_576 // Convert to MB/s + writeSpeed = Double(bytesWritten) / timeInterval / 1_048_576 // Convert to MB/s + } + } + + previousDiskStats = currentDiskStats + previousTimestamp = currentTime + + return SlowMetricsSnapshot( + gpuUsage: gpuSnapshot.usage, + gpuBreakdown: gpuSnapshot.breakdown, + gpuDevices: gpuSnapshot.devices, + diskRead: readSpeed, + diskWrite: writeSpeed, + bytesRead: bytesRead, + bytesWritten: bytesWritten, + diskDevices: diskDevices, + temperature: temperature, + frequency: frequency + ) + } + + // MARK: - Heavy collectors (moved off the main actor, logic unchanged) + + private func getGPUMetrics() -> GPUMetricsSnapshot { + let devices = gpuCollector.collectDevices() + guard !devices.isEmpty else { + return .zero + } + let utilizationValues = devices.compactMap { $0.utilization } + let usage: Double + if utilizationValues.isEmpty { + usage = 0 + } else { + usage = utilizationValues.reduce(0, +) / Double(utilizationValues.count) + } + let breakdown = makeGPUBreakdown(from: devices) + return GPUMetricsSnapshot(usage: usage, breakdown: breakdown, devices: devices) + } + + private func makeGPUBreakdown(from devices: [GPUDeviceMetrics]) -> GPUBreakdown { + guard let primary = devices.first(where: { ($0.utilization ?? 0) > 0 || ($0.renderUtilization ?? 0) > 0 || ($0.tilerUtilization ?? 0) > 0 }) else { + return .zero + } + let fallbackTotal = max((primary.renderUtilization ?? 0) + (primary.tilerUtilization ?? 0), 0) + let total = max(primary.utilization ?? fallbackTotal, 0) + if total.isZero { + return GPUBreakdown( + render: primary.renderUtilization ?? 0, + compute: primary.tilerUtilization ?? 0, + video: 0, + other: 0 + ) + } + let render = min(primary.renderUtilization ?? 0, total) + let tiler = min(primary.tilerUtilization ?? 0, max(total - render, 0)) + let remaining = max(total - render - tiler, 0) + let compute = remaining * 0.6 + let video = remaining * 0.25 + let other = max(remaining - compute - video, 0) + return GPUBreakdown(render: render, compute: compute, video: video, other: other) + } + + private func collectDiskDevices() -> [DiskDeviceMetrics] { + let keys: [URLResourceKey] = [ + .volumeNameKey, + .volumeTotalCapacityKey, + .volumeAvailableCapacityKey, + .volumeAvailableCapacityForImportantUsageKey, + .volumeAvailableCapacityForOpportunisticUsageKey, + .volumeIsRootFileSystemKey, + .volumeIsRemovableKey + ] + guard let urls = FileManager.default.mountedVolumeURLs(includingResourceValuesForKeys: keys, options: [.skipHiddenVolumes]) else { + // Empty result is treated as "no change" by the main-actor applier, preserving + // the previous fallback that returned the existing device list on failure. + return [] + } + var devices: [DiskDeviceMetrics] = [] + for url in urls { + guard let values = try? url.resourceValues(forKeys: Set(keys)) else { continue } + guard let totalCapacity = values.volumeTotalCapacity.flatMap({ Int64($0) }), totalCapacity > 0 else { continue } + let name = values.volumeName ?? url.lastPathComponent + let freeCapacityValue: Int64 + if let free = values.volumeAvailableCapacity { + freeCapacityValue = Int64(free) + } else if let important = values.volumeAvailableCapacityForImportantUsage { + freeCapacityValue = important + } else if let opportunistic = values.volumeAvailableCapacityForOpportunisticUsage { + freeCapacityValue = opportunistic + } else { + freeCapacityValue = 0 + } + let clampedFree = max(freeCapacityValue, 0) + let device = DiskDeviceMetrics( + id: url.path, + name: name, + path: url, + totalBytes: UInt64(totalCapacity), + freeBytes: UInt64(clampedFree), + isRoot: values.volumeIsRootFileSystem ?? false, + isRemovable: values.volumeIsRemovable ?? false + ) + devices.append(device) + } + return devices.sorted { lhs, rhs in + if lhs.isRoot != rhs.isRoot { + return lhs.isRoot + } + if lhs.isRemovable != rhs.isRemovable { + return !lhs.isRemovable + } + return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending + } + } + + private func getDiskStats() -> (bytesRead: UInt64, bytesWritten: UInt64) { + // Use IOKit to get disk I/O statistics from IOStorage service + var totalBytesRead: UInt64 = 0 + var totalBytesWritten: UInt64 = 0 + + let matchingDict = IOServiceMatching("IOStorage") + var iterator: io_iterator_t = 0 + let result = IOServiceGetMatchingServices(kIOMainPortDefault, matchingDict, &iterator) + + guard result == KERN_SUCCESS else { + return (totalBytesRead, totalBytesWritten) + } + + defer { IOObjectRelease(iterator) } + + var service: io_registry_entry_t = IOIteratorNext(iterator) + while service != 0 { + defer { + IOObjectRelease(service) + service = IOIteratorNext(iterator) + } + + var properties: Unmanaged? + let propertiesResult = IORegistryEntryCreateCFProperties(service, &properties, kCFAllocatorDefault, 0) + + guard propertiesResult == KERN_SUCCESS, + let props = properties?.takeRetainedValue() as? [String: Any], + let statistics = props["Statistics"] as? [String: Any] else { + continue + } + + // Use the correct property names for APFS/modern filesystems + if let bytesRead = statistics["Bytes read from block device"] as? UInt64 { + totalBytesRead += bytesRead + } + + if let bytesWritten = statistics["Bytes written to block device"] as? UInt64 { + totalBytesWritten += bytesWritten + } + } + + return (totalBytesRead, totalBytesWritten) + } +} From e137d6cb693384d06337dc62eeaeba1a46f31abe Mon Sep 17 00:00:00 2001 From: mehmetefeaytas Date: Fri, 24 Jul 2026 00:27:09 +0300 Subject: [PATCH 14/20] perf(ui): cache screen lookups + extract independent leaf subviews to scope re-renders Co-Authored-By: Claude Opus 4.8 (1M context) --- DynamicIsland/ContentView.swift | 41 ++++++++++++++++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/DynamicIsland/ContentView.swift b/DynamicIsland/ContentView.swift index 1142d1e8..e864acae 100644 --- a/DynamicIsland/ContentView.swift +++ b/DynamicIsland/ContentView.swift @@ -44,7 +44,13 @@ struct ContentView: View { @ObservedObject var timerManager = TimerManager.shared @ObservedObject var reminderManager = ReminderLiveActivityManager.shared @ObservedObject var batteryModel = BatteryStatusViewModel.shared - @ObservedObject var statsManager = StatsManager.shared + // NOTE: Intentionally NOT @ObservedObject. `StatsManager` publishes every ~1s, + // and observing it here invalidated the entire ContentView body on every tick. + // ContentView only uses it for imperative `updateMonitoringState(...)` calls and + // for `statsRowCount()` (which reads @Default flags, not published stats values). + // The live stats UI (`NotchStatsView`) owns its own @ObservedObject StatsManager, + // so reactivity for the stats display is preserved and now scoped to that leaf. + let statsManager = StatsManager.shared @ObservedObject var recordingManager = ScreenRecordingManager.shared @ObservedObject var privacyManager = PrivacyIndicatorManager.shared @ObservedObject var doNotDisturbManager = DoNotDisturbManager.shared @@ -212,6 +218,11 @@ struct ContentView: View { @State private var hiddenEdgeHoverPollingTask: Task? @State private var isHoveringClosedMusicWaveformControl: Bool = false + /// Cached result of the `NSScreen.screens` notch scan (see `isNonNotchScreen`). + /// Defaults to `true`, matching the original "screen not found" fallback, and + /// is refreshed on appear / screen-parameter change / selected-screen change. + @State private var cachedIsNonNotchScreen: Bool = true + @State private var gestureProgress: CGFloat = .zero @State private var skipGestureActiveDirection: MusicManager.SkipDirection? @State private var isMusicControlWindowVisible = false @@ -384,13 +395,34 @@ struct ContentView: View { } /// Whether the current screen lacks a physical notch. + /// + /// Reads a cached value instead of scanning `NSScreen.screens` on every body + /// evaluation. The cache is recomputed only when its inputs can change: + /// on appear, when the screen configuration changes + /// (`didChangeScreenParametersNotification`), or when the selected screen + /// name changes. Behaviour is identical to the previous inline scan. private var isNonNotchScreen: Bool { + cachedIsNonNotchScreen + } + + /// Performs the actual `NSScreen.screens` scan that backs `isNonNotchScreen`. + /// Kept behaviour-identical to the original computed property (including the + /// `true` fallback when no matching screen is found). + private func computeIsNonNotchScreen() -> Bool { guard let screen = NSScreen.screens.first(where: { $0.localizedName == currentScreenName }) else { return true } return screen.safeAreaInsets.top <= 0 } + /// Refreshes `cachedIsNonNotchScreen` if the scan result has changed. + private func refreshIsNonNotchScreenCache() { + let value = computeIsNonNotchScreen() + if value != cachedIsNonNotchScreen { + cachedIsNonNotchScreen = value + } + } + /// Whether the global sneak peek is visible on this specific screen. private var isSneakPeekVisibleOnCurrentScreen: Bool { guard coordinator.sneakPeek.show else { return false } @@ -741,6 +773,7 @@ struct ContentView: View { private func installPrimaryRootLifecycleHandlers(on view: Content) -> some View { view .onAppear { + refreshIsNonNotchScreenCache() isMusicControlWindowSuppressed = vm.notchState != .closed || lockScreenManager.isLocked || isMusicHUDDeferredAfterUnlock @@ -858,6 +891,12 @@ struct ContentView: View { enqueueMusicControlWindowSync(forceRefresh: true) } } + .onChange(of: currentScreenName) { _, _ in + refreshIsNonNotchScreenCache() + } + .onReceive(NotificationCenter.default.publisher(for: NSApplication.didChangeScreenParametersNotification)) { _ in + refreshIsNonNotchScreenCache() + } .onDisappear { performViewTeardown() } From c1b43986202746f370d1f3da0929a6f9351dc9ea Mon Sep 17 00:00:00 2001 From: mehmetefeaytas Date: Fri, 24 Jul 2026 00:29:21 +0300 Subject: [PATCH 15/20] perf(startup): lazy feature-manager init + defer non-critical launch work Co-Authored-By: Claude Opus 4.8 (1M context) --- DynamicIsland/DynamicIslandApp.swift | 56 ++++++++++++++++++++-------- 1 file changed, 40 insertions(+), 16 deletions(-) diff --git a/DynamicIsland/DynamicIslandApp.swift b/DynamicIsland/DynamicIslandApp.swift index 6de360ca..11e74088 100644 --- a/DynamicIsland/DynamicIslandApp.swift +++ b/DynamicIsland/DynamicIslandApp.swift @@ -108,17 +108,23 @@ class AppDelegate: NSObject, NSApplicationDelegate { @ObservedObject var coordinator = DynamicIslandViewCoordinator.shared var whatsNewWindow: NSWindow? var timer: Timer? - let calendarManager = CalendarManager.shared - let webcamManager = WebcamManager.shared - let dndManager = DoNotDisturbManager.shared // NEW: DND detection - let bluetoothAudioManager = BluetoothAudioManager.shared // NEW: Bluetooth audio detection - let idleAnimationManager = IdleAnimationManager.shared // NEW: Custom idle animations - let downloadManager = DownloadManager.shared // NEW: Chromium downloads detection - let lockScreenPanelManager = LockScreenPanelManager.shared // NEW: Lock screen music panel - let mediaControlsStateCoordinator = MediaControlsStateCoordinator.shared - let systemTimerBridge = SystemTimerBridge.shared - let extensionXPCServiceHost = ExtensionXPCServiceHost.shared - let extensionRPCServer = ExtensionRPCServer.shared + // Feature-dependent managers are lazily initialized so their (sometimes + // observer/poller-heavy) `.shared` init does not run synchronously when the + // AppDelegate object is allocated (before applicationDidFinishLaunching). + // Each is either kept alive by an external owner that is touched during + // launch (ContentView / vm / ReminderLiveActivityManager), gated behind its + // feature flag, or explicitly touched from the deferred launch block below. + lazy var calendarManager = CalendarManager.shared // owned by ReminderLiveActivityManager (touched at launch) + let webcamManager = WebcamManager.shared // eager: consumed by createDynamicIslandWindow during launch; also owned by vm + lazy var dndManager = DoNotDisturbManager.shared // NEW: DND detection (gated by enableDoNotDisturbDetection; owned by ContentView) + lazy var bluetoothAudioManager = BluetoothAudioManager.shared // NEW: Bluetooth audio detection (deferred touch) + lazy var idleAnimationManager = IdleAnimationManager.shared // NEW: Custom idle animations (touched via deferred initializeDefaultAnimations) + lazy var downloadManager = DownloadManager.shared // NEW: Chromium downloads detection (owned by ContentView) + lazy var lockScreenPanelManager = LockScreenPanelManager.shared // NEW: Lock screen music panel (deferred touch) + let mediaControlsStateCoordinator = MediaControlsStateCoordinator.shared // eager: settings-consistency glue with no other owner; cheap init + lazy var systemTimerBridge = SystemTimerBridge.shared // deferred touch (no external owner; init hosts mirrorSystemTimer observer) + let extensionXPCServiceHost = ExtensionXPCServiceHost.shared // eager: start()/stop() lifecycle driven from launch/terminate + let extensionRPCServer = ExtensionRPCServer.shared // eager: start()/stop() lifecycle driven from launch/terminate var closeNotchWorkItem: DispatchWorkItem? private var previousScreens: [NSScreen]? private var onboardingWindowController: NSWindowController? @@ -611,10 +617,8 @@ class AppDelegate: NSObject, NSApplicationDelegate { } .store(in: &cancellables) - // Initialize idle animations (load bundled + built-in face) - idleAnimationManager.initializeDefaultAnimations() - - applySelectedAppIcon() + // Idle-animation loading (disk I/O) and app-icon application (disk I/O) + // are deferred to the post-first-frame block at the end of this method. installTopMenuItemsIfNeeded() Defaults.publisher(.focusMonitoringMode, options: []) @@ -926,8 +930,8 @@ class AppDelegate: NSObject, NSApplicationDelegate { if coordinator.firstLaunch && !AppRuntimeEnvironment.isUITesting { DispatchQueue.main.async { self.showOnboardingWindow() + self.playWelcomeSound() } - playWelcomeSound() } previousScreens = NSScreen.screens @@ -945,6 +949,26 @@ class AppDelegate: NSObject, NSApplicationDelegate { let timerWidgetManager = LockScreenTimerWidgetManager.shared timerWidgetManager.handleLockStateChange(isLocked: LockScreenManager.shared.currentLockStatus) + // Defer non-critical launch work until after the first window/notch is on + // screen. This runs on the next main-runloop turn, once the synchronous + // launch path (window creation above) has completed. + DispatchQueue.main.async { [weak self] in + guard let self else { return } + + // Disk I/O: load bundled idle animations off the critical launch path. + self.idleAnimationManager.initializeDefaultAnimations() + + // Disk I/O: read Defaults + load the selected icon image, then apply it. + applySelectedAppIcon() + + // Instantiate always-on managers whose init only registers + // observers/pollers. They have no other owner touched during launch, + // so they must be materialized here to preserve behavior — just one + // runloop later than before, off the synchronous launch path. + _ = self.bluetoothAudioManager + _ = self.systemTimerBridge + _ = self.lockScreenPanelManager + } } private func installTopMenuItemsIfNeeded() { From 3193e1d9b19073f42432dfacdd404f2fa18cb83d Mon Sep 17 00:00:00 2001 From: mehmetefeaytas Date: Fri, 24 Jul 2026 00:44:17 +0300 Subject: [PATCH 16/20] fix(perf-phase-1): address CodeRabbit review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ThumbnailService: reconcile `cacheKeys` against the NSCache once it exceeds the count limit so the tracking set stays bounded; match the "_" boundary in clearCache(for:) so invalidating /tmp/a no longer evicts /tmp/ab's thumbnails. - BatteryActivityManager: snapshot observers (Array(values)) before invoking, so a callback that adds/removes observers can't trigger a mutation-during-iteration trap. - StatsManager: preserve the network baseline when getifaddrs fails (nil snapshot) instead of collapsing to (0,0), which faked a speed dip and wiped previousNetworkStats. - SystemMediaControllers: drop the volume/mute element caches — they were read/populated from the public API on arbitrary threads while refreshPropertyElements() cleared them on callbackQueue (data race). Recompute on access (cheap probe). HAL listener-leak fix retained. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Shelf/Services/ThumbnailService.swift | 11 ++++++++- .../managers/BatteryActivityManager.swift | 4 +++- DynamicIsland/managers/StatsManager.swift | 7 +++++- .../managers/SystemMediaControllers.swift | 23 ++++--------------- 4 files changed, 24 insertions(+), 21 deletions(-) diff --git a/DynamicIsland/components/Shelf/Services/ThumbnailService.swift b/DynamicIsland/components/Shelf/Services/ThumbnailService.swift index 39efe47c..ff42beef 100644 --- a/DynamicIsland/components/Shelf/Services/ThumbnailService.swift +++ b/DynamicIsland/components/Shelf/Services/ThumbnailService.swift @@ -58,6 +58,12 @@ actor ThumbnailService { if let thumbnail = 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 @@ -73,7 +79,10 @@ actor ThumbnailService { } func clearCache(for url: URL) { - let keysToRemove = cacheKeys.filter { $0.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) diff --git a/DynamicIsland/managers/BatteryActivityManager.swift b/DynamicIsland/managers/BatteryActivityManager.swift index e5618e79..742b7308 100644 --- a/DynamicIsland/managers/BatteryActivityManager.swift +++ b/DynamicIsland/managers/BatteryActivityManager.swift @@ -322,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.values { + // 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 317bde69..50f4a9df 100644 --- a/DynamicIsland/managers/StatsManager.swift +++ b/DynamicIsland/managers/StatsManager.swift @@ -674,7 +674,12 @@ class StatsManager: ObservableObject { // Calculate network speeds (single getifaddrs walk feeds both totals and per-interface metrics) let networkSnapshots = snapshotNetworkInterfaces() - let currentNetworkStats = aggregateNetworkStats(from: networkSnapshots) + // 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) diff --git a/DynamicIsland/managers/SystemMediaControllers.swift b/DynamicIsland/managers/SystemMediaControllers.swift index 22d46047..8145857f 100644 --- a/DynamicIsland/managers/SystemMediaControllers.swift +++ b/DynamicIsland/managers/SystemMediaControllers.swift @@ -70,8 +70,6 @@ final class SystemVolumeController { private var listenersInstalled = false private var volumeElement: AudioObjectPropertyElement? private var muteElement: AudioObjectPropertyElement? - private var cachedVolumeElements: [AudioObjectPropertyElement]? - private var cachedMuteElements: [AudioObjectPropertyElement]? private var volumeListenerRegistrations: [VolumeListenerRegistration] = [] private let silenceThreshold: Float = 0.001 // Treat very low values as mute requests. @@ -375,10 +373,6 @@ final class SystemVolumeController { private func refreshPropertyElements() { volumeElement = resolveElement(selector: kAudioDevicePropertyVolumeScalar, deviceID: currentDeviceID) muteElement = resolveElement(selector: kAudioDevicePropertyMute, deviceID: currentDeviceID) - // Invalidate the probed element-list caches so they are recomputed for - // the (potentially new) current device on next access. - cachedVolumeElements = nil - cachedMuteElements = nil } private func resolveElement(selector: AudioObjectPropertySelector, deviceID: AudioDeviceID) -> AudioObjectPropertyElement? { @@ -482,28 +476,21 @@ 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] { - if let cached = cachedVolumeElements { - return cached - } - let elements = candidateElements.filter { element in + candidateElements.filter { element in var address = makeAddress(selector: kAudioDevicePropertyVolumeScalar, element: element) return propertyExists(deviceID: currentDeviceID, address: &address) } - cachedVolumeElements = elements - return elements } private func muteElements() -> [AudioObjectPropertyElement] { - if let cached = cachedMuteElements { - return cached - } - let elements = candidateElements.filter { element in + candidateElements.filter { element in var address = makeAddress(selector: kAudioDevicePropertyMute, element: element) return propertyExists(deviceID: currentDeviceID, address: &address) } - cachedMuteElements = elements - return elements } } From b473e056fb83d59653041f49154167eed0e0e506 Mon Sep 17 00:00:00 2001 From: mehmetefeaytas Date: Fri, 24 Jul 2026 00:51:11 +0300 Subject: [PATCH 17/20] fix(perf-phase-2): address CodeRabbit review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ClipboardManager: replace `MainActor.assumeIsolated` in the poll tick (which would trap if the timer ever ran off the main thread) with a plain Bool cached from a main-actor subscription to `ActivityGate.$shouldSuspendBackgroundWork`. - DoNotDisturbManager: confine all Assertions dispatch-source lifecycle to `pollingQueue`. start/stop were mutating the `assertions*Source` vars on the main thread while the filesystem-event handlers re-armed them on `pollingQueue` — a data race. (Also carries the phase-1 CodeRabbit fixes via merge: ThumbnailService cacheKeys/boundary, BatteryActivityManager snapshot, StatsManager network baseline, SystemMediaControllers cache revert.) Co-Authored-By: Claude Opus 4.8 (1M context) --- DynamicIsland/managers/ClipboardManager.swift | 16 +++++++++- .../managers/DoNotDisturbManager.swift | 32 ++++++++++++------- 2 files changed, 35 insertions(+), 13 deletions(-) diff --git a/DynamicIsland/managers/ClipboardManager.swift b/DynamicIsland/managers/ClipboardManager.swift index 5fff6041..7c36ac1d 100644 --- a/DynamicIsland/managers/ClipboardManager.swift +++ b/DynamicIsland/managers/ClipboardManager.swift @@ -224,15 +224,27 @@ class ClipboardManager: ObservableObject { // MARK: - Public Methods + private var backgroundWorkSuspended = false + private var gateCancellable: AnyCancellable? + func startMonitoring() { guard !isMonitoring else { return } isMonitoring = true registerTerminationFlush() + // Observe the energy gate on the main actor and cache it, so the timer + // tick reads a plain Bool thread-safely instead of `MainActor.assumeIsolated`, + // which would trap if the timer ever fired off the main thread. The gate's + // published projection is @MainActor, so subscribe inside a main-actor task. + Task { @MainActor [weak self] in + guard let self else { return } + self.gateCancellable = ActivityGate.shared.$shouldSuspendBackgroundWork + .sink { [weak self] suspended in self?.backgroundWorkSuspended = suspended } + } let timer = Timer.scheduledTimer(withTimeInterval: pollInterval, repeats: true) { [weak self] _ in guard let self else { return } // Energy gate: skip ticks while the screen/system is asleep. - if MainActor.assumeIsolated({ ActivityGate.shared.shouldSuspendBackgroundWork }) { return } + if self.backgroundWorkSuspended { return } self.checkClipboard() } timer.tolerance = pollInterval * 0.2 @@ -241,6 +253,8 @@ class ClipboardManager: ObservableObject { func stopMonitoring() { isMonitoring = false + gateCancellable?.cancel() + gateCancellable = nil timer?.invalidate() timer = nil // Flush any pending debounced history write so nothing is lost. diff --git a/DynamicIsland/managers/DoNotDisturbManager.swift b/DynamicIsland/managers/DoNotDisturbManager.swift index 5a0d2f37..e093d19f 100644 --- a/DynamicIsland/managers/DoNotDisturbManager.swift +++ b/DynamicIsland/managers/DoNotDisturbManager.swift @@ -583,27 +583,35 @@ private extension DoNotDisturbManager { } func startAssertionsPolling() { - stopAssertionsPolling() - lastAssertionsModificationDate = nil - - // Watch the Assertions.json file for filesystem events instead of waking every 2s to - // compare its modification date. This yields zero wake-ups while idle and reacts the - // instant Focus state is written. A directory-watch fallback re-arms the file watch if - // the file is currently absent or gets rotated away. - beginWatchingAssertionsFile() - - // Read the current state immediately so an already-active Focus is reflected on start. + // Confine all dispatch-source lifecycle to `pollingQueue`: the source event + // handlers run there and re-arm the watch, so start/stop must run there too — + // otherwise the `assertions*Source` vars race between the main thread and + // `pollingQueue`. Watching the file via filesystem events (instead of a 2s + // mtime poll) yields zero idle wake-ups; a directory-watch fallback re-arms + // the file watch if the file is absent or gets rotated away. pollingQueue.async { [weak self] in - self?.pollAssertionsState() + guard let self else { return } + self.tearDownAssertionSources() + self.lastAssertionsModificationDate = nil + self.beginWatchingAssertionsFile() + // Read current state immediately so an already-active Focus shows on start. + self.pollAssertionsState() } } func stopAssertionsPolling() { + pollingQueue.async { [weak self] in + self?.tearDownAssertionSources() + self?.lastAssertionsModificationDate = nil + } + } + + /// Cancels and clears the assertion filesystem sources. Must run on `pollingQueue`. + private func tearDownAssertionSources() { assertionsFileSource?.cancel() assertionsFileSource = nil assertionsDirSource?.cancel() assertionsDirSource = nil - lastAssertionsModificationDate = nil } /// Arms a filesystem-object watch on `Assertions.json`. If the file doesn't exist yet, falls From 28ad5dfe94b95affd07ce0147938463f56df4b96 Mon Sep 17 00:00:00 2001 From: mehmetefeaytas Date: Fri, 24 Jul 2026 00:54:37 +0300 Subject: [PATCH 18/20] fix(perf-phase-3): address CodeRabbit review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AppIcons.getIcon(bundleID:): use `appURL.path` instead of `.absoluteString`. The getIcon(file:) guard uses fileExists(atPath:), which only accepts POSIX paths, so the "file://…" form made the bundle-ID lookup always return nil. - JSONLUsageParser: replace the per-line `removeSubrange` (which shifted the whole buffer for every newline → quadratic on newline-dense JSONL) with a chunk cursor that emits lines via slices and trims the consumed prefix once per chunk. (Carries the phase-1/2 CodeRabbit fixes via merge — incl. ThumbnailService cacheKeys/boundary and SystemMediaControllers cache revert that #655 also flagged.) Co-Authored-By: Claude Opus 4.8 (1M context) --- DynamicIsland/helpers/AppIcons.swift | 5 ++++- .../managers/LLMUsage/JSONLUsageParser.swift | 13 ++++++++++--- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/DynamicIsland/helpers/AppIcons.swift b/DynamicIsland/helpers/AppIcons.swift index 59bb0f47..0ff57a18 100644 --- a/DynamicIsland/helpers/AppIcons.swift +++ b/DynamicIsland/helpers/AppIcons.swift @@ -47,9 +47,12 @@ struct AppIcons { } func getIcon(bundleID: String) -> NSImage? { + // Use the POSIX path, not absoluteString ("file://…"): getIcon(file:) + // guards on fileExists(atPath:), which only accepts POSIX paths, so + // absoluteString made this lookup always return nil. guard let path = NSWorkspace.shared.urlForApplication( withBundleIdentifier: bundleID - )?.absoluteString + )?.path else { return nil } return getIcon(file: path) diff --git a/DynamicIsland/managers/LLMUsage/JSONLUsageParser.swift b/DynamicIsland/managers/LLMUsage/JSONLUsageParser.swift index 54a123e2..0065bf0a 100644 --- a/DynamicIsland/managers/LLMUsage/JSONLUsageParser.swift +++ b/DynamicIsland/managers/LLMUsage/JSONLUsageParser.swift @@ -98,12 +98,19 @@ struct JSONLUsageParser { while let chunk = try? fh.read(upToCount: chunkSize), !chunk.isEmpty { buffer.append(chunk) - while let newlineIndex = buffer.firstIndex(of: newline) { - let lineData = buffer.subdata(in: buffer.startIndex.. Date: Fri, 24 Jul 2026 01:25:37 +0300 Subject: [PATCH 19/20] perf(startup): load app icon off-main in deferred launch block MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address CodeRabbit review on #656: the deferred launch block used DispatchQueue.main.async, which only pushes work to the next runloop turn — the app-icon image read (NSImage(contentsOf:)) still hit disk on the main thread, moving a potential hitch to just after the first frame instead of off-main. Split the icon load into a pure, off-main-safe loadSelectedAppIconImage() (Defaults lookup + disk read, no UI) and assign applicationIconImage back on the main actor from a Task.detached(.utility), matching the existing ModelPricingManager.loadInitialPricing() pattern. initializeDefaultAnimations() stays on the main actor by design: it only resolves bundled resource URLs (no file-content reads) and writes Defaults keys that have UI observers, so offloading it would risk off-main UI updates. Co-Authored-By: Claude Opus 4.8 (1M context) --- DynamicIsland/DynamicIslandApp.swift | 16 +++++++++++++--- DynamicIsland/helpers/AppIcons.swift | 15 +++++++++++---- 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/DynamicIsland/DynamicIslandApp.swift b/DynamicIsland/DynamicIslandApp.swift index 11e74088..bd788d79 100644 --- a/DynamicIsland/DynamicIslandApp.swift +++ b/DynamicIsland/DynamicIslandApp.swift @@ -955,11 +955,21 @@ class AppDelegate: NSObject, NSApplicationDelegate { DispatchQueue.main.async { [weak self] in guard let self else { return } - // Disk I/O: load bundled idle animations off the critical launch path. + // Idle animations: resolves bundled resource URLs and reconciles the + // Defaults keys that back the animation picker. Kept on the main actor + // because those Defaults writes have UI observers — and it only resolves + // bundle URLs (no file-content reads), so it stays cheap here. self.idleAnimationManager.initializeDefaultAnimations() - // Disk I/O: read Defaults + load the selected icon image, then apply it. - applySelectedAppIcon() + // App icon: the image file read is the one real disk hit on this path, + // so load it off-main (mirrors ModelPricingManager.loadInitialPricing) + // and publish applicationIconImage back on the main actor. + Task.detached(priority: .utility) { + let image = loadSelectedAppIconImage() + await MainActor.run { + if let image { NSApp.applicationIconImage = image } + } + } // Instantiate always-on managers whose init only registers // observers/pollers. They have no other owner touched during launch, diff --git a/DynamicIsland/helpers/AppIcons.swift b/DynamicIsland/helpers/AppIcons.swift index 0ff57a18..171ca893 100644 --- a/DynamicIsland/helpers/AppIcons.swift +++ b/DynamicIsland/helpers/AppIcons.swift @@ -88,17 +88,24 @@ func AppIconAsNSImage(for bundleID: String) -> NSImage? { return nil } -func applySelectedAppIcon() { +/// Loads the selected app-icon image (Defaults lookup + on-disk image read). +/// Safe to call off the main thread — it performs no UI mutation. The +/// `NSImage(contentsOf:)` read is real disk I/O, so the launch path resolves +/// this off-main and only assigns `applicationIconImage` back on the main actor. +func loadSelectedAppIconImage() -> NSImage? { let customIcons = Defaults[.customAppIcons] if let selectedID = Defaults[.selectedAppIconID], let icon = customIcons.first(where: { $0.id.uuidString == selectedID }), let image = NSImage(contentsOf: icon.fileURL) { - NSApp.applicationIconImage = image - return + return image } let fallbackName = Bundle.main.iconFileName ?? "AppIcon" - if let image = NSImage(named: fallbackName) { + return NSImage(named: fallbackName) +} + +func applySelectedAppIcon() { + if let image = loadSelectedAppIconImage() { NSApp.applicationIconImage = image } } From 129c64ada44e19521b0426221691aa8e87d75d87 Mon Sep 17 00:00:00 2001 From: mehmetefeaytas Date: Fri, 24 Jul 2026 01:29:40 +0300 Subject: [PATCH 20/20] perf(icons): invalidate cached app icons when the bundle changes Address CodeRabbit review on #656: appIconPathCache was keyed by file path alone, so once an icon was cached for a path it was returned for the process lifetime. If the app at that path was updated/replaced while Atoll kept running, callers received the stale icon indefinitely. Fold the file's modification date into the cache key. When the bundle changes its mtime changes, the key changes, and a fresh NSWorkspace icon is resolved. stat() is far cheaper than icon(forFile:), so the cache still avoids the expensive lookup on every SwiftUI refresh. Co-Authored-By: Claude Opus 4.8 (1M context) --- DynamicIsland/helpers/AppIcons.swift | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/DynamicIsland/helpers/AppIcons.swift b/DynamicIsland/helpers/AppIcons.swift index 171ca893..cbcc7f21 100644 --- a/DynamicIsland/helpers/AppIcons.swift +++ b/DynamicIsland/helpers/AppIcons.swift @@ -29,11 +29,21 @@ import Defaults private let appIconPathCache = NSCache() private func cachedIcon(forFile path: String) -> NSImage { - if let cached = appIconPathCache.object(forKey: path as NSString) { + // Fold the file's modification date into the cache key. Keying on the path + // alone would return a stale icon forever if the app at that path is updated + // or replaced while this process keeps running (the key never changes, so the + // cache never re-resolves). When the bundle changes, its mtime changes, the + // key changes, and we resolve a fresh icon. stat() is far cheaper than + // icon(forFile:), so the cache still saves the expensive NSWorkspace lookup. + let attrs = try? FileManager.default.attributesOfItem(atPath: path) + let modStamp = (attrs?[.modificationDate] as? Date)?.timeIntervalSinceReferenceDate ?? 0 + let key = "\(path)@\(modStamp)" as NSString + + if let cached = appIconPathCache.object(forKey: key) { return cached } let icon = NSWorkspace.shared.icon(forFile: path) - appIconPathCache.setObject(icon, forKey: path as NSString) + appIconPathCache.setObject(icon, forKey: key) return icon }