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() } diff --git a/DynamicIsland/DynamicIslandApp.swift b/DynamicIsland/DynamicIslandApp.swift index 6de360ca..bd788d79 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,36 @@ 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 } + + // 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() + + // 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, + // 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() { 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..15eeaf95 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 @@ -54,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 @@ -68,7 +91,7 @@ final class NowPlayingController: ObservableObject, MediaControllerProtocol { bundle, "MRMediaRemoteSetShuffleMode" as CFString), let MRMediaRemoteSetRepeatModePointer = CFBundleGetFunctionPointerForName( bundle, "MRMediaRemoteSetRepeatMode" as CFString) - + else { return nil } mediaRemoteBundle = bundle @@ -81,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() @@ -103,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) @@ -134,19 +256,28 @@ 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 - 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"), @@ -160,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() @@ -179,7 +310,7 @@ final class NowPlayingController: ObservableObject, MediaControllerProtocol { else { return } print("NowPlayingController [stderr]: \(message)") } - + self.process = process self.pipeHandler = pipeHandler @@ -189,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 } @@ -243,7 +396,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 +411,10 @@ final class NowPlayingController: ObservableObject, MediaControllerProtocol { payload.bundleIdentifier ?? (diff ? self.playbackState.bundleIdentifier : "") ) - - self.playbackState = newPlaybackState + + await MainActor.run { [weak self] in + self?.playbackState = newPlaybackState + } } } diff --git a/DynamicIsland/MediaControllers/SpotifyController.swift b/DynamicIsland/MediaControllers/SpotifyController.swift index 289a9e14..9d7656cd 100644 --- a/DynamicIsland/MediaControllers/SpotifyController.swift +++ b/DynamicIsland/MediaControllers/SpotifyController.swift @@ -87,7 +87,8 @@ class SpotifyController: MediaControllerProtocol { self?.artistMetadataFetchTask?.cancel() self?.artistMetadataFetchTask = nil self?.currentArtistTrackURI = nil - if let self { + Task { @MainActor [weak self] in + guard let self else { return } var updatedState = self.playbackState updatedState.liveArtworkURL = nil self.playbackState = updatedState @@ -195,7 +196,10 @@ class SpotifyController: MediaControllerProtocol { artistMetadataFetchTask = nil } - playbackState = state + let resolvedState = state + await MainActor.run { [weak self] in + self?.playbackState = resolvedState + } if !trackURI.isEmpty { if cachedArtistResult == nil { diff --git a/DynamicIsland/MediaControllers/YouTube Music Controller/YouTubeMusicController.swift b/DynamicIsland/MediaControllers/YouTube Music Controller/YouTubeMusicController.swift index 34add634..89ec4673 100644 --- a/DynamicIsland/MediaControllers/YouTube Music Controller/YouTubeMusicController.swift +++ b/DynamicIsland/MediaControllers/YouTube Music Controller/YouTubeMusicController.swift @@ -95,7 +95,9 @@ final class YouTubeMusicController: MediaControllerProtocol { func updatePlaybackInfo() async { guard isActive() else { - resetPlaybackState() + await MainActor.run { [weak self] in + self?.resetPlaybackState() + } return } @@ -161,8 +163,10 @@ final class YouTubeMusicController: MediaControllerProtocol { await webSocketClient?.disconnect() webSocketClient = nil } - - resetPlaybackState() + + await MainActor.run { [weak self] in + self?.resetPlaybackState() + } } private func initializeIfAppActive() async { @@ -237,33 +241,49 @@ final class YouTubeMusicController: MediaControllerProtocol { } guard let newPosition = position else { return } - var copied = playbackState - copied.currentTime = newPosition - copied.lastUpdated = Date() - playbackState = copied + await MainActor.run { [weak self] in + guard let self else { return } + var copied = self.playbackState + copied.currentTime = newPosition + copied.lastUpdated = Date() + self.playbackState = copied + } case .repeatChanged: guard let data = message.extractData() else { return } - var copy = playbackState + var newRepeatMode: RepeatMode? = nil if let repeatStr = data["repeat"] as? String { switch repeatStr.uppercased() { - case "NONE": copy.repeatMode = .off - case "ALL": copy.repeatMode = .all - case "ONE": copy.repeatMode = .one + case "NONE": newRepeatMode = .off + case "ALL": newRepeatMode = .all + case "ONE": newRepeatMode = .one default: break } } - copy.lastUpdated = Date() - playbackState = copy + + await MainActor.run { [weak self] in + guard let self else { return } + var copy = self.playbackState + if let newRepeatMode { copy.repeatMode = newRepeatMode } + copy.lastUpdated = Date() + self.playbackState = copy + } case .shuffleChanged: guard let data = message.extractData() else { return } - var copy = playbackState - if let shuffle = data["shuffle"] as? Bool { copy.isShuffled = shuffle } - else if let shuffle = data["isShuffled"] as? Bool { copy.isShuffled = shuffle } - copy.lastUpdated = Date() - playbackState = copy + + var newShuffle: Bool? = nil + if let shuffle = data["shuffle"] as? Bool { newShuffle = shuffle } + else if let shuffle = data["isShuffled"] as? Bool { newShuffle = shuffle } + + await MainActor.run { [weak self] in + guard let self else { return } + var copy = self.playbackState + if let newShuffle { copy.isShuffled = newShuffle } + copy.lastUpdated = Date() + self.playbackState = copy + } case .volumeChanged: break @@ -339,24 +359,40 @@ final class YouTubeMusicController: MediaControllerProtocol { ) // Lightweight endpoint-specific parsing if endpoint == "/shuffle" { - if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let shuffleState = json["state"] as? Bool { - playbackState.isShuffled = shuffleState - } else { - playbackState.isShuffled = !playbackState.isShuffled + var shuffleState: Bool? = nil + if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let state = json["state"] as? Bool { + shuffleState = state + } + await MainActor.run { [weak self] in + guard let self else { return } + if let shuffleState { + self.playbackState.isShuffled = shuffleState + } else { + self.playbackState.isShuffled = !self.playbackState.isShuffled + } } } else if endpoint == "/repeat-mode" { + var mode: String? = nil if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { - if let mode = json["mode"] as? String { updateRepeatMode(mode) } + mode = json["mode"] as? String + } + if let mode { + await MainActor.run { [weak self] in + self?.updateRepeatMode(mode) + } } } else if endpoint == "/switch-repeat" { - // Find next repeat mode - let nextMode: RepeatMode - switch playbackState.repeatMode { - case .off: nextMode = .all - case .all: nextMode = .one - case .one: nextMode = .off + await MainActor.run { [weak self] in + guard let self else { return } + // Find next repeat mode + let nextMode: RepeatMode + switch self.playbackState.repeatMode { + case .off: nextMode = .all + case .all: nextMode = .one + case .one: nextMode = .off + } + self.playbackState.repeatMode = nextMode } - playbackState.repeatMode = nextMode } else if refresh && webSocketClient == nil { try? await Task.sleep(for: .milliseconds(100)) await updatePlaybackInfo() @@ -409,7 +445,10 @@ final class YouTubeMusicController: MediaControllerProtocol { } // Always update - removed comparison since PlaybackState doesn't conform to Equatable - playbackState = newState + let resolvedState = newState + await MainActor.run { [weak self] in + self?.playbackState = resolvedState + } artworkFetchTask?.cancel() artworkFetchTask = nil diff --git a/DynamicIsland/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/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/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.. = { + let cache = NSCache() + cache.countLimit = 200 + cache.totalCostLimit = 64 * 1024 * 1024 // 64 MB + return cache + }() + // NSCache cannot enumerate its keys, so we track them separately to + // support path-based invalidation in clearCache(for:). + private var cacheKeys: Set = [] private var pendingRequests: [String: Task] = [:] private let thumbnailGenerator = QLThumbnailGenerator.shared private init() {} - + func thumbnail(for url: URL, size: CGSize) async -> NSImage? { let cacheKey = "\(url.path)_\(size.width)x\(size.height)" - - if let cached = cache[cacheKey] { + + if let cached = cache.object(forKey: cacheKey as NSString) { return cached } - + if let pending = pendingRequests[cacheKey] { return await pending.value } - + let task = Task { let thumbnail = await generateQuickLookThumbnail(for: url, size: size) if let thumbnail = thumbnail { - cache[cacheKey] = thumbnail + cache.setObject(thumbnail, forKey: cacheKey as NSString, cost: estimatedCost(of: thumbnail)) + cacheKeys.insert(cacheKey) + // NSCache evicts silently, so `cacheKeys` would grow unbounded. + // Reconcile against the cache once it exceeds the count limit, + // dropping keys whose objects are already gone. + if cacheKeys.count > 256 { + cacheKeys = cacheKeys.filter { cache.object(forKey: $0 as NSString) != nil } + } } pendingRequests[cacheKey] = nil return thumbnail } - + pendingRequests[cacheKey] = task return await task.value } - + func clearCache() { - cache.removeAll() + cache.removeAllObjects() + cacheKeys.removeAll() } - + func clearCache(for url: URL) { - cache = cache.filter { !$0.key.starts(with: url.path) } + // Keys are "_x"; match on the "_" boundary so that + // invalidating /tmp/a does not also evict /tmp/ab's thumbnails. + let prefix = "\(url.path)_" + let keysToRemove = cacheKeys.filter { $0.hasPrefix(prefix) } + for key in keysToRemove { + cache.removeObject(forKey: key as NSString) + cacheKeys.remove(key) + } + } + + /// Rough byte estimate (width * height * 4 bytes/pixel) used as the NSCache cost. + private func estimatedCost(of image: NSImage) -> Int { + let size = image.size + return max(1, Int(size.width * size.height * 4)) } // MARK: - Private Methods diff --git a/DynamicIsland/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..cbcc7f21 100644 --- a/DynamicIsland/helpers/AppIcons.swift +++ b/DynamicIsland/helpers/AppIcons.swift @@ -20,21 +20,51 @@ 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 { + // 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: key) + 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? { + // 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) } @@ -50,37 +80,42 @@ 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 } -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 } } 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 + } + } +} diff --git a/DynamicIsland/managers/BatteryActivityManager.swift b/DynamicIsland/managers/BatteryActivityManager.swift index a70d807f..742b7308 100644 --- a/DynamicIsland/managers/BatteryActivityManager.swift +++ b/DynamicIsland/managers/BatteryActivityManager.swift @@ -37,7 +37,8 @@ class BatteryActivityManager { var onTimeToFullChargeChange: ((Int) -> Void)? private var batterySource: CFRunLoopSource? - private var observers: [(BatteryEvent) -> Void] = [] + private var observers: [Int: (BatteryEvent) -> Void] = [:] + private var nextObserverToken: Int = 0 private var previousBatteryInfo: BatteryInfo? private var notificationQueue: [BatteryEvent] = [] private var isProcessingNotifications = false @@ -304,15 +305,16 @@ class BatteryActivityManager { /// - Parameter observer: The observer closure to be called on battery events /// - Returns: The ID of the observer for later removal func addObserver(_ observer: @escaping (BatteryEvent) -> Void) -> Int { - observers.append(observer) - return observers.count - 1 + let token = nextObserverToken + nextObserverToken += 1 + observers[token] = observer + return token } /// Removes an observer by its ID /// - Parameter id: The ID of the observer to be removed func removeObserver(byId id: Int) { - guard id >= 0 && id < observers.count else { return } - observers.remove(at: id) + observers.removeValue(forKey: id) } /// Notifies all observers of a battery event @@ -320,7 +322,9 @@ class BatteryActivityManager { private func notifyObservers(event: BatteryEvent) { DispatchQueue.main.async { [weak self] in guard let self = self else { return } - for observer in self.observers { + // Snapshot before invoking: an observer may add/remove observers + // from inside its callback, which would mutate `observers` mid-iteration. + for observer in Array(self.observers.values) { observer(event) } } diff --git a/DynamicIsland/managers/BluetoothAudioManager.swift b/DynamicIsland/managers/BluetoothAudioManager.swift index c0b56440..72f04028 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,55 @@ 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 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. // Check if Bluetooth is powered on guard IOBluetoothHostController.default()?.powerState == kBluetoothHCIPowerStateON else { // Bluetooth is off - clear connected devices if any diff --git a/DynamicIsland/managers/ClipboardManager.swift b/DynamicIsland/managers/ClipboardManager.swift index d3a11a6a..7c36ac1d 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 { @@ -213,19 +224,54 @@ class ClipboardManager: ObservableObject { // MARK: - Public Methods + private var backgroundWorkSuspended = false + private var gateCancellable: AnyCancellable? + func startMonitoring() { guard !isMonitoring else { return } - + isMonitoring = true - timer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] _ in - self?.checkClipboard() + 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 self.backgroundWorkSuspended { return } + self.checkClipboard() } + timer.tolerance = pollInterval * 0.2 + self.timer = timer } - + func stopMonitoring() { isMonitoring = false + gateCancellable?.cancel() + gateCancellable = nil 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 +563,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/DoNotDisturbManager.swift b/DynamicIsland/managers/DoNotDisturbManager.swift index 1bb8a1b8..e093d19f 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. @@ -582,22 +583,101 @@ private extension DoNotDisturbManager { } func startAssertionsPolling() { - 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 - self?.pollAssertionsState() + // 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 + 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() } - timer.resume() - pollingSource = timer } func stopAssertionsPolling() { - pollingSource?.cancel() - pollingSource = nil - lastAssertionsModificationDate = nil + 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 + } + + /// 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() { diff --git a/DynamicIsland/managers/LLMUsage/JSONLUsageParser.swift b/DynamicIsland/managers/LLMUsage/JSONLUsageParser.swift index 7d6f7664..0065bf0a 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,38 @@ 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) + // Scan the whole chunk advancing a cursor, then trim the consumed + // prefix once. The previous per-line removeSubrange shifted the rest + // of the buffer for every newline → quadratic on newline-dense JSONL. + var searchStart = buffer.startIndex + while let newlineIndex = buffer[searchStart...].firstIndex(of: newline) { + let lineData = buffer[searchStart.. 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 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/StatsManager.swift b/DynamicIsland/managers/StatsManager.swift index 7abe47ec..b6c66b6b 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() { @@ -478,13 +485,10 @@ 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() - - // 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) @@ -554,19 +558,21 @@ 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() - 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,56 +674,46 @@ 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 - let currentNetworkStats = getNetworkStats() + + // Calculate network speeds (single getifaddrs walk feeds both totals and per-interface metrics) + let networkSnapshots = snapshotNetworkInterfaces() + // On a getifaddrs failure (nil snapshot) preserve the prior baseline + // instead of collapsing to (0,0), which would both fake a speed dip and + // wipe previousNetworkStats when it is assigned below. + let currentNetworkStats = networkSnapshots != nil + ? aggregateNetworkStats(from: networkSnapshots) + : previousNetworkStats let currentTime = Date() let timeInterval = currentTime.timeIntervalSince(previousTimestamp) - + 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 @@ -722,58 +724,104 @@ 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(deltaTime: timeInterval) - diskDevices = collectDiskDevices() - refreshProcessStatsIfNeeded(force: true) + networkInterfaces = collectNetworkInterfaces(from: networkSnapshots, deltaTime: timeInterval) + + // 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]) { @@ -941,45 +989,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 @@ -1027,26 +1036,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 +1077,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 @@ -1103,57 +1146,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) @@ -1251,94 +1243,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 - 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) @@ -1542,3 +1446,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) + } +} diff --git a/DynamicIsland/managers/SystemMediaControllers.swift b/DynamicIsland/managers/SystemMediaControllers.swift index bec76792..b7d168ab 100644 --- a/DynamicIsland/managers/SystemMediaControllers.swift +++ b/DynamicIsland/managers/SystemMediaControllers.swift @@ -70,8 +70,15 @@ final class SystemVolumeController { private var listenersInstalled = false private var volumeElement: AudioObjectPropertyElement? private var muteElement: AudioObjectPropertyElement? + private var volumeListenerRegistrations: [VolumeListenerRegistration] = [] private let silenceThreshold: Float = 0.001 // Treat very low values as mute requests. + private struct VolumeListenerRegistration { + let deviceID: AudioDeviceID + var address: AudioObjectPropertyAddress + let block: AudioObjectPropertyListenerBlock + } + private let candidateElements: [AudioObjectPropertyElement] = [ kAudioObjectPropertyElementMain, AudioObjectPropertyElement(1), @@ -214,21 +221,48 @@ final class SystemVolumeController { } private func installVolumeListeners(for deviceID: AudioDeviceID) { + // Remove any previously registered listeners (e.g. from a prior output + // device) before re-installing, otherwise the old HAL listeners leak and + // deliver duplicate notifications on every route change. + removeVolumeListeners() + if let element = resolveElement(selector: kAudioDevicePropertyVolumeScalar, deviceID: deviceID) { volumeElement = element - var address = makeAddress(selector: kAudioDevicePropertyVolumeScalar, element: element) - AudioObjectAddPropertyListenerBlock(deviceID, &address, callbackQueue) { [weak self] _, _ in - self?.notifyCurrentState() - } + addVolumeListener(selector: kAudioDevicePropertyVolumeScalar, element: element, deviceID: deviceID) } if let element = resolveElement(selector: kAudioDevicePropertyMute, deviceID: deviceID) { muteElement = element - var address = makeAddress(selector: kAudioDevicePropertyMute, element: element) - AudioObjectAddPropertyListenerBlock(deviceID, &address, callbackQueue) { [weak self] _, _ in - self?.notifyCurrentState() + addVolumeListener(selector: kAudioDevicePropertyMute, element: element, deviceID: deviceID) + } + } + + private func addVolumeListener(selector: AudioObjectPropertySelector, element: AudioObjectPropertyElement, deviceID: AudioDeviceID) { + var address = makeAddress(selector: selector, element: element) + let block: AudioObjectPropertyListenerBlock = { [weak self] _, _ in + self?.notifyCurrentState() + } + let status = AudioObjectAddPropertyListenerBlock(deviceID, &address, callbackQueue, block) + if status == noErr { + volumeListenerRegistrations.append(VolumeListenerRegistration(deviceID: deviceID, address: address, block: block)) + } else { + NSLog("⚠️ Failed to install volume/mute listener for selector \(selector): \(status)") + } + } + + private func removeVolumeListeners() { + for var registration in volumeListenerRegistrations { + let status = AudioObjectRemovePropertyListenerBlock( + registration.deviceID, + ®istration.address, + callbackQueue, + registration.block + ) + if status != noErr { + NSLog("⚠️ Failed to remove volume/mute listener: \(status)") } } + volumeListenerRegistrations.removeAll() } private func handleDefaultDeviceChanged() { @@ -442,6 +476,9 @@ final class SystemVolumeController { return AudioObjectSetPropertyData(currentDeviceID, &address, 0, nil, size, &data) } + // Computed on each access rather than cached: these are called synchronously + // from the public API on arbitrary threads while `refreshPropertyElements()` + // runs on `callbackQueue`, so a shared cache would race. The probe is cheap. private func volumeElements() -> [AudioObjectPropertyElement] { candidateElements.filter { element in var address = makeAddress(selector: kAudioDevicePropertyVolumeScalar, element: element) @@ -479,7 +516,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 @@ -507,11 +550,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) } } @@ -524,6 +570,7 @@ final class SystemBrightnessController { userInitiatedResetTimer?.invalidate() userInitiatedResetTimer = nil userInitiatedBrightnessChange = false + continuousFallbackPolling = false pendingAdjustTarget = nil } @@ -559,9 +606,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 + } } } @@ -772,11 +833,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 } @@ -786,7 +850,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) @@ -795,6 +859,8 @@ final class SystemBrightnessController { self.lastEmittedBrightness = max(0, min(1, system)) } } + timer.tolerance = interval * 0.2 + pollTimer = timer } deinit { 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 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