diff --git a/.gitignore b/.gitignore
index 96a6a4e..3b22743 100644
--- a/.gitignore
+++ b/.gitignore
@@ -142,6 +142,7 @@ SHEET_PRESENTATION_FIX.md
lock_mode_reference.md
liquid_glass.md
huds.md
-
+borrow_migrations.md
+borrowing/
MoveToSky.xcodeproj
/MoveToSky/
\ No newline at end of file
diff --git a/DynamicIsland.xcodeproj/xcshareddata/xcschemes/DynamicIsland.xcscheme b/DynamicIsland.xcodeproj/xcshareddata/xcschemes/DynamicIsland.xcscheme
index 9b2fc4e..80849ad 100644
--- a/DynamicIsland.xcodeproj/xcshareddata/xcschemes/DynamicIsland.xcscheme
+++ b/DynamicIsland.xcodeproj/xcshareddata/xcschemes/DynamicIsland.xcscheme
@@ -40,11 +40,8 @@
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
-
-
-
+
-
+
3 {
- // Expand height for 4+ graphs
- targetSize = CGSize(
- width: baseSize.width,
- height: baseSize.height + 65 // Add extra height for second row
- )
- } else {
- targetSize = baseSize
- }
- } else {
- targetSize = baseSize
+ guard coordinator.currentView == .stats else {
+ return baseSize
}
- return targetSize
+ let rows = statsRowCount()
+ if rows <= 1 {
+ return baseSize
+ }
+
+ let additionalRows = max(rows - 1, 0)
+ let extraHeight = CGFloat(additionalRows) * statsAdditionalRowHeight
+ return CGSize(width: baseSize.width, height: baseSize.height + extraHeight)
}
@@ -96,6 +81,7 @@ struct ContentView: View {
private let extendedHoverPadding: CGFloat = 30
private let zeroHeightHoverPadding: CGFloat = 10
+ private let statsAdditionalRowHeight: CGFloat = 110
// Use minimalistic corner radius ONLY when opened, keep normal when closed
private var activeCornerRadiusInsets: (opened: (top: CGFloat, bottom: CGFloat), closed: (top: CGFloat, bottom: CGFloat)) {
@@ -266,10 +252,12 @@ struct ContentView: View {
isViewTransitioning = false
// Check if this transition will cause a size change
- let oldSize = oldValue == .stats && statsTabHasExpandedHeight() ?
- CGSize(width: openNotchSize.width, height: openNotchSize.height + 65) : openNotchSize
- let newSize = newValue == .stats && statsTabHasExpandedHeight() ?
- CGSize(width: openNotchSize.width, height: openNotchSize.height + 65) : openNotchSize
+ let baseOpenSize = Defaults[.enableMinimalisticUI] ? minimalisticOpenNotchSize : openNotchSize
+ let expandedHeight = baseOpenSize.height + statsAdditionalRowHeight
+ let oldSize = oldValue == .stats && statsTabHasExpandedHeight() ?
+ CGSize(width: baseOpenSize.width, height: expandedHeight) : baseOpenSize
+ let newSize = newValue == .stats && statsTabHasExpandedHeight() ?
+ CGSize(width: baseOpenSize.width, height: expandedHeight) : baseOpenSize
let sizeWillChange = oldSize != newSize
// Set flags for transition tracking
@@ -694,7 +682,7 @@ struct ContentView: View {
self.isHovering = false
}
- if self.vm.notchState == .open && !self.vm.isBatteryPopoverActive {
+ if self.vm.notchState == .open && !self.hasAnyActivePopovers() {
self.vm.close()
}
}
@@ -780,13 +768,23 @@ struct ContentView: View {
// Helper to check if stats tab has 4+ graphs (needs expanded height)
private func statsTabHasExpandedHeight() -> Bool {
+ return statsRowCount() > 1
+ }
+
+ private func enabledStatsGraphCount() -> Int {
var enabledCount = 0
if showCpuGraph { enabledCount += 1 }
if showMemoryGraph { enabledCount += 1 }
if showGpuGraph { enabledCount += 1 }
if showNetworkGraph { enabledCount += 1 }
if showDiskGraph { enabledCount += 1 }
- return enabledCount > 3
+ return enabledCount
+ }
+
+ private func statsRowCount() -> Int {
+ let count = enabledStatsGraphCount()
+ if count == 0 { return 0 }
+ return count <= 3 ? 1 : 2
}
// MARK: - Gesture Handling
diff --git a/DynamicIsland/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings
index 116b2ab..8cb52c0 100644
--- a/DynamicIsland/Localizable.xcstrings
+++ b/DynamicIsland/Localizable.xcstrings
@@ -205,6 +205,10 @@
"comment" : "A label displaying the duration of the current song. The argument is the duration of the song in seconds.",
"isCommentAutoGenerated" : true
},
+ "—" : {
+ "comment" : "A placeholder text used when GPU usage is not available.",
+ "isCommentAutoGenerated" : true
+ },
"'Now Playing' was the only option on previous versions and works with all media apps." : {
"localizations" : {
"ar" : {
@@ -817,10 +821,6 @@
},
"%lld colors" : {
- },
- "%lld processes" : {
- "comment" : "A label at the bottom of the ranked processes popover showing the total number of processes being monitored.",
- "isCommentAutoGenerated" : true
},
"%lld%%" : {
"localizations" : {
@@ -3053,6 +3053,10 @@
}
}
},
+ "Averages" : {
+ "comment" : "A label displayed above the average usage and last update information for a GPU.",
+ "isCommentAutoGenerated" : true
+ },
"Battery" : {
"localizations" : {
"ar" : {
@@ -3572,6 +3576,10 @@
},
"Bottom Pad:" : {
+ },
+ "Breakdown" : {
+ "comment" : "A label displayed above the breakdown of memory usage.",
+ "isCommentAutoGenerated" : true
},
"Brightness HUD" : {
@@ -4828,6 +4836,10 @@
},
"Copy something to start" : {
+ },
+ "Core %lld" : {
+ "comment" : "A label showing the ID of a CPU core. The argument is the ID of the CPU core.",
+ "isCommentAutoGenerated" : true
},
"CPU Usage" : {
@@ -6795,6 +6807,10 @@
},
"Enable timer live activity" : {
+ },
+ "Engine Utilization" : {
+ "comment" : "A label describing the utilization of the GPU's engine components.",
+ "isCommentAutoGenerated" : true
},
"Enhance your experience with HUDs" : {
"localizations" : {
@@ -7312,6 +7328,10 @@
}
}
},
+ "External" : {
+ "comment" : "A label indicating an external storage device.",
+ "isCommentAutoGenerated" : true
+ },
"Fill Notch" : {
},
@@ -7417,6 +7437,10 @@
},
"Fit to Notch" : {
+ },
+ "Free · %@" : {
+ "comment" : "A label displaying the amount of free storage on a disk device. The argument is the number of free bytes on the disk.",
+ "isCommentAutoGenerated" : true
},
"Gemini API Key" : {
@@ -8749,6 +8773,10 @@
}
}
},
+ "Idle" : {
+ "comment" : "A label displayed alongside the status of a GPU device.",
+ "isCommentAutoGenerated" : true
+ },
"Idle Animation Style" : {
"comment" : "The header label for the section that lets users choose the style of idle animation.",
"isCommentAutoGenerated" : true
@@ -9078,6 +9106,14 @@
}
}
},
+ "IPv4 · %@" : {
+ "comment" : "A label displaying an IP address for a network interface. The text inside the parentheses is the IP address of the interface.",
+ "isCommentAutoGenerated" : true
+ },
+ "IPv6 · %@" : {
+ "comment" : "A label displaying an IPv6 address associated with a network interface. The text inside the parentheses is replaced with the actual IPv6 address.",
+ "isCommentAutoGenerated" : true
+ },
"Keyboard Backlight HUD" : {
},
@@ -9201,6 +9237,10 @@
},
"Live Performance Data" : {
+ },
+ "Load Average" : {
+ "comment" : "A label displayed above the load average values.",
+ "isCommentAutoGenerated" : true
},
"Lock Screen" : {
@@ -11765,6 +11805,10 @@
}
}
},
+ "No active interfaces detected yet." : {
+ "comment" : "A message displayed when there are no active network interfaces to display.",
+ "isCommentAutoGenerated" : true
+ },
"No clipboard history" : {
},
@@ -12198,11 +12242,19 @@
"comment" : "A label displayed when no graphs are enabled in the Stats tab.",
"isCommentAutoGenerated" : true
},
+ "No process data yet. Keep stats open for a moment." : {
+ "comment" : "A message displayed when there is no process data available to display in the CPU stats view. It encourages the user to keep the stats open for a moment to gather more data.",
+ "isCommentAutoGenerated" : true
+ },
"No results" : {
},
"No results found" : {
+ },
+ "No storage devices detected yet." : {
+ "comment" : "A message indicating that no storage devices have been detected yet.",
+ "isCommentAutoGenerated" : true
},
"Non-notch display height" : {
"localizations" : {
@@ -12997,10 +13049,6 @@
},
"Picking Status" : {
- },
- "PID: %d" : {
- "comment" : "A small label displaying the process ID of a particular app. The argument is the process ID of the app.",
- "isCommentAutoGenerated" : true
},
"Pin items from history to save them here" : {
@@ -13128,6 +13176,10 @@
},
"Position" : {
+ },
+ "Pressure" : {
+ "comment" : "A label describing the memory pressure status.",
+ "isCommentAutoGenerated" : true
},
"Preview" : {
@@ -13470,6 +13522,10 @@
}
}
},
+ "Read" : {
+ "comment" : "A heading displayed for the user's disk read speed.",
+ "isCommentAutoGenerated" : true
+ },
"Ready" : {
},
@@ -13488,10 +13544,6 @@
},
"Recording Status" : {
- },
- "Refresh" : {
- "comment" : "A button to refresh the displayed data.",
- "isCommentAutoGenerated" : true
},
"Release name" : {
"localizations" : {
@@ -14298,6 +14350,18 @@
},
"Sensitivity" : {
+ },
+ "Session · ↓ %@ · ↑ %@" : {
+ "comment" : "A row displaying the total downloaded and uploaded bytes for the network session.",
+ "isCommentAutoGenerated" : true,
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Session · ↓ %1$@ · ↑ %2$@"
+ }
+ }
+ }
},
"Set" : {
@@ -16018,6 +16082,18 @@
},
"Supports reasoning mode" : {
+ },
+ "Swap" : {
+ "comment" : "A label for the section of the memory dashboard related to swap usage.",
+ "isCommentAutoGenerated" : true
+ },
+ "Swap is disabled on this system." : {
+ "comment" : "A message indicating that swap memory is disabled on the user's system.",
+ "isCommentAutoGenerated" : true
+ },
+ "System" : {
+ "comment" : "A label displayed above the system, load average, and uptime information.",
+ "isCommentAutoGenerated" : true
},
"System features" : {
"localizations" : {
@@ -16452,8 +16528,8 @@
"Toggle the Dynamic Island open or closed from anywhere." : {
},
- "Top Processes by %@" : {
- "comment" : "A heading that announces the top processes by a specific metric. The text inside the parentheses is replaced with the actual metric name.",
+ "Total · %@" : {
+ "comment" : "A label displaying the total storage capacity of a disk device. The argument is the total storage capacity of the disk device.",
"isCommentAutoGenerated" : true
},
"Transform" : {
@@ -16976,6 +17052,18 @@
}
}
},
+ "Upload" : {
+ "comment" : "A label displayed above the user's upload statistics.",
+ "isCommentAutoGenerated" : true
+ },
+ "Usage" : {
+ "comment" : "A label displayed above the CPU usage statistics.",
+ "isCommentAutoGenerated" : true
+ },
+ "Usage Breakdown" : {
+ "comment" : "A heading for the breakdown of CPU usage.",
+ "isCommentAutoGenerated" : true
+ },
"Use music visualizer spectrogram" : {
"localizations" : {
"ar" : {
@@ -17076,8 +17164,20 @@
}
}
},
+ "Used" : {
+ "comment" : "A label describing the percentage of memory that is currently in use.",
+ "isCommentAutoGenerated" : true
+ },
+ "Used · %@" : {
+ "comment" : "A label showing the used space on a storage device, followed by the size of that space. The argument is the number of bytes used on the device.",
+ "isCommentAutoGenerated" : true
+ },
"Uses event-driven private API for real-time screen recording detection" : {
+ },
+ "Utilization" : {
+ "comment" : "A label displayed alongside the utilization percentage in the GPU detail view.",
+ "isCommentAutoGenerated" : true
},
"Version" : {
"localizations" : {
@@ -17599,6 +17699,10 @@
},
"With %lld graphs enabled, the Dynamic Island will expand horizontally to accommodate all graphs in a single row." : {
+ },
+ "Write" : {
+ "comment" : "A label for the write section of the disk overview.",
+ "isCommentAutoGenerated" : true
},
"You" : {
diff --git a/DynamicIsland/components/Notch/NotchStatsView.swift b/DynamicIsland/components/Notch/NotchStatsView.swift
index b8e172e..9bb3dd7 100644
--- a/DynamicIsland/components/Notch/NotchStatsView.swift
+++ b/DynamicIsland/components/Notch/NotchStatsView.swift
@@ -56,15 +56,21 @@ struct NotchStatsView: View {
@State private var showingCPUPopover = false
@State private var showingMemoryPopover = false
@State private var showingGPUPopover = false
+ @State private var showingNetworkPopover = false
+ @State private var showingDiskPopover = false
@State private var isHoveringCPUPopover = false
@State private var isHoveringMemoryPopover = false
@State private var isHoveringGPUPopover = false
+ @State private var isHoveringNetworkPopover = false
+ @State private var isHoveringDiskPopover = false
@State private var isResizingForStats = false
+ @State private var statsHoverGraceActive = false
+ @State private var statsHoverGraceWorkItem: DispatchWorkItem?
@EnvironmentObject var vm: DynamicIslandViewModel
-
+
var availableGraphs: [GraphData] {
var graphs: [GraphData] = []
-
+
if showCpuGraph {
graphs.append(SingleGraphData(
title: "CPU",
@@ -74,7 +80,7 @@ struct NotchStatsView: View {
icon: "cpu"
))
}
-
+
if showMemoryGraph {
graphs.append(SingleGraphData(
title: "Memory",
@@ -84,7 +90,7 @@ struct NotchStatsView: View {
icon: "memorychip"
))
}
-
+
if showGpuGraph {
graphs.append(SingleGraphData(
title: "GPU",
@@ -94,7 +100,7 @@ struct NotchStatsView: View {
icon: "display"
))
}
-
+
if showNetworkGraph {
graphs.append(DualGraphData(
title: "Network",
@@ -108,7 +114,7 @@ struct NotchStatsView: View {
icon: "network"
))
}
-
+
if showDiskGraph {
graphs.append(DualGraphData(
title: "Disk",
@@ -122,15 +128,15 @@ struct NotchStatsView: View {
icon: "internaldrive"
))
}
-
+
return graphs
}
-
+
// Smart grid layout system for different graph counts
@ViewBuilder
var statsGridLayout: some View {
let graphCount = availableGraphs.count
-
+
if graphCount <= 3 {
// 1-3 graphs: Single row with equal spacing
LazyVGrid(
@@ -163,7 +169,7 @@ struct NotchStatsView: View {
graphViewForIndex(index)
}
}
-
+
// Second row: 2 graphs (half-width each)
LazyVGrid(
columns: Array(repeating: GridItem(.flexible(), spacing: 8), count: 2),
@@ -176,13 +182,12 @@ struct NotchStatsView: View {
}
}
}
-
+
@ViewBuilder
private func graphViewForIndex(_ index: Int) -> some View {
let graphData = availableGraphs[index]
-
- // Only make CPU, Memory, GPU clickable for process popovers
- if graphData.title == "CPU" || graphData.title == "Memory" || graphData.title == "GPU" {
+
+ if let rankingType = rankingTypeForGraph(graphData) {
Button(action: {
handleGraphClick(for: graphData)
}) {
@@ -191,7 +196,7 @@ struct NotchStatsView: View {
.buttonStyle(PlainButtonStyle())
.popover(isPresented: bindingForGraph(graphData)) {
RankedProcessPopover(
- rankingType: rankingTypeForGraph(graphData),
+ rankingType: rankingType,
onHoverChange: { hovering in
switch graphData.title {
case "CPU":
@@ -200,13 +205,16 @@ struct NotchStatsView: View {
isHoveringMemoryPopover = hovering
case "GPU":
isHoveringGPUPopover = hovering
+ case "Network":
+ isHoveringNetworkPopover = hovering
+ case "Disk":
+ isHoveringDiskPopover = hovering
default:
break
}
}
)
.onDisappear {
- // Reset hover states when popover disappears
switch graphData.title {
case "CPU":
isHoveringCPUPopover = false
@@ -214,10 +222,13 @@ struct NotchStatsView: View {
isHoveringMemoryPopover = false
case "GPU":
isHoveringGPUPopover = false
+ case "Network":
+ isHoveringNetworkPopover = false
+ case "Disk":
+ isHoveringDiskPopover = false
default:
break
}
- // Ensure popover state is updated when popover disappears
DispatchQueue.main.async {
updateStatsPopoverState()
}
@@ -228,7 +239,6 @@ struct NotchStatsView: View {
removal: .scale.combined(with: .opacity).animation(.easeInOut(duration: 0.4))
))
} else {
- // Network and Disk graphs are not clickable
UnifiedStatsCard(graphData: graphData)
.transition(.asymmetric(
insertion: .scale.combined(with: .opacity).animation(.easeInOut(duration: 0.4)),
@@ -236,7 +246,7 @@ struct NotchStatsView: View {
))
}
}
-
+
private func handleGraphClick(for graphData: GraphData) {
switch graphData.title {
case "CPU":
@@ -245,11 +255,15 @@ struct NotchStatsView: View {
showingMemoryPopover = true
case "GPU":
showingGPUPopover = true
+ case "Network":
+ showingNetworkPopover = true
+ case "Disk":
+ showingDiskPopover = true
default:
break
}
}
-
+
private func bindingForGraph(_ graphData: GraphData) -> Binding {
switch graphData.title {
case "CPU":
@@ -258,12 +272,16 @@ struct NotchStatsView: View {
return $showingMemoryPopover
case "GPU":
return $showingGPUPopover
+ case "Network":
+ return $showingNetworkPopover
+ case "Disk":
+ return $showingDiskPopover
default:
return .constant(false)
}
}
-
- private func rankingTypeForGraph(_ graphData: GraphData) -> ProcessRankingType {
+
+ private func rankingTypeForGraph(_ graphData: GraphData) -> ProcessRankingType? {
switch graphData.title {
case "CPU":
return .cpu
@@ -271,8 +289,12 @@ struct NotchStatsView: View {
return .memory
case "GPU":
return .gpu
+ case "Network":
+ return .network
+ case "Disk":
+ return .disk
default:
- return .cpu
+ return nil
}
}
@@ -323,7 +345,6 @@ struct NotchStatsView: View {
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding()
} else {
- // Stats content with smart grid layout
VStack(spacing: 8) {
statsGridLayout
}
@@ -345,24 +366,61 @@ struct NotchStatsView: View {
}
}
.onDisappear {
+ statsHoverGraceWorkItem?.cancel()
+ statsHoverGraceWorkItem = nil
+ if statsHoverGraceActive {
+ statsHoverGraceActive = false
+ }
// Keep monitoring running when tab is not visible
+ updateStatsPopoverState()
}
.animation(.easeInOut(duration: 0.4), value: enableStatsFeature)
.animation(.easeInOut(duration: 0.4), value: availableGraphs.count)
- .onChange(of: availableGraphs.count) { _, newCount in
+ .onChange(of: availableGraphs.count) { _, _ in
// Protect against hover interference during dynamic sizing
isResizingForStats = true
DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) {
isResizingForStats = false
}
}
- .onChange(of: showingCPUPopover) { _, _ in
+ .onChange(of: showingCPUPopover) { _, newValue in
+ if newValue {
+ activateStatsHoverGrace()
+ } else {
+ deactivateStatsHoverGrace()
+ }
updateStatsPopoverState()
}
- .onChange(of: showingMemoryPopover) { _, _ in
+ .onChange(of: showingMemoryPopover) { _, newValue in
+ if newValue {
+ activateStatsHoverGrace()
+ } else {
+ deactivateStatsHoverGrace()
+ }
updateStatsPopoverState()
}
- .onChange(of: showingGPUPopover) { _, _ in
+ .onChange(of: showingGPUPopover) { _, newValue in
+ if newValue {
+ activateStatsHoverGrace()
+ } else {
+ deactivateStatsHoverGrace()
+ }
+ updateStatsPopoverState()
+ }
+ .onChange(of: showingNetworkPopover) { _, newValue in
+ if newValue {
+ activateStatsHoverGrace()
+ } else {
+ deactivateStatsHoverGrace()
+ }
+ updateStatsPopoverState()
+ }
+ .onChange(of: showingDiskPopover) { _, newValue in
+ if newValue {
+ activateStatsHoverGrace()
+ } else {
+ deactivateStatsHoverGrace()
+ }
updateStatsPopoverState()
}
.onChange(of: isHoveringCPUPopover) { _, _ in
@@ -374,23 +432,48 @@ struct NotchStatsView: View {
.onChange(of: isHoveringGPUPopover) { _, _ in
updateStatsPopoverState()
}
+ .onChange(of: isHoveringNetworkPopover) { _, _ in
+ updateStatsPopoverState()
+ }
+ .onChange(of: isHoveringDiskPopover) { _, _ in
+ updateStatsPopoverState()
+ }
+ }
+
+ private func activateStatsHoverGrace(duration: TimeInterval = 0.45) {
+ statsHoverGraceWorkItem?.cancel()
+ statsHoverGraceActive = true
+ let workItem = DispatchWorkItem {
+ statsHoverGraceActive = false
+ statsHoverGraceWorkItem = nil
+ updateStatsPopoverState()
+ }
+ statsHoverGraceWorkItem = workItem
+ DispatchQueue.main.asyncAfter(deadline: .now() + duration, execute: workItem)
+ }
+
+ private func deactivateStatsHoverGrace() {
+ statsHoverGraceWorkItem?.cancel()
+ statsHoverGraceWorkItem = nil
+ if statsHoverGraceActive {
+ statsHoverGraceActive = false
+ }
}
private func updateStatsPopoverState() {
- // Use the same logic as battery popover: active only when shown AND hovered
- // Also consider resize protection to prevent closing during layout changes
- let newState = (showingCPUPopover && isHoveringCPUPopover) ||
- (showingMemoryPopover && isHoveringMemoryPopover) ||
- (showingGPUPopover && isHoveringGPUPopover) ||
- isResizingForStats
+ let anyPopoverOpen = showingCPUPopover || showingMemoryPopover || showingGPUPopover || showingNetworkPopover || showingDiskPopover
+ let newState = anyPopoverOpen || isResizingForStats || statsHoverGraceActive
if vm.isStatsPopoverActive != newState {
vm.isStatsPopoverActive = newState
#if DEBUG
print("📊 Stats popover state updated: \(newState)")
- print(" CPU: shown=\(showingCPUPopover), hovering=\(isHoveringCPUPopover)")
- print(" Memory: shown=\(showingMemoryPopover), hovering=\(isHoveringMemoryPopover)")
- print(" GPU: shown=\(showingGPUPopover), hovering=\(isHoveringGPUPopover)")
+ print(" CPU open=\(showingCPUPopover)")
+ print(" Memory open=\(showingMemoryPopover)")
+ print(" GPU open=\(showingGPUPopover)")
+ print(" Network open=\(showingNetworkPopover)")
+ print(" Disk open=\(showingDiskPopover)")
print(" Resizing: \(isResizingForStats)")
+ print(" Hover grace: \(statsHoverGraceActive)")
#endif
}
}
@@ -460,8 +543,8 @@ struct UnifiedStatsCard: View {
}
.frame(height: 36) // Match boring.notch exactly - reduced from 50
- // Click hint - only for CPU, Memory, GPU
- if graphData.title == "CPU" || graphData.title == "Memory" || graphData.title == "GPU" {
+ // Click hint - shown for graphs that open popovers
+ if ["CPU", "Memory", "GPU", "Network", "Disk"].contains(graphData.title) {
Text("Click for details")
.font(.caption2)
.foregroundColor(.secondary)
diff --git a/DynamicIsland/components/Stats/CPUStatsDetailView.swift b/DynamicIsland/components/Stats/CPUStatsDetailView.swift
new file mode 100644
index 0000000..f40bd86
--- /dev/null
+++ b/DynamicIsland/components/Stats/CPUStatsDetailView.swift
@@ -0,0 +1,57 @@
+//
+// CPUStatsDetailView.swift
+// DynamicIsland
+//
+// SwiftUI port of the stats app CPU popup layout.
+// Mirrors dashboard, history, detail rows, and top processes.
+//
+// Created by GitHub Copilot on 18/10/2025.
+//
+
+import SwiftUI
+
+struct CPUStatsDetailView: View {
+ @ObservedObject private var statsManager = StatsManager.shared
+ @State private var topProcesses: [ProcessStats] = []
+
+ private let systemColor = Color(red: 0.94, green: 0.32, blue: 0.28)
+ private let userColor = Color(red: 0.27, green: 0.52, blue: 0.97)
+ private let idleColor = Color.gray.opacity(0.28)
+
+ var body: some View {
+ ScrollView {
+ VStack(spacing: 16) {
+ StatsCard(title: "CPU Overview") {
+ CPUUsageDashboard(
+ breakdown: statsManager.cpuBreakdown,
+ loadAverage: statsManager.cpuLoadAverage,
+ uptime: statsManager.cpuUptime,
+ coreCount: statsManager.cpuCoreUsage.count,
+ systemColor: systemColor,
+ userColor: userColor,
+ idleColor: idleColor,
+ temperature: statsManager.cpuTemperature,
+ frequency: statsManager.cpuFrequency
+ )
+ }
+
+ StatsCard(title: "Top Processes") {
+ CPUProcessList(processes: topProcesses, accentColor: userColor)
+ }
+
+ if !statsManager.cpuCoreUsage.isEmpty {
+ StatsCard(title: "Per-Core Usage") {
+ CPUCoreUsageGrid(cores: statsManager.cpuCoreUsage, accentColor: userColor)
+ }
+ }
+ }
+ .padding(16)
+ }
+ .onReceive(statsManager.$topCPUProcesses) { processes in
+ topProcesses = Array(processes.prefix(8))
+ }
+ .onAppear {
+ topProcesses = Array(statsManager.topCPUProcesses.prefix(8))
+ }
+ }
+}
diff --git a/DynamicIsland/components/Stats/CPUStatsSharedViews.swift b/DynamicIsland/components/Stats/CPUStatsSharedViews.swift
new file mode 100644
index 0000000..de57954
--- /dev/null
+++ b/DynamicIsland/components/Stats/CPUStatsSharedViews.swift
@@ -0,0 +1,545 @@
+//
+// CPUStatsSharedViews.swift
+// DynamicIsland
+//
+// Shared CPU stats UI components reused across the notch stats tab
+// and the legacy stats panel.
+//
+// Created by GitHub Copilot on 19/10/2025.
+//
+
+import SwiftUI
+
+struct StatsCard: View {
+ let title: String
+ let padding: CGFloat
+ let background: Color
+ let cornerRadius: CGFloat
+ let content: Content
+
+ init(
+ title: String,
+ padding: CGFloat = 16,
+ background: Color = Color(nsColor: .windowBackgroundColor).opacity(0.65),
+ cornerRadius: CGFloat = 12,
+ @ViewBuilder content: () -> Content
+ ) {
+ self.title = title
+ self.padding = padding
+ self.background = background
+ self.cornerRadius = cornerRadius
+ self.content = content()
+ }
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 12) {
+ Text(title)
+ .font(.headline)
+ .foregroundStyle(.primary)
+
+ content
+ }
+ .padding(padding)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .background(
+ RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
+ .fill(background)
+ )
+ .overlay(
+ RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
+ .stroke(Color.white.opacity(0.05), lineWidth: 0.5)
+ )
+ }
+}
+
+struct CPUUsageDashboard: View {
+ let breakdown: CPULoadBreakdown
+ let loadAverage: LoadAverage
+ let uptime: TimeInterval
+ let coreCount: Int
+ let systemColor: Color
+ let userColor: Color
+ let idleColor: Color
+ let temperature: CPUTemperatureMetrics
+ let frequency: CPUFrequencyMetrics?
+
+ var body: some View {
+ let usageSection = VStack(alignment: .leading, spacing: 12) {
+ Text("Usage")
+ .font(.caption)
+ .foregroundColor(.secondary)
+ DetailRow(color: systemColor.opacity(0.95), label: "System", value: StatsFormatting.percentage(breakdown.system))
+ DetailRow(color: userColor.opacity(0.95), label: "User", value: StatsFormatting.percentage(breakdown.user))
+ DetailRow(color: idleColor.opacity(0.7), label: "Idle", value: StatsFormatting.percentage(breakdown.idle))
+ DetailRow(color: nil, label: "Active", value: StatsFormatting.percentage(breakdown.activeUsage))
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+
+ let systemSection = VStack(alignment: .leading, spacing: 10) {
+ Text("System")
+ .font(.caption)
+ .foregroundColor(.secondary)
+ DetailRow(color: nil, label: "Logical cores", value: "\(max(coreCount, 1))")
+ DetailRow(color: nil, label: "Uptime", value: StatsFormatting.abbreviatedDuration(uptime))
+ Divider().padding(.vertical, 4)
+ DetailRow(color: nil, label: "Load 1m", value: String(format: "%.2f", loadAverage.oneMinute))
+ DetailRow(color: nil, label: "Load 5m", value: String(format: "%.2f", loadAverage.fiveMinutes))
+ DetailRow(color: nil, label: "Load 15m", value: String(format: "%.2f", loadAverage.fifteenMinutes))
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+
+ let primaryGauges = CPUSensorGaugeGroup(
+ breakdown: breakdown,
+ temperature: temperature,
+ frequency: frequency,
+ systemColor: systemColor,
+ userColor: userColor,
+ idleColor: idleColor,
+ usageTint: userColor,
+ temperatureTint: Color(red: 0.98, green: 0.53, blue: 0.18)
+ )
+ .frame(maxWidth: .infinity, alignment: .center)
+
+ return ViewThatFits(in: .horizontal) {
+ VStack(alignment: .leading, spacing: 20) {
+ primaryGauges
+ usageSection
+ Divider().padding(.vertical, 4)
+ systemSection
+ }
+ HStack(alignment: .top, spacing: 28) {
+ primaryGauges
+ .frame(maxWidth: .infinity, alignment: .center)
+ usageSection
+ .frame(maxWidth: .infinity, alignment: .leading)
+ systemSection
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+ }
+ }
+}
+
+private struct CPUSensorGaugeGroup: View {
+ let breakdown: CPULoadBreakdown
+ let temperature: CPUTemperatureMetrics
+ let frequency: CPUFrequencyMetrics?
+ let systemColor: Color
+ let userColor: Color
+ let idleColor: Color
+ let usageTint: Color
+ let temperatureTint: Color
+
+ var body: some View {
+ HStack(alignment: .center, spacing: 18) {
+ CPUTemperatureGauge(temperature: temperature.celsius, tint: temperatureTint, size: 78, lineWidth: 8)
+ .frame(maxWidth: .infinity)
+ CPUSegmentDonut(
+ breakdown: breakdown,
+ systemColor: systemColor,
+ userColor: userColor,
+ idleColor: idleColor,
+ diameter: 118,
+ lineWidth: 13
+ )
+ .frame(width: 118, height: 118)
+ .frame(maxWidth: .infinity)
+ CPUFrequencyGauge(frequency: frequency, tint: usageTint, size: 78, lineWidth: 8)
+ .frame(maxWidth: .infinity)
+ }
+ .frame(maxWidth: .infinity, alignment: .center)
+ }
+}
+
+private struct CPUTemperatureGauge: View {
+ let temperature: Double?
+ let tint: Color
+ var size: CGFloat = 84
+ var lineWidth: CGFloat = 9
+ private let maxTemperature: Double = 110
+
+ var body: some View {
+ CircularGaugeView(
+ title: "Temperature",
+ value: normalizedValue,
+ tint: tint,
+ centerPrimaryText: centerPrimary,
+ subtitle: nil,
+ size: size,
+ lineWidth: lineWidth
+ )
+ }
+
+ private var normalizedValue: Double {
+ guard let temperature, maxTemperature > 0 else { return 0 }
+ return min(max(temperature / maxTemperature, 0), 1)
+ }
+
+ private var centerPrimary: String {
+ guard let temperature else { return "—" }
+ return String(format: "%.0f°", temperature)
+ }
+}
+
+private struct CPUFrequencyGauge: View {
+ let frequency: CPUFrequencyMetrics?
+ let tint: Color
+ var size: CGFloat = 84
+ var lineWidth: CGFloat = 9
+
+ var body: some View {
+ CircularGaugeView(
+ title: "Frequency",
+ value: normalizedValue,
+ tint: tint,
+ centerPrimaryText: centerPrimary,
+ centerSecondaryText: frequency != nil ? "GHz" : nil,
+ subtitle: subtitle,
+ size: size,
+ lineWidth: lineWidth
+ )
+ }
+
+ private var normalizedValue: Double {
+ guard let frequency else { return 0 }
+ let candidateMax = frequency.maxOverallGHz > 0 ? frequency.maxOverallGHz : frequency.overallGHz
+ let denominator = max(candidateMax, 1.0)
+ return min(max(frequency.overallGHz / denominator, 0), 1)
+ }
+
+ private var centerPrimary: String {
+ guard let frequency else { return "—" }
+ return String(format: "%.1f", frequency.overallGHz)
+ }
+
+ private var subtitle: String? {
+ guard let frequency else { return nil }
+ var components: [String] = []
+ if let eValue = frequency.eCoreGHz {
+ components.append("E \(String(format: "%.1f", eValue))")
+ }
+ if let pValue = frequency.pCoreGHz {
+ components.append("P \(String(format: "%.1f", pValue))")
+ }
+ return components.isEmpty ? nil : components.joined(separator: " • ")
+ }
+}
+
+private struct CPUSegmentDonut: View {
+ let breakdown: CPULoadBreakdown
+ let systemColor: Color
+ let userColor: Color
+ let idleColor: Color
+ var diameter: CGFloat = 132
+ var lineWidth: CGFloat = 14
+
+ var body: some View {
+ ZStack {
+ Circle()
+ .stroke(idleColor.opacity(0.3), lineWidth: lineWidth)
+ .frame(width: diameter, height: diameter)
+ CPUUsageRing(
+ breakdown: breakdown,
+ systemColor: systemColor,
+ userColor: userColor,
+ lineWidth: lineWidth
+ )
+ .frame(width: diameter, height: diameter)
+ VStack(spacing: 4) {
+ Text(StatsFormatting.percentage(breakdown.activeUsage))
+ .font(.system(size: 30, weight: .bold, design: .rounded))
+ Text("Active")
+ .font(.footnote)
+ .foregroundColor(.secondary)
+ }
+ }
+ }
+}
+
+struct CPUUsageRing: View {
+ let breakdown: CPULoadBreakdown
+ let systemColor: Color
+ let userColor: Color
+ var lineWidth: CGFloat = 14
+
+ var body: some View {
+ let segments = breakdown.normalizedSegments
+ ZStack {
+ if segments.system > 0 {
+ RingArc(start: 0, end: segments.system, color: systemColor, lineWidth: lineWidth)
+ }
+ if segments.user > 0 {
+ RingArc(start: segments.system, end: segments.system + segments.user, color: userColor, lineWidth: lineWidth)
+ }
+ }
+ }
+}
+
+struct RingArc: View {
+ let start: Double
+ let end: Double
+ let color: Color
+ var lineWidth: CGFloat = 14
+
+ var body: some View {
+ Circle()
+ .trim(from: CGFloat(start), to: CGFloat(end))
+ .stroke(color, style: StrokeStyle(lineWidth: lineWidth, lineCap: .round))
+ .rotationEffect(.degrees(-90))
+ }
+}
+
+struct LegendRow: View {
+ let color: Color
+ let title: String
+ let value: String
+
+ var body: some View {
+ HStack(spacing: 8) {
+ Circle()
+ .fill(color)
+ .frame(width: 10, height: 10)
+ Text(title)
+ .font(.system(size: 12, weight: .medium))
+ .foregroundColor(.secondary)
+ Spacer()
+ Text(value)
+ .font(.system(size: 13, weight: .semibold, design: .monospaced))
+ .foregroundColor(.primary)
+ }
+ }
+}
+
+struct LoadAverageRow: View {
+ let loadAverage: LoadAverage
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 4) {
+ Text("Load Average")
+ .font(.caption)
+ .foregroundColor(.secondary)
+ HStack(spacing: 12) {
+ LoadAverageValue(label: "1m", value: loadAverage.oneMinute)
+ LoadAverageValue(label: "5m", value: loadAverage.fiveMinutes)
+ LoadAverageValue(label: "15m", value: loadAverage.fifteenMinutes)
+ }
+ }
+ }
+}
+
+struct LoadAverageValue: View {
+ let label: String
+ let value: Double
+
+ var body: some View {
+ VStack(spacing: 2) {
+ Text(label)
+ .font(.caption2)
+ .foregroundColor(.secondary)
+ Text(String(format: "%.2f", value))
+ .font(.system(size: 13, weight: .semibold, design: .monospaced))
+ .foregroundColor(.primary)
+ }
+ }
+}
+
+struct DetailRow: View {
+ let color: Color?
+ let label: String
+ let value: String
+
+ var body: some View {
+ HStack {
+ if let color {
+ Circle()
+ .fill(color)
+ .frame(width: 8, height: 8)
+ }
+ Text(label)
+ .font(.system(size: 12, weight: .medium))
+ .foregroundColor(.secondary)
+ .lineLimit(1)
+ .layoutPriority(0.5)
+ Spacer()
+ Text(value)
+ .font(.system(size: 13, weight: .semibold, design: .monospaced))
+ .foregroundColor(.primary)
+ .lineLimit(1)
+ .minimumScaleFactor(0.85)
+ .layoutPriority(1)
+ }
+ }
+}
+
+struct CPUCoreUsageGrid: View {
+ let cores: [CPUCoreUsage]
+ let accentColor: Color
+
+ private var columns: [GridItem] {
+ [GridItem(.adaptive(minimum: 90), spacing: 14, alignment: .top)]
+ }
+
+ var body: some View {
+ LazyVGrid(columns: columns, spacing: 14) {
+ ForEach(cores) { core in
+ CPUCoreUsageCell(core: core, accentColor: accentColor)
+ }
+ }
+ }
+}
+
+private struct CPUCoreUsageCell: View {
+ let core: CPUCoreUsage
+ let accentColor: Color
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 8) {
+ HStack {
+ Text("Core \(core.id + 1)")
+ .font(.caption)
+ .foregroundColor(.secondary)
+ Spacer()
+ Text(StatsFormatting.percentage(core.usage))
+ .font(.system(size: 12, weight: .semibold, design: .monospaced))
+ .foregroundColor(accentColor)
+ }
+ GeometryReader { proxy in
+ let width = proxy.size.width
+ let progress = max(0, min(core.usage / 100, 1))
+ ZStack(alignment: .leading) {
+ Capsule()
+ .fill(Color.primary.opacity(0.08))
+ Capsule()
+ .fill(accentColor)
+ .frame(width: width * progress)
+ }
+ }
+ .frame(height: 8)
+ }
+ .padding(10)
+ .background(
+ RoundedRectangle(cornerRadius: 10, style: .continuous)
+ .fill(Color(nsColor: .controlBackgroundColor).opacity(0.6))
+ )
+ }
+}
+
+struct CPUDetailsGrid: View {
+ let breakdown: CPULoadBreakdown
+ let loadAverage: LoadAverage
+ let uptime: TimeInterval
+ let coreCount: Int
+ let systemColor: Color
+ let userColor: Color
+ var showLoadAverage: Bool = true
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 14) {
+ VStack(alignment: .leading, spacing: 8) {
+ Text("Usage Breakdown")
+ .font(.caption)
+ .foregroundColor(.secondary)
+ DetailRow(color: nil, label: "Total", value: StatsFormatting.percentage(breakdown.activeUsage))
+ DetailRow(color: systemColor, label: "System", value: StatsFormatting.percentage(breakdown.system))
+ DetailRow(color: userColor, label: "User", value: StatsFormatting.percentage(breakdown.user))
+ DetailRow(color: Color.gray.opacity(0.4), label: "Idle", value: StatsFormatting.percentage(breakdown.idle))
+ }
+
+ Divider().padding(.vertical, 4)
+
+ VStack(alignment: .leading, spacing: 8) {
+ Text("System")
+ .font(.caption)
+ .foregroundColor(.secondary)
+ DetailRow(color: nil, label: "Logical cores", value: "\(max(coreCount, 1))")
+ DetailRow(color: nil, label: "Uptime", value: StatsFormatting.abbreviatedDuration(uptime))
+ }
+
+ if showLoadAverage {
+ Divider().padding(.vertical, 4)
+ LoadAverageRow(loadAverage: loadAverage)
+ }
+ }
+ }
+}
+
+
+struct CPUProcessList: View {
+ let processes: [ProcessStats]
+ let accentColor: Color
+ var displayLimit: Int? = nil
+
+ private var visibleProcesses: [ProcessStats] {
+ if let limit = displayLimit {
+ return Array(processes.prefix(limit))
+ }
+ return processes
+ }
+
+ var body: some View {
+ VStack(spacing: 8) {
+ if visibleProcesses.isEmpty {
+ Text("No process data yet. Keep stats open for a moment.")
+ .font(.footnote)
+ .foregroundColor(.secondary)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ } else {
+ ForEach(visibleProcesses) { process in
+ CPUProcessRow(process: process, accentColor: accentColor)
+ if process.id != visibleProcesses.last?.id {
+ Divider().padding(.leading, 32)
+ }
+ }
+ }
+ }
+ }
+}
+
+struct CPUProcessRow: View {
+ let process: ProcessStats
+ let accentColor: Color
+
+ var body: some View {
+ HStack(spacing: 12) {
+ iconView
+ .frame(width: 24, height: 24)
+ .clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous))
+ .overlay(
+ RoundedRectangle(cornerRadius: 6, style: .continuous)
+ .stroke(Color.white.opacity(0.08), lineWidth: 0.5)
+ )
+
+ VStack(alignment: .leading, spacing: 2) {
+ Text(process.name)
+ .font(.system(size: 13, weight: .semibold))
+ .lineLimit(1)
+ Text(process.memoryUsageString)
+ .font(.caption2)
+ .foregroundColor(.secondary)
+ .lineLimit(1)
+ }
+
+ Spacer()
+
+ Text(process.cpuUsageString)
+ .font(.system(size: 13, weight: .semibold, design: .monospaced))
+ .foregroundColor(accentColor)
+ }
+ }
+
+ private var iconView: some View {
+ Group {
+ if let icon = process.icon {
+ Image(nsImage: icon)
+ .resizable()
+ .scaledToFit()
+ } else {
+ ZStack {
+ accentColor.opacity(0.15)
+ Image(systemName: "app")
+ .font(.system(size: 12, weight: .medium))
+ .foregroundColor(accentColor)
+ }
+ }
+ }
+ }
+}
diff --git a/DynamicIsland/components/Stats/CircularGaugeView.swift b/DynamicIsland/components/Stats/CircularGaugeView.swift
new file mode 100644
index 0000000..758d524
--- /dev/null
+++ b/DynamicIsland/components/Stats/CircularGaugeView.swift
@@ -0,0 +1,48 @@
+import SwiftUI
+
+struct CircularGaugeView: View {
+ let title: String
+ let value: Double
+ let tint: Color
+ let centerPrimaryText: String
+ var centerSecondaryText: String? = nil
+ var subtitle: String? = nil
+ var size: CGFloat = 72
+ var lineWidth: CGFloat = 8
+ var backgroundTint: Color = Color.primary.opacity(0.08)
+
+ var body: some View {
+ VStack(spacing: 8) {
+ ZStack {
+ Circle()
+ .stroke(backgroundTint, lineWidth: lineWidth)
+ .frame(width: size, height: size)
+ if value > 0 {
+ RingArc(start: 0, end: min(max(value, 0), 1), color: tint, lineWidth: lineWidth)
+ .frame(width: size, height: size)
+ }
+ VStack(spacing: 2) {
+ Text(centerPrimaryText)
+ .font(.system(size: 16, weight: .semibold, design: .rounded))
+ .foregroundColor(tint)
+ if let centerSecondaryText, !centerSecondaryText.isEmpty {
+ Text(centerSecondaryText)
+ .font(.system(size: 11, weight: .medium))
+ .foregroundColor(.secondary)
+ }
+ }
+ }
+ VStack(spacing: 2) {
+ Text(title)
+ .font(.caption)
+ .foregroundColor(.secondary)
+ if let subtitle, !subtitle.isEmpty {
+ Text(subtitle)
+ .font(.footnote)
+ .foregroundColor(.secondary)
+ .multilineTextAlignment(.center)
+ }
+ }
+ }
+ }
+}
diff --git a/DynamicIsland/components/Stats/DiskStatsDetailView.swift b/DynamicIsland/components/Stats/DiskStatsDetailView.swift
new file mode 100644
index 0000000..9aefcad
--- /dev/null
+++ b/DynamicIsland/components/Stats/DiskStatsDetailView.swift
@@ -0,0 +1,220 @@
+//
+// DiskStatsDetailView.swift
+// DynamicIsland
+//
+// Disk detail dashboard mirroring Stats app cards.
+//
+// Created by GitHub Copilot on 19/10/2025.
+//
+
+import SwiftUI
+
+struct DiskStatsDetailView: View {
+ @ObservedObject private var statsManager = StatsManager.shared
+
+ private let readColor = Color.cyan
+ private let writeColor = Color.yellow
+ private let cardBackground = Color(nsColor: .windowBackgroundColor).opacity(0.65)
+
+ var body: some View {
+ ScrollView {
+ VStack(spacing: 16) {
+ StatsCard(title: "Disk Overview", padding: 16, background: cardBackground, cornerRadius: 12) {
+ DiskOverview(
+ readSpeed: statsManager.diskRead,
+ writeSpeed: statsManager.diskWrite,
+ peakRead: statsManager.diskReadHistory.max() ?? 0,
+ peakWrite: statsManager.diskWriteHistory.max() ?? 0,
+ readColor: readColor,
+ writeColor: writeColor
+ )
+ }
+
+ StatsCard(title: "Storage Devices", padding: 16, background: cardBackground, cornerRadius: 12) {
+ DiskDevicesCard(devices: statsManager.diskDevices, accentColor: readColor)
+ }
+
+ StatsCard(title: "Session Totals", padding: 16, background: cardBackground, cornerRadius: 12) {
+ DiskTotalsView(totals: statsManager.diskTotals, readColor: readColor, writeColor: writeColor)
+ }
+ }
+ .padding(16)
+ }
+ .frame(minWidth: 360, minHeight: 340)
+ }
+}
+
+private struct DiskOverview: View {
+ let readSpeed: Double
+ let writeSpeed: Double
+ let peakRead: Double
+ let peakWrite: Double
+ let readColor: Color
+ let writeColor: Color
+
+ var body: some View {
+ HStack(alignment: .center, spacing: 24) {
+ VStack(alignment: .leading, spacing: 10) {
+ Text("Read")
+ .font(.headline)
+ .foregroundColor(readColor)
+ Text(StatsFormatting.mbPerSecond(readSpeed))
+ .font(.system(size: 26, weight: .bold, design: .rounded))
+ .foregroundColor(.primary)
+ DetailRow(color: readColor.opacity(0.6), label: "Peak", value: StatsFormatting.mbPerSecond(peakRead))
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+
+ Divider()
+ .frame(height: 110)
+ .overlay(Color.white.opacity(0.06))
+
+ VStack(alignment: .leading, spacing: 10) {
+ Text("Write")
+ .font(.headline)
+ .foregroundColor(writeColor)
+ Text(StatsFormatting.mbPerSecond(writeSpeed))
+ .font(.system(size: 26, weight: .bold, design: .rounded))
+ .foregroundColor(.primary)
+ DetailRow(color: writeColor.opacity(0.6), label: "Peak", value: StatsFormatting.mbPerSecond(peakWrite))
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+ }
+}
+
+private struct DiskTotalsView: View {
+ let totals: DiskTotals
+ let readColor: Color
+ let writeColor: Color
+
+ var body: some View {
+ VStack(spacing: 10) {
+ DetailRow(color: readColor.opacity(0.75), label: "Read", value: formattedBytes(totals.readMB))
+ DetailRow(color: writeColor.opacity(0.75), label: "Written", value: formattedBytes(totals.writtenMB))
+ }
+ }
+
+ private func formattedBytes(_ megabytes: Double) -> String {
+ let bytes = UInt64(megabytes * 1_048_576)
+ return StatsFormatting.bytes(bytes)
+ }
+}
+
+private struct DiskDevicesCard: View {
+ let devices: [DiskDeviceMetrics]
+ let accentColor: Color
+
+ private var sortedDevices: [DiskDeviceMetrics] {
+ devices.sorted { lhs, rhs in
+ if lhs.isRoot != rhs.isRoot {
+ return lhs.isRoot && !rhs.isRoot
+ }
+ if lhs.isRemovable != rhs.isRemovable {
+ return !lhs.isRemovable && rhs.isRemovable
+ }
+ return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending
+ }
+ }
+
+ var body: some View {
+ if sortedDevices.isEmpty {
+ Text("No storage devices detected yet.")
+ .font(.footnote)
+ .foregroundColor(.secondary)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ } else {
+ VStack(spacing: 12) {
+ ForEach(sortedDevices) { device in
+ DiskDeviceRow(device: device, accentColor: accentColor)
+ if device.id != sortedDevices.last?.id {
+ Divider().padding(.leading, 36)
+ }
+ }
+ }
+ }
+ }
+}
+
+private struct DiskDeviceRow: View {
+ let device: DiskDeviceMetrics
+ let accentColor: Color
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 8) {
+ HStack(spacing: 10) {
+ RoundedRectangle(cornerRadius: 8, style: .continuous)
+ .fill(accentColor.opacity(0.16))
+ .overlay(
+ Image(systemName: device.isRemovable ? "externaldrive" : "internaldrive")
+ .font(.system(size: 16, weight: .semibold))
+ .foregroundColor(accentColor)
+ )
+ .frame(width: 32, height: 32)
+ VStack(alignment: .leading, spacing: 2) {
+ Text(device.name)
+ .font(.system(size: 13, weight: .semibold))
+ Text(device.path.path)
+ .font(.caption2)
+ .foregroundColor(.secondary)
+ .lineLimit(1)
+ }
+ Spacer()
+ if device.isRoot {
+ Text("System")
+ .font(.caption2)
+ .foregroundColor(accentColor)
+ .padding(.horizontal, 6)
+ .padding(.vertical, 2)
+ .background(accentColor.opacity(0.16))
+ .clipShape(Capsule())
+ } else if device.isRemovable {
+ Text("External")
+ .font(.caption2)
+ .foregroundColor(accentColor)
+ .padding(.horizontal, 6)
+ .padding(.vertical, 2)
+ .background(accentColor.opacity(0.16))
+ .clipShape(Capsule())
+ }
+ }
+
+ DiskUsageBar(usedBytes: device.usedBytes, totalBytes: device.totalBytes, tint: accentColor)
+ .frame(height: 8)
+
+ HStack {
+ Text("Used · \(StatsFormatting.bytes(device.usedBytes))")
+ .font(.caption2)
+ .foregroundColor(.secondary)
+ Spacer()
+ Text("Free · \(StatsFormatting.bytes(device.freeBytes))")
+ .font(.caption2)
+ .foregroundColor(.secondary)
+ }
+
+ Text("Total · \(StatsFormatting.bytes(device.totalBytes))")
+ .font(.caption2)
+ .foregroundColor(.secondary)
+ }
+ }
+}
+
+private struct DiskUsageBar: View {
+ let usedBytes: UInt64
+ let totalBytes: UInt64
+ let tint: Color
+
+ var body: some View {
+ GeometryReader { proxy in
+ let width = proxy.size.width
+ let progress = totalBytes > 0 ? min(max(Double(usedBytes) / Double(totalBytes), 0), 1) : 0
+ ZStack(alignment: .leading) {
+ Capsule()
+ .fill(Color.primary.opacity(0.08))
+ Capsule()
+ .fill(tint.opacity(0.8))
+ .frame(width: width * progress)
+ }
+ }
+ }
+}
diff --git a/DynamicIsland/components/Stats/GPUStatsDetailView.swift b/DynamicIsland/components/Stats/GPUStatsDetailView.swift
new file mode 100644
index 0000000..3e52a07
--- /dev/null
+++ b/DynamicIsland/components/Stats/GPUStatsDetailView.swift
@@ -0,0 +1,305 @@
+//
+// GPUStatsDetailView.swift
+// DynamicIsland
+//
+// GPU detail dashboard inspired by the Stats app layout.
+//
+// Created by GitHub Copilot on 19/10/2025.
+//
+
+import SwiftUI
+
+struct GPUStatsDetailView: View {
+ @ObservedObject private var statsManager = StatsManager.shared
+ @State private var topProcesses: [ProcessStats] = []
+
+ private let accentColor = Color.purple
+ private let cardBackground = Color(nsColor: .windowBackgroundColor).opacity(0.65)
+ private let processDisplayLimit = 8
+
+ var body: some View {
+ ScrollView {
+ VStack(spacing: 16) {
+ StatsCard(title: "GPU Overview", padding: 16, background: cardBackground, cornerRadius: 12) {
+ GPUUsageDashboard(
+ usage: statsManager.gpuUsage,
+ breakdown: statsManager.gpuBreakdown,
+ averageUsage: statsManager.avgGpuUsage,
+ lastUpdated: statsManager.lastUpdated,
+ accentColor: accentColor,
+ primaryDevice: statsManager.gpuDevices.first
+ )
+ }
+
+ StatsCard(title: "Top Processes", padding: 16, background: cardBackground, cornerRadius: 12) {
+ CPUProcessList(processes: topProcesses, accentColor: accentColor, displayLimit: processDisplayLimit)
+ }
+
+ if !statsManager.gpuDevices.isEmpty {
+ StatsCard(title: "Devices", padding: 16, background: cardBackground, cornerRadius: 12) {
+ GPUDeviceList(devices: statsManager.gpuDevices, accentColor: accentColor)
+ }
+ }
+ }
+ .padding(16)
+ }
+ .frame(minWidth: 360, minHeight: 380)
+ .onAppear(perform: refreshProcesses)
+ .onReceive(statsManager.$lastUpdated) { _ in
+ refreshProcesses()
+ }
+ }
+
+ private func refreshProcesses() {
+ let processes = statsManager.getProcessesRankedByGPU()
+ topProcesses = Array(processes.prefix(processDisplayLimit))
+ }
+}
+
+private struct GPUUsageDashboard: View {
+ let usage: Double
+ let breakdown: GPUBreakdown
+ let averageUsage: Double
+ let lastUpdated: Date
+ let accentColor: Color
+ let primaryDevice: GPUDeviceMetrics?
+
+ var body: some View {
+ ViewThatFits(in: .horizontal) {
+ HStack(alignment: .top, spacing: 20) {
+ leftColumn
+ breakdownAndMeta
+ }
+ VStack(alignment: .leading, spacing: 20) {
+ leftColumn
+ breakdownAndMeta
+ }
+ }
+ }
+
+ private func formattedTimestamp(_ date: Date) -> String {
+ let formatter = DateFormatter()
+ formatter.dateStyle = .none
+ formatter.timeStyle = .medium
+ return formatter.string(from: date)
+ }
+
+ private var leftColumn: some View {
+ GPUEngineGauges(
+ usage: usage,
+ render: renderEngineUtilization,
+ tiler: tilerUtilization,
+ accentColor: accentColor
+ )
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+
+ private var breakdownAndMeta: some View {
+ ViewThatFits(in: .horizontal) {
+ HStack(alignment: .top, spacing: 20) {
+ breakdownSection
+ metaSection
+ }
+ VStack(alignment: .leading, spacing: 16) {
+ breakdownSection
+ Divider().padding(.vertical, 4)
+ metaSection
+ }
+ }
+ }
+
+ private var breakdownSection: some View {
+ VStack(alignment: .leading, spacing: 12) {
+ DetailRow(color: accentColor.opacity(0.9), label: "Render", value: StatsFormatting.percentage(breakdown.render))
+ DetailRow(color: accentColor.opacity(0.7), label: "Compute", value: StatsFormatting.percentage(breakdown.compute))
+ DetailRow(color: accentColor.opacity(0.55), label: "Video", value: StatsFormatting.percentage(breakdown.video))
+ DetailRow(color: accentColor.opacity(0.45), label: "Other", value: StatsFormatting.percentage(breakdown.other))
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+
+ private var metaSection: some View {
+ VStack(alignment: .leading, spacing: 8) {
+ Text("Averages")
+ .font(.caption)
+ .foregroundColor(.secondary)
+ DetailRow(color: nil, label: "Session Avg", value: StatsFormatting.percentage(averageUsage))
+ DetailRow(color: nil, label: "Last Update", value: formattedTimestamp(lastUpdated))
+ if let device = primaryDevice {
+ Divider().padding(.vertical, 4)
+ DetailRow(color: nil, label: "Active GPU", value: device.formattedVendorModel)
+ DetailRow(color: nil, label: "Status", value: device.isActive ? "Active" : "Idle")
+ DetailRow(color: nil, label: "Temperature", value: device.temperatureText)
+ if let cores = device.cores {
+ DetailRow(color: nil, label: "Cores", value: "\(cores)")
+ }
+ }
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+
+ private var renderEngineUtilization: Double? {
+ primaryDevice?.renderUtilization ?? breakdown.render
+ }
+
+ private var tilerUtilization: Double? {
+ primaryDevice?.tilerUtilization
+ }
+}
+
+private struct GPUEngineGauges: View {
+ let usage: Double
+ let render: Double?
+ let tiler: Double?
+ let accentColor: Color
+ private let ringDiameter: CGFloat = 112
+ private let ringLineWidth: CGFloat = 12
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 10) {
+ Text("Engine Utilization")
+ .font(.caption)
+ .foregroundColor(.secondary)
+ HStack(alignment: .center, spacing: 18) {
+ EngineGaugeView(title: "Render", value: render, tint: accentColor, size: 72)
+ GPUUsageRing(usage: usage, accentColor: accentColor, diameter: ringDiameter, lineWidth: ringLineWidth)
+ EngineGaugeView(title: "Tiler", value: tiler, tint: accentColor.opacity(0.75), size: 72)
+ }
+ .frame(maxWidth: .infinity, alignment: .center)
+ }
+ }
+}
+
+private struct GPUUsageRing: View {
+ let usage: Double
+ let accentColor: Color
+ var diameter: CGFloat = 108
+ var lineWidth: CGFloat = 12
+
+ var body: some View {
+ ZStack {
+ Circle()
+ .stroke(accentColor.opacity(0.25), lineWidth: lineWidth)
+ RingArc(start: 0, end: CGFloat(min(max(usage / 100, 0), 1)), color: accentColor, lineWidth: lineWidth)
+ VStack(spacing: 4) {
+ Text(StatsFormatting.percentage(usage))
+ .font(.system(size: 26, weight: .bold, design: .rounded))
+ Text("Active")
+ .font(.footnote)
+ .foregroundColor(.secondary)
+ }
+ }
+ .frame(width: diameter, height: diameter)
+ }
+}
+
+private struct EngineGaugeView: View {
+ let title: String
+ let value: Double?
+ let tint: Color
+ var size: CGFloat = 68
+
+ var body: some View {
+ if let value {
+ CircularGaugeView(
+ title: title,
+ value: min(max(value / 100, 0), 1),
+ tint: tint,
+ centerPrimaryText: StatsFormatting.percentage(value),
+ centerSecondaryText: nil,
+ subtitle: nil,
+ size: size,
+ lineWidth: 7,
+ backgroundTint: tint.opacity(0.18)
+ )
+ } else {
+ placeholder
+ }
+ }
+
+ private var placeholder: some View {
+ VStack(spacing: 8) {
+ ZStack {
+ Circle()
+ .stroke(Color.primary.opacity(0.08), lineWidth: 7)
+ .frame(width: size, height: size)
+ Text("—")
+ .font(.system(size: 18, weight: .semibold))
+ .foregroundColor(.secondary)
+ }
+ Text(title)
+ .font(.caption)
+ .foregroundColor(.secondary)
+ }
+ }
+}
+
+private struct GPUDeviceList: View {
+ let devices: [GPUDeviceMetrics]
+ let accentColor: Color
+
+ var body: some View {
+ VStack(spacing: 16) {
+ ForEach(Array(devices.enumerated()), id: \.element.id) { index, device in
+ GPUDeviceRow(device: device, accentColor: accentColor)
+ if index < devices.count - 1 {
+ Divider()
+ }
+ }
+ }
+ }
+}
+
+private struct GPUDeviceRow: View {
+ let device: GPUDeviceMetrics
+ let accentColor: Color
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 12) {
+ HStack(spacing: 12) {
+ Circle()
+ .fill(device.isActive ? Color.green.opacity(0.85) : Color.red.opacity(0.7))
+ .frame(width: 8, height: 8)
+ VStack(alignment: .leading, spacing: 2) {
+ Text(device.formattedVendorModel)
+ .font(.system(size: 13, weight: .semibold))
+ Text(device.isActive ? "Active" : "Idle")
+ .font(.caption2)
+ .foregroundColor(device.isActive ? .green : .secondary)
+ }
+ Spacer()
+ VStack(alignment: .trailing, spacing: 2) {
+ Text(device.utilizationText)
+ .font(.system(size: 14, weight: .semibold, design: .monospaced))
+ .foregroundColor(accentColor)
+ Text("Utilization")
+ .font(.caption2)
+ .foregroundColor(.secondary)
+ }
+ }
+ VStack(spacing: 8) {
+ if let render = device.renderUtilization {
+ DetailRow(color: accentColor.opacity(0.9), label: "Renderer", value: StatsFormatting.percentage(render))
+ }
+ if let tiler = device.tilerUtilization {
+ DetailRow(color: accentColor.opacity(0.6), label: "Tiler", value: StatsFormatting.percentage(tiler))
+ }
+ if let temp = device.temperature {
+ DetailRow(color: nil, label: "Temperature", value: String(format: "%.0f°C", temp))
+ }
+ if let fan = device.fanSpeed {
+ DetailRow(color: nil, label: "Fan Speed", value: "\(fan)%")
+ }
+ if let coreClock = device.coreClock {
+ DetailRow(color: nil, label: "Core Clock", value: "\(coreClock) MHz")
+ }
+ if let memoryClock = device.memoryClock {
+ DetailRow(color: nil, label: "Memory Clock", value: "\(memoryClock) MHz")
+ }
+ if let cores = device.cores {
+ DetailRow(color: nil, label: "Cores", value: "\(cores)")
+ }
+ }
+ }
+ }
+}
diff --git a/DynamicIsland/components/Stats/MemoryStatsDetailView.swift b/DynamicIsland/components/Stats/MemoryStatsDetailView.swift
new file mode 100644
index 0000000..7f8b548
--- /dev/null
+++ b/DynamicIsland/components/Stats/MemoryStatsDetailView.swift
@@ -0,0 +1,246 @@
+//
+// MemoryStatsDetailView.swift
+// DynamicIsland
+//
+// Detailed memory dashboard mirroring the Stats app layout.
+//
+// Created by GitHub Copilot on 19/10/2025.
+//
+
+import SwiftUI
+
+struct MemoryStatsDetailView: View {
+ @ObservedObject private var statsManager = StatsManager.shared
+ @State private var topProcesses: [ProcessStats] = []
+
+ private let accentColor = Color.green
+ private let cardBackground = Color(nsColor: .windowBackgroundColor).opacity(0.65)
+ private let processDisplayLimit = 8
+
+ var body: some View {
+ ScrollView {
+ VStack(spacing: 16) {
+ StatsCard(title: "Memory Overview", padding: 16, background: cardBackground, cornerRadius: 12) {
+ MemoryUsageDashboard(breakdown: statsManager.memoryBreakdown, accentColor: accentColor)
+ }
+
+ StatsCard(title: "Top Processes", padding: 16, background: cardBackground, cornerRadius: 12) {
+ CPUProcessList(processes: topProcesses, accentColor: accentColor, displayLimit: processDisplayLimit)
+ }
+
+ StatsCard(title: "Pressure & Swap", padding: 16, background: cardBackground, cornerRadius: 12) {
+ MemoryHealthView(breakdown: statsManager.memoryBreakdown, accentColor: accentColor)
+ }
+ }
+ .padding(16)
+ }
+ .frame(minWidth: 380, minHeight: 420)
+ .onAppear(perform: refreshProcesses)
+ .onReceive(statsManager.$lastUpdated) { _ in
+ refreshProcesses()
+ }
+ }
+
+ private func refreshProcesses() {
+ let processes = statsManager.getProcessesRankedByMemory()
+ topProcesses = Array(processes.prefix(processDisplayLimit))
+ }
+}
+
+private struct MemoryUsageDashboard: View {
+ let breakdown: MemoryBreakdown
+ let accentColor: Color
+
+ private let freeColor = Color.gray.opacity(0.28)
+
+ var body: some View {
+ let totalsSection = VStack(alignment: .leading, spacing: 12) {
+ DetailRow(color: accentColor.opacity(0.8), label: "Used", value: formattedBytes(breakdown.usedBytes))
+ DetailRow(color: freeColor.opacity(0.9), label: "Free", value: formattedBytes(breakdown.freeBytes))
+ DetailRow(color: nil, label: "Total", value: formattedBytes(breakdown.totalBytes))
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+
+ let breakdownSection = VStack(alignment: .leading, spacing: 8) {
+ Text("Breakdown")
+ .font(.caption)
+ .foregroundColor(.secondary)
+ MemoryBreakdownRow(label: "App", value: breakdown.appBytes, total: breakdown.totalBytes, color: accentColor.opacity(0.85))
+ MemoryBreakdownRow(label: "Cached", value: breakdown.cacheBytes, total: breakdown.totalBytes, color: accentColor.opacity(0.65))
+ MemoryBreakdownRow(label: "Wired", value: breakdown.wiredBytes, total: breakdown.totalBytes, color: accentColor.opacity(0.45))
+ MemoryBreakdownRow(label: "Compressed", value: breakdown.compressedBytes, total: breakdown.totalBytes, color: accentColor.opacity(0.35))
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+
+ return ViewThatFits(in: .horizontal) {
+ VStack(alignment: .leading, spacing: 20) {
+ usageRing
+ totalsSection
+ Divider().padding(.vertical, 4)
+ breakdownSection
+ }
+ HStack(alignment: .top, spacing: 28) {
+ usageRing
+ totalsSection
+ breakdownSection
+ }
+ }
+ }
+
+ private func formattedBytes(_ value: UInt64) -> String {
+ StatsFormatting.bytes(value)
+ }
+
+ private var usageRing: some View {
+ ZStack {
+ Circle()
+ .stroke(freeColor.opacity(0.35), lineWidth: 14)
+ .frame(width: 128, height: 128)
+
+ MemoryUsageRing(breakdown: breakdown, usedColor: accentColor, freeColor: freeColor)
+ .frame(width: 128, height: 128)
+
+ VStack(spacing: 4) {
+ Text(StatsFormatting.percentage(breakdown.usedPercentage))
+ .font(.system(size: 30, weight: .bold, design: .rounded))
+ Text("Used")
+ .font(.footnote)
+ .foregroundColor(.secondary)
+ }
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+}
+
+private struct MemoryUsageRing: View {
+ let breakdown: MemoryBreakdown
+ let usedColor: Color
+ let freeColor: Color
+
+ var body: some View {
+ ZStack {
+ let usedFraction = CGFloat(min(max(breakdown.usedPercentage / 100, 0), 1))
+ if usedFraction > 0 {
+ RingArc(start: 0, end: usedFraction, color: usedColor, lineWidth: 14)
+ }
+ if usedFraction < 1 {
+ RingArc(start: usedFraction, end: 1, color: freeColor.opacity(0.65), lineWidth: 14)
+ }
+ }
+ }
+}
+
+private struct MemoryBreakdownRow: View {
+ let label: String
+ let value: UInt64
+ let total: UInt64
+ let color: Color
+
+ var body: some View {
+ let percent = total > 0 ? Double(value) / Double(total) * 100 : 0
+ return DetailRow(color: color, label: label, value: "\(StatsFormatting.bytes(value)) · \(StatsFormatting.percentage(percent))")
+ }
+}
+
+private struct MemoryHealthView: View {
+ let breakdown: MemoryBreakdown
+ let accentColor: Color
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 16) {
+ MemoryPressureIndicator(pressure: breakdown.pressure)
+ Divider().padding(.vertical, 4)
+ SwapUsageView(swap: breakdown.swap, accentColor: accentColor)
+ }
+ }
+}
+
+private struct MemoryPressureIndicator: View {
+ let pressure: MemoryPressure
+
+ private var status: (title: String, color: Color, description: String) {
+ switch pressure.level {
+ case .normal:
+ return ("Normal", Color.green, "System memory pressure is nominal.")
+ case .warning:
+ return ("Warning", Color.orange, "Memory pressure is elevated. Closing apps may help.")
+ case .critical:
+ return ("Critical", Color.red, "Memory pressure is critical; the system may purge aggressively.")
+ }
+ }
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 8) {
+ Text("Pressure")
+ .font(.caption)
+ .foregroundColor(.secondary)
+
+ HStack {
+ Capsule()
+ .fill(status.color.opacity(0.16))
+ .overlay(
+ HStack(spacing: 8) {
+ Circle()
+ .fill(status.color)
+ .frame(width: 10, height: 10)
+ Text(status.title)
+ .font(.system(size: 13, weight: .semibold))
+ .foregroundColor(status.color)
+ }
+ .padding(.horizontal, 12)
+ )
+ .frame(height: 28)
+ Spacer()
+ }
+
+ Text(status.description)
+ .font(.footnote)
+ .foregroundColor(.secondary)
+ }
+ }
+}
+
+private struct SwapUsageView: View {
+ let swap: MemorySwap
+ let accentColor: Color
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 10) {
+ Text("Swap")
+ .font(.caption)
+ .foregroundColor(.secondary)
+
+ if swap.totalBytes > 0 {
+ DetailRow(color: accentColor.opacity(0.85), label: "Used", value: StatsFormatting.bytes(swap.usedBytes))
+ DetailRow(color: accentColor.opacity(0.55), label: "Free", value: StatsFormatting.bytes(swap.freeBytes))
+ DetailRow(color: nil, label: "Total", value: StatsFormatting.bytes(swap.totalBytes))
+ SwapUsageBar(usedBytes: swap.usedBytes, totalBytes: swap.totalBytes, tint: accentColor)
+ .frame(height: 8)
+ } else {
+ Text("Swap is disabled on this system.")
+ .font(.footnote)
+ .foregroundColor(.secondary)
+ }
+ }
+ }
+}
+
+private struct SwapUsageBar: View {
+ let usedBytes: UInt64
+ let totalBytes: UInt64
+ let tint: Color
+
+ var body: some View {
+ GeometryReader { proxy in
+ let width = proxy.size.width
+ let progress = totalBytes > 0 ? min(max(Double(usedBytes) / Double(totalBytes), 0), 1) : 0
+ ZStack(alignment: .leading) {
+ Capsule()
+ .fill(Color.primary.opacity(0.08))
+ Capsule()
+ .fill(tint.opacity(0.8))
+ .frame(width: width * progress)
+ }
+ }
+ }
+}
diff --git a/DynamicIsland/components/Stats/NetworkStatsDetailView.swift b/DynamicIsland/components/Stats/NetworkStatsDetailView.swift
new file mode 100644
index 0000000..8be9e7a
--- /dev/null
+++ b/DynamicIsland/components/Stats/NetworkStatsDetailView.swift
@@ -0,0 +1,264 @@
+//
+// NetworkStatsDetailView.swift
+// DynamicIsland
+//
+// Network detail dashboard with throughput history and totals.
+//
+// Created by GitHub Copilot on 19/10/2025.
+//
+
+import SwiftUI
+
+struct NetworkStatsDetailView: View {
+ @ObservedObject private var statsManager = StatsManager.shared
+
+ private let downloadColor = Color.orange
+ private let uploadColor = Color.red
+ private let cardBackground = Color(nsColor: .windowBackgroundColor).opacity(0.65)
+
+ var body: some View {
+ ScrollView {
+ VStack(spacing: 16) {
+ StatsCard(title: "Network Overview", padding: 16, background: cardBackground, cornerRadius: 12) {
+ NetworkOverview(
+ download: statsManager.networkDownload,
+ upload: statsManager.networkUpload,
+ avgDownload: average(statsManager.networkDownloadHistory),
+ avgUpload: average(statsManager.networkUploadHistory),
+ peakDownload: statsManager.networkDownloadHistory.max() ?? 0,
+ peakUpload: statsManager.networkUploadHistory.max() ?? 0,
+ downloadColor: downloadColor,
+ uploadColor: uploadColor
+ )
+ }
+
+ StatsCard(title: "Interfaces", padding: 16, background: cardBackground, cornerRadius: 12) {
+ NetworkInterfacesCard(
+ interfaces: statsManager.networkInterfaces,
+ downloadColor: downloadColor,
+ uploadColor: uploadColor
+ )
+ }
+
+ StatsCard(title: "Session Totals", padding: 16, background: cardBackground, cornerRadius: 12) {
+ NetworkTotalsView(totals: statsManager.networkTotals, downloadColor: downloadColor, uploadColor: uploadColor)
+ }
+ }
+ .padding(16)
+ }
+ .frame(minWidth: 360, minHeight: 340)
+ }
+
+ private func average(_ values: [Double]) -> Double {
+ guard !values.isEmpty else { return 0 }
+ let filtered = values.filter { $0 > 0 }
+ guard !filtered.isEmpty else { return 0 }
+ return filtered.reduce(0, +) / Double(filtered.count)
+ }
+}
+
+private struct NetworkOverview: View {
+ let download: Double
+ let upload: Double
+ let avgDownload: Double
+ let avgUpload: Double
+ let peakDownload: Double
+ let peakUpload: Double
+ let downloadColor: Color
+ let uploadColor: Color
+
+ var body: some View {
+ HStack(alignment: .center, spacing: 24) {
+ VStack(alignment: .leading, spacing: 10) {
+ Text("Download")
+ .font(.headline)
+ .foregroundColor(downloadColor)
+ Text(StatsFormatting.throughput(download))
+ .font(.system(size: 26, weight: .bold, design: .rounded))
+ .foregroundColor(.primary)
+ DetailRow(color: downloadColor.opacity(0.6), label: "Average", value: StatsFormatting.throughput(avgDownload))
+ DetailRow(color: downloadColor.opacity(0.45), label: "Peak", value: StatsFormatting.throughput(peakDownload))
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+
+ Divider()
+ .frame(height: 110)
+ .overlay(Color.white.opacity(0.06))
+
+ VStack(alignment: .leading, spacing: 10) {
+ Text("Upload")
+ .font(.headline)
+ .foregroundColor(uploadColor)
+ Text(StatsFormatting.throughput(upload))
+ .font(.system(size: 26, weight: .bold, design: .rounded))
+ .foregroundColor(.primary)
+ DetailRow(color: uploadColor.opacity(0.6), label: "Average", value: StatsFormatting.throughput(avgUpload))
+ DetailRow(color: uploadColor.opacity(0.45), label: "Peak", value: StatsFormatting.throughput(peakUpload))
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+ }
+}
+
+private struct NetworkTotalsView: View {
+ let totals: NetworkTotals
+ let downloadColor: Color
+ let uploadColor: Color
+
+ var body: some View {
+ VStack(spacing: 10) {
+ DetailRow(color: downloadColor.opacity(0.75), label: "Downloaded", value: formattedBytes(totals.downloadedMB))
+ DetailRow(color: uploadColor.opacity(0.75), label: "Uploaded", value: formattedBytes(totals.uploadedMB))
+ }
+ }
+
+ private func formattedBytes(_ megabytes: Double) -> String {
+ let bytes = UInt64(megabytes * 1_048_576)
+ return StatsFormatting.bytes(bytes)
+ }
+}
+
+private struct NetworkInterfacesCard: View {
+ let interfaces: [NetworkInterfaceMetrics]
+ let downloadColor: Color
+ let uploadColor: Color
+
+ private var sortedInterfaces: [NetworkInterfaceMetrics] {
+ interfaces.sorted { lhs, rhs in
+ if lhs.isActive != rhs.isActive {
+ return lhs.isActive && !rhs.isActive
+ }
+ return interfacePriority(lhs.type) < interfacePriority(rhs.type)
+ }
+ }
+
+ private func interfacePriority(_ type: NetworkInterfaceType) -> Int {
+ switch type {
+ case .wifi:
+ return 0
+ case .ethernet:
+ return 1
+ case .cellular:
+ return 2
+ case .other:
+ return 3
+ case .loopback:
+ return 4
+ }
+ }
+
+ var body: some View {
+ if sortedInterfaces.isEmpty {
+ Text("No active interfaces detected yet.")
+ .font(.footnote)
+ .foregroundColor(.secondary)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ } else {
+ VStack(spacing: 12) {
+ ForEach(sortedInterfaces) { interface in
+ NetworkInterfaceRow(
+ interface: interface,
+ downloadColor: downloadColor,
+ uploadColor: uploadColor
+ )
+ if interface.id != sortedInterfaces.last?.id {
+ Divider().padding(.leading, 44)
+ }
+ }
+ }
+ }
+ }
+}
+
+private struct NetworkInterfaceRow: View {
+ let interface: NetworkInterfaceMetrics
+ let downloadColor: Color
+ let uploadColor: Color
+
+ var body: some View {
+ HStack(alignment: .top, spacing: 12) {
+ icon
+ VStack(alignment: .leading, spacing: 6) {
+ HStack(spacing: 8) {
+ Text(interface.displayName)
+ .font(.system(size: 13, weight: .semibold))
+ .foregroundColor(.primary)
+ if interface.isActive {
+ Text("Active")
+ .font(.caption2)
+ .foregroundColor(downloadColor)
+ .padding(.horizontal, 6)
+ .padding(.vertical, 2)
+ .background(downloadColor.opacity(0.16))
+ .clipShape(Capsule())
+ }
+ }
+
+ if let ipv4 = interface.ipv4, !ipv4.isEmpty {
+ Text("IPv4 · \(ipv4)")
+ .font(.caption2)
+ .foregroundColor(.secondary)
+ }
+ if let ipv6 = interface.ipv6, !ipv6.isEmpty {
+ Text("IPv6 · \(ipv6)")
+ .font(.caption2)
+ .foregroundColor(.secondary)
+ }
+
+ Text("Session · ↓ \(formattedBytes(interface.totalDownloaded)) · ↑ \(formattedBytes(interface.totalUploaded))")
+ .font(.caption2)
+ .foregroundColor(.secondary)
+ }
+
+ Spacer()
+
+ VStack(alignment: .trailing, spacing: 4) {
+ metricRow(label: "Down", value: StatsFormatting.throughput(interface.currentDownload), color: downloadColor)
+ metricRow(label: "Up", value: StatsFormatting.throughput(interface.currentUpload), color: uploadColor)
+ }
+ }
+ }
+
+ private var icon: some View {
+ let isActive = interface.isActive
+ return RoundedRectangle(cornerRadius: 8, style: .continuous)
+ .fill(isActive ? downloadColor.opacity(0.18) : Color.primary.opacity(0.05))
+ .overlay(
+ Image(systemName: iconName)
+ .font(.system(size: 16, weight: .semibold))
+ .foregroundColor(isActive ? downloadColor : .secondary)
+ )
+ .frame(width: 32, height: 32)
+ }
+
+ private var iconName: String {
+ switch interface.type {
+ case .wifi:
+ return "wifi"
+ case .ethernet:
+ return "cable.connector"
+ case .cellular:
+ return "antenna.radiowaves.left.and.right"
+ case .loopback:
+ return "arrow.triangle.2.circlepath"
+ case .other:
+ return "network"
+ }
+ }
+
+ private func metricRow(label: String, value: String, color: Color) -> some View {
+ HStack(spacing: 6) {
+ Text(label)
+ .font(.caption2)
+ .foregroundColor(.secondary)
+ Text(value)
+ .font(.system(size: 13, weight: .semibold, design: .monospaced))
+ .foregroundColor(color)
+ }
+ }
+
+ private func formattedBytes(_ megabytes: Double) -> String {
+ let bytes = UInt64(max(megabytes, 0) * 1_048_576)
+ return StatsFormatting.bytes(bytes)
+ }
+}
diff --git a/DynamicIsland/components/Stats/ProcessStats.swift b/DynamicIsland/components/Stats/ProcessStats.swift
index 99a1130..2d0b4bf 100644
--- a/DynamicIsland/components/Stats/ProcessStats.swift
+++ b/DynamicIsland/components/Stats/ProcessStats.swift
@@ -34,12 +34,16 @@ enum ProcessRankingType {
case cpu
case memory
case gpu
+ case network
+ case disk
var title: String {
switch self {
case .cpu: return "CPU Usage"
- case .memory: return "Memory Usage"
+ case .memory: return "Memory Usage"
case .gpu: return "GPU Usage"
+ case .network: return "Network Activity"
+ case .disk: return "Disk Activity"
}
}
@@ -48,6 +52,8 @@ enum ProcessRankingType {
case .cpu: return "cpu"
case .memory: return "memorychip"
case .gpu: return "display"
+ case .network: return "network"
+ case .disk: return "internaldrive"
}
}
@@ -56,6 +62,8 @@ enum ProcessRankingType {
case .cpu: return .blue
case .memory: return .green
case .gpu: return .purple
+ case .network: return .orange
+ case .disk: return .cyan
}
}
}
\ No newline at end of file
diff --git a/DynamicIsland/components/Stats/RankedProcessPopover.swift b/DynamicIsland/components/Stats/RankedProcessPopover.swift
index d47e673..ce1ea24 100644
--- a/DynamicIsland/components/Stats/RankedProcessPopover.swift
+++ b/DynamicIsland/components/Stats/RankedProcessPopover.swift
@@ -6,191 +6,116 @@
// Adapted from boring.notch implementation
import SwiftUI
+#if os(macOS)
+import AppKit
+#endif
struct RankedProcessPopover: View {
let rankingType: ProcessRankingType
- @ObservedObject var statsManager = StatsManager.shared
@Environment(\.dismiss) private var dismiss
- @State private var isHovering = false
// Callback to notify parent about hover state
var onHoverChange: ((Bool) -> Void)?
- private var rankedProcesses: [ProcessStats] {
+ private var configuration: (width: CGFloat, minHeight: CGFloat, padding: CGFloat) {
switch rankingType {
case .cpu:
- return statsManager.getProcessesRankedByCPU()
+ return (420, 420, 0)
case .memory:
- return statsManager.getProcessesRankedByMemory()
+ return (400, 380, 0)
case .gpu:
- return statsManager.getProcessesRankedByGPU()
+ return (380, 360, 0)
+ case .network, .disk:
+ return (360, 320, 0)
}
}
var body: some View {
- VStack(alignment: .leading, spacing: 8) {
- // Header
- HStack {
- Image(systemName: rankingType.icon)
- .font(.title3)
- .foregroundColor(rankingType.color)
-
- Text("Top Processes by \(rankingType.title)")
- .font(.headline)
- .fontWeight(.semibold)
-
- Spacer()
-
+ popoverContent
+ .padding(configuration.padding)
+ .frame(width: configuration.width)
+ .frame(minHeight: configuration.minHeight)
+ .background(Color(NSColor.controlBackgroundColor))
+ .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
+ .shadow(color: .black.opacity(0.28), radius: 12, x: 0, y: 8)
+ .overlay(alignment: .topTrailing) {
Button(action: { dismiss() }) {
Image(systemName: "xmark.circle.fill")
.font(.title3)
.foregroundColor(.secondary)
+ .padding(10)
}
.buttonStyle(PlainButtonStyle())
}
- .padding(.bottom, 4)
-
- Divider()
-
- // Process list
- ScrollView {
- LazyVStack(spacing: 6) {
- ForEach(Array(rankedProcesses.prefix(15).enumerated()), id: \.element.id) { index, process in
- RankedProcessRow(
- process: process,
- rank: index + 1,
- rankingType: rankingType
- )
- }
+ .overlay(alignment: .center) {
+ PopoverHoverSensor { hovering in
+ onHoverChange?(hovering)
}
- .padding(.vertical, 4)
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .allowsHitTesting(false)
}
- .frame(maxHeight: 300)
-
- // Footer with refresh button
- HStack {
- Text("\(rankedProcesses.count) processes")
- .font(.caption)
- .foregroundColor(.secondary)
-
- Spacer()
-
- Button("Refresh") {
- // Trigger stats update
- if statsManager.isMonitoring {
- // Stats will auto-refresh
- }
- }
- .font(.caption)
- .foregroundColor(rankingType.color)
- }
- }
- .padding()
- .frame(width: 320)
- .frame(minHeight: 200)
- .background(Color(NSColor.controlBackgroundColor))
- .clipShape(RoundedRectangle(cornerRadius: 12))
- .shadow(color: .black.opacity(0.3), radius: 8, x: 0, y: 4)
- .onHover { hovering in
- isHovering = hovering
- onHoverChange?(hovering)
+ }
+
+ @ViewBuilder
+ private var popoverContent: some View {
+ switch rankingType {
+ case .cpu:
+ CPUStatsDetailView()
+ case .memory:
+ MemoryStatsDetailView()
+ case .gpu:
+ GPUStatsDetailView()
+ case .network:
+ NetworkStatsDetailView()
+ case .disk:
+ DiskStatsDetailView()
}
}
}
-struct RankedProcessRow: View {
- let process: ProcessStats
- let rank: Int
- let rankingType: ProcessRankingType
- @State private var isHovered = false
-
- private var primaryValue: String {
- switch rankingType {
- case .cpu:
- return process.cpuUsageString
- case .memory:
- return process.memoryUsageString
- case .gpu:
- return process.cpuUsageString // Using CPU as proxy for GPU
- }
+#if os(macOS)
+private struct PopoverHoverSensor: NSViewRepresentable {
+ var onHoverChange: ((Bool) -> Void)?
+
+ func makeNSView(context: Context) -> TrackingView {
+ let view = TrackingView()
+ view.onHoverChange = onHoverChange
+ return view
}
-
- private var secondaryValue: String {
- switch rankingType {
- case .cpu:
- return process.memoryUsageString
- case .memory:
- return process.cpuUsageString
- case .gpu:
- return process.memoryUsageString
- }
+
+ func updateNSView(_ nsView: TrackingView, context: Context) {
+ nsView.onHoverChange = onHoverChange
}
-
- var body: some View {
- HStack(spacing: 8) {
- // Rank
- Text("\(rank)")
- .font(.caption)
- .fontWeight(.bold)
- .foregroundColor(rankingType.color)
- .frame(width: 24, alignment: .center)
-
- // App icon
- Group {
- if let icon = process.icon {
- Image(nsImage: icon)
- .resizable()
- .aspectRatio(contentMode: .fit)
- } else {
- Image(systemName: "app")
- .foregroundColor(.secondary)
- }
+
+ final class TrackingView: NSView {
+ var onHoverChange: ((Bool) -> Void)?
+ private var trackingArea: NSTrackingArea?
+
+ override func updateTrackingAreas() {
+ super.updateTrackingAreas()
+ if let trackingArea {
+ removeTrackingArea(trackingArea)
}
- .frame(width: 20, height: 20)
-
- // Process info
- VStack(alignment: .leading, spacing: 2) {
- Text(process.name)
- .font(.system(size: 12, weight: .medium))
- .foregroundColor(.primary)
- .lineLimit(1)
- .truncationMode(.tail)
-
- HStack(spacing: 8) {
- Text(primaryValue)
- .font(.caption)
- .fontWeight(.semibold)
- .foregroundColor(rankingType.color)
-
- Text(secondaryValue)
- .font(.caption)
- .foregroundColor(.secondary)
- }
- }
-
- Spacer()
-
- // PID
- Text("PID: \(process.pid)")
- .font(.caption2)
- .foregroundColor(.secondary)
- .opacity(0.7)
+ let options: NSTrackingArea.Options = [.mouseEnteredAndExited, .activeAlways, .inVisibleRect]
+ let area = NSTrackingArea(rect: bounds, options: options, owner: self, userInfo: nil)
+ addTrackingArea(area)
+ trackingArea = area
}
- .padding(.horizontal, 8)
- .padding(.vertical, 6)
- .background(
- RoundedRectangle(cornerRadius: 6)
- .fill(isHovered ? Color(NSColor.selectedControlColor).opacity(0.2) : Color.clear)
- )
- .onHover { hovering in
- withAnimation(.easeInOut(duration: 0.15)) {
- isHovered = hovering
+
+ override func mouseEntered(with event: NSEvent) {
+ onHoverChange?(true)
+ }
+
+ override func mouseExited(with event: NSEvent) {
+ onHoverChange?(false)
+ }
+
+ deinit {
+ onHoverChange?(false)
+ if let trackingArea {
+ removeTrackingArea(trackingArea)
}
}
}
}
-
-#Preview {
- RankedProcessPopover(rankingType: .cpu)
- .background(Color.black)
-}
\ No newline at end of file
+#endif
\ No newline at end of file
diff --git a/DynamicIsland/components/Stats/StatsFormatting.swift b/DynamicIsland/components/Stats/StatsFormatting.swift
new file mode 100644
index 0000000..0e77fd2
--- /dev/null
+++ b/DynamicIsland/components/Stats/StatsFormatting.swift
@@ -0,0 +1,78 @@
+//
+// StatsFormatting.swift
+// DynamicIsland
+//
+// Lightweight formatters shared across stats detail popovers.
+//
+// Created by GitHub Copilot on 19/10/2025.
+//
+
+import Foundation
+
+enum StatsFormatting {
+ static func bytes(_ value: UInt64) -> String {
+ let formatter = ByteCountFormatter()
+ formatter.allowedUnits = [.useMB, .useGB]
+ formatter.countStyle = .memory
+ formatter.includesUnit = true
+ return formatter.string(fromByteCount: Int64(value))
+ }
+
+ static func mbPerSecond(_ value: Double) -> String {
+ let formatter = NumberFormatter()
+ formatter.numberStyle = .decimal
+ formatter.maximumFractionDigits = value >= 10 ? 1 : 2
+ let number = NSNumber(value: value)
+ let formatted = formatter.string(from: number) ?? String(format: "%.2f", value)
+ return "\(formatted) MB/s"
+ }
+
+ static func throughput(_ valueInMegabytesPerSecond: Double) -> String {
+ let formatter = NumberFormatter()
+ formatter.numberStyle = .decimal
+
+ if valueInMegabytesPerSecond >= 1 {
+ formatter.maximumFractionDigits = valueInMegabytesPerSecond >= 10 ? 1 : 2
+ let formatted = formatter.string(from: NSNumber(value: valueInMegabytesPerSecond))
+ ?? String(format: "%.2f", valueInMegabytesPerSecond)
+ return "\(formatted) MB/s"
+ }
+
+ let valueInKilobytesPerSecond = valueInMegabytesPerSecond * 1024
+ if valueInKilobytesPerSecond >= 1 {
+ formatter.maximumFractionDigits = valueInKilobytesPerSecond >= 10 ? 0 : 1
+ let formatted = formatter.string(from: NSNumber(value: valueInKilobytesPerSecond))
+ ?? String(format: valueInKilobytesPerSecond >= 10 ? "%.0f" : "%.1f", valueInKilobytesPerSecond)
+ return "\(formatted) KB/s"
+ }
+
+ let valueInBytesPerSecond = valueInMegabytesPerSecond * 1_048_576
+ if valueInBytesPerSecond >= 1 {
+ formatter.maximumFractionDigits = 0
+ let formatted = formatter.string(from: NSNumber(value: valueInBytesPerSecond))
+ ?? String(format: "%.0f", valueInBytesPerSecond)
+ return "\(formatted) B/s"
+ }
+
+ return "0 B/s"
+ }
+
+ static func percentage(_ value: Double) -> String {
+ String(format: "%.1f%%", value)
+ }
+
+ static func gigabytes(_ value: Double) -> String {
+ let formatter = NumberFormatter()
+ formatter.numberStyle = .decimal
+ formatter.maximumFractionDigits = value >= 10 ? 1 : 2
+ return (formatter.string(from: NSNumber(value: value)) ?? String(format: "%.2f", value)) + " GB"
+ }
+
+ static func abbreviatedDuration(_ value: TimeInterval) -> String {
+ let formatter = DateComponentsFormatter()
+ formatter.allowedUnits = [.day, .hour, .minute]
+ formatter.maximumUnitCount = 2
+ formatter.unitsStyle = .abbreviated
+ return formatter.string(from: value) ?? "—"
+ }
+}
diff --git a/DynamicIsland/components/Stats/StatsPanelView.swift b/DynamicIsland/components/Stats/StatsPanelView.swift
index 2c86b47..f0a04c2 100644
--- a/DynamicIsland/components/Stats/StatsPanelView.swift
+++ b/DynamicIsland/components/Stats/StatsPanelView.swift
@@ -67,65 +67,10 @@ struct StatsPanelView: View {
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
// Stats content - Compact two-row layout
- VStack(spacing: 16) {
- // Top row: System overview cards
- HStack(spacing: 12) {
- CompactOverviewCard(
- title: "CPU",
- value: statsManager.cpuUsageString,
- color: .blue,
- icon: "cpu",
- data: statsManager.cpuHistory
- )
-
- CompactOverviewCard(
- title: "Memory",
- value: statsManager.memoryUsageString,
- color: .green,
- icon: "memorychip",
- data: statsManager.memoryHistory
- )
-
- CompactOverviewCard(
- title: "GPU",
- value: statsManager.gpuUsageString,
- color: .purple,
- icon: "display",
- data: statsManager.gpuHistory
- )
- }
-
- // Bottom row: Network and Disk graphs
- HStack(spacing: 12) {
- CompactDualGraph(
- title: "Network",
- primaryLabel: "Down",
- primaryValue: "\(String(format: "%.1f", statsManager.networkDownload / 1024 / 1024)) MB/s",
- primaryData: statsManager.networkDownloadHistory,
- primaryColor: .green,
- secondaryLabel: "Up",
- secondaryValue: "\(String(format: "%.1f", statsManager.networkUpload / 1024 / 1024)) MB/s",
- secondaryData: statsManager.networkUploadHistory,
- secondaryColor: .orange
- )
-
- CompactDualGraph(
- title: "Disk",
- primaryLabel: "Read",
- primaryValue: "\(String(format: "%.1f", statsManager.diskRead / 1024 / 1024)) MB/s",
- primaryData: statsManager.diskReadHistory,
- primaryColor: .cyan,
- secondaryLabel: "Write",
- secondaryValue: "\(String(format: "%.1f", statsManager.diskWrite / 1024 / 1024)) MB/s",
- secondaryData: statsManager.diskWriteHistory,
- secondaryColor: .pink
- )
- }
- }
- .padding(16)
+ CPUStatsDetailView()
}
}
- .frame(width: 650, height: 300)
+ .frame(width: 520, height: 620)
.background(VisualEffectView(material: .hudWindow, blendingMode: .behindWindow))
.cornerRadius(12)
.shadow(color: .black.opacity(0.4), radius: 20, x: 0, y: 10)
diff --git a/DynamicIsland/managers/StatsManager.swift b/DynamicIsland/managers/StatsManager.swift
index 493dfeb..32b971d 100644
--- a/DynamicIsland/managers/StatsManager.swift
+++ b/DynamicIsland/managers/StatsManager.swift
@@ -10,10 +10,357 @@ import Combine
import SwiftUI
import IOKit
import IOKit.ps
+import IOKit.graphics
import Darwin
import AppKit
//import Network
+struct MemoryBreakdown: Equatable {
+ let totalBytes: UInt64
+ let usedBytes: UInt64
+ let freeBytes: UInt64
+ let wiredBytes: UInt64
+ let activeBytes: UInt64
+ let inactiveBytes: UInt64
+ let compressedBytes: UInt64
+ let appBytes: UInt64
+ let cacheBytes: UInt64
+ let swap: MemorySwap
+ let pressure: MemoryPressure
+
+ static let zero = MemoryBreakdown(
+ totalBytes: 0,
+ usedBytes: 0,
+ freeBytes: 0,
+ wiredBytes: 0,
+ activeBytes: 0,
+ inactiveBytes: 0,
+ compressedBytes: 0,
+ appBytes: 0,
+ cacheBytes: 0,
+ swap: .zero,
+ pressure: .unknown
+ )
+
+ var usedPercentage: Double {
+ guard totalBytes > 0 else { return 0 }
+ return Double(usedBytes) / Double(totalBytes) * 100
+ }
+
+ var freePercentage: Double {
+ guard totalBytes > 0 else { return 0 }
+ return Double(freeBytes) / Double(totalBytes) * 100
+ }
+
+ var appPercentage: Double {
+ guard totalBytes > 0 else { return 0 }
+ return Double(appBytes) / Double(totalBytes) * 100
+ }
+
+ var cachePercentage: Double {
+ guard totalBytes > 0 else { return 0 }
+ return Double(cacheBytes) / Double(totalBytes) * 100
+ }
+}
+
+struct MemorySwap: Equatable {
+ let totalBytes: UInt64
+ let usedBytes: UInt64
+ let freeBytes: UInt64
+
+ static let zero = MemorySwap(totalBytes: 0, usedBytes: 0, freeBytes: 0)
+}
+
+enum MemoryPressureLevel: String, Equatable {
+ case normal
+ case warning
+ case critical
+}
+
+struct MemoryPressure: Equatable {
+ let rawValue: Int
+ let level: MemoryPressureLevel
+
+ static let unknown = MemoryPressure(rawValue: 0, level: .normal)
+}
+
+struct CPUCoreUsage: Identifiable, Equatable {
+ let id: Int
+ let usage: Double
+}
+
+// MARK: - GPU Helpers
+
+private final class GPUInfoCollector {
+ func collectDevices() -> [GPUDeviceMetrics] {
+ var devices: [GPUDeviceMetrics] = []
+ let matching = IOServiceMatching(kIOAcceleratorClassName)
+ var iterator: io_iterator_t = 0
+ guard IOServiceGetMatchingServices(kIOMainPortDefault, matching, &iterator) == KERN_SUCCESS else {
+ return devices
+ }
+ defer { IOObjectRelease(iterator) }
+ var index = 0
+ var service = IOIteratorNext(iterator)
+ while service != 0 {
+ if let properties = copyProperties(for: service),
+ let device = makeDevice(from: properties, index: index) {
+ devices.append(device)
+ }
+ IOObjectRelease(service)
+ service = IOIteratorNext(iterator)
+ index += 1
+ }
+ return devices
+ }
+
+ private func copyProperties(for service: io_registry_entry_t) -> [String: Any]? {
+ var properties: Unmanaged?
+ guard IORegistryEntryCreateCFProperties(service, &properties, kCFAllocatorDefault, 0) == KERN_SUCCESS,
+ let dict = properties?.takeRetainedValue() as? [String: Any] else {
+ return nil
+ }
+ return dict
+ }
+
+ private func makeDevice(from dict: [String: Any], index: Int) -> GPUDeviceMetrics? {
+ guard let ioClass = dict["IOClass"] as? String else { return nil }
+ let stats = dict["PerformanceStatistics"] as? [String: Any] ?? [:]
+ let vendor = vendorName(from: ioClass) ?? (dict["vendor"] as? String)
+ let model = sanitizedModel(primary: stats["model"] as? String,
+ secondary: dict["model"] as? String,
+ vendorFallback: vendor)
+ let id = "\(model)#\(index + 1)"
+ let utilization = percentValue(for: ["Device Utilization %", "GPU Activity(%)"], in: stats)
+ let renderUtilization = percentValue(for: ["Renderer Utilization %"], in: stats)
+ let tilerUtilization = percentValue(for: ["Tiler Utilization %"], in: stats)
+ let temperature = numericValue(for: ["Temperature(C)", "temperature"], in: stats)
+ let fanSpeed = intValue(for: ["Fan Speed(%)"], in: stats)
+ let coreClock = intValue(for: ["Core Clock(MHz)"], in: stats)
+ let memoryClock = intValue(for: ["Memory Clock(MHz)"], in: stats)
+ let cores = (dict["gpu-core-count"] as? NSNumber)?.intValue ?? (dict["Cores"] as? Int)
+ let isActive = isAcceleratorActive(from: dict)
+ return GPUDeviceMetrics(
+ id: id,
+ vendor: vendor,
+ model: model,
+ isActive: isActive,
+ utilization: utilization,
+ renderUtilization: renderUtilization,
+ tilerUtilization: tilerUtilization,
+ temperature: temperature,
+ fanSpeed: fanSpeed,
+ coreClock: coreClock,
+ memoryClock: memoryClock,
+ cores: cores
+ )
+ }
+
+ private func vendorName(from ioClass: String) -> String? {
+ let value = ioClass.lowercased()
+ if value.contains("nvidia") {
+ return "NVIDIA"
+ } else if value.contains("amd") {
+ return "AMD"
+ } else if value.contains("intel") {
+ return "Intel"
+ } else if value.contains("agx") || value.contains("apple") {
+ return "Apple"
+ }
+ return nil
+ }
+
+ private func sanitizedModel(primary: String?, secondary: String?, vendorFallback: String?) -> String {
+ let normalizedPrimary = normalizedString(primary)
+ if let normalizedPrimary, !normalizedPrimary.isEmpty {
+ return normalizedPrimary
+ }
+ let normalizedSecondary = normalizedString(secondary)
+ if let normalizedSecondary, !normalizedSecondary.isEmpty {
+ return normalizedSecondary
+ }
+ if let vendorFallback, !vendorFallback.isEmpty {
+ return "\(vendorFallback) Graphics"
+ }
+ return "GPU"
+ }
+
+ private func normalizedString(_ raw: String?) -> String? {
+ guard var value = raw else { return nil }
+ value = value.replacingOccurrences(of: "\0", with: "")
+ value = value.trimmingCharacters(in: .whitespacesAndNewlines)
+ return value.isEmpty ? nil : value
+ }
+
+ private func percentValue(for keys: [String], in dict: [String: Any]) -> Double? {
+ for key in keys {
+ if let number = dict[key] as? NSNumber {
+ return clampPercent(number.doubleValue)
+ }
+ if let value = dict[key] as? Double {
+ return clampPercent(value)
+ }
+ if let value = dict[key] as? Int {
+ return clampPercent(Double(value))
+ }
+ }
+ return nil
+ }
+
+ private func numericValue(for keys: [String], in dict: [String: Any]) -> Double? {
+ for key in keys {
+ if let number = dict[key] as? NSNumber {
+ return number.doubleValue
+ }
+ if let value = dict[key] as? Double {
+ return value
+ }
+ if let value = dict[key] as? Int {
+ return Double(value)
+ }
+ }
+ return nil
+ }
+
+ private func intValue(for keys: [String], in dict: [String: Any]) -> Int? {
+ for key in keys {
+ if let number = dict[key] as? NSNumber {
+ return number.intValue
+ }
+ if let value = dict[key] as? Int {
+ return value
+ }
+ }
+ return nil
+ }
+
+ private func isAcceleratorActive(from dict: [String: Any]) -> Bool {
+ guard let agcInfo = dict["AGCInfo"] as? [String: Any] else {
+ return true
+ }
+ if let poweredOff = agcInfo["poweredOffByAGC"] as? NSNumber {
+ return poweredOff.intValue == 0
+ }
+ if let poweredOff = agcInfo["poweredOffByAGC"] as? Int {
+ return poweredOff == 0
+ }
+ return true
+ }
+
+ private func clampPercent(_ value: Double) -> Double {
+ return min(max(value, 0), 100)
+ }
+}
+
+struct GPUBreakdown: Equatable {
+ let render: Double
+ let compute: Double
+ let video: Double
+ let other: Double
+
+ static let zero = GPUBreakdown(render: 0, compute: 0, video: 0, other: 0)
+
+ var totalUsage: Double {
+ render + compute + video + other
+ }
+}
+
+struct GPUMetricsSnapshot {
+ let usage: Double
+ let breakdown: GPUBreakdown
+ let devices: [GPUDeviceMetrics]
+
+ static let zero = GPUMetricsSnapshot(usage: 0, breakdown: .zero, devices: [])
+}
+
+enum NetworkInterfaceType: String {
+ case wifi
+ case ethernet
+ case loopback
+ case cellular
+ case other
+}
+
+struct NetworkInterfaceMetrics: Identifiable, Equatable {
+ let name: String
+ let displayName: String
+ let type: NetworkInterfaceType
+ let ipv4: String?
+ let ipv6: String?
+ let isActive: Bool
+ let currentDownload: Double
+ let currentUpload: Double
+ let totalDownloaded: Double
+ let totalUploaded: Double
+
+ var id: String { name }
+}
+
+struct DiskDeviceMetrics: Identifiable, Equatable {
+ let id: String
+ let name: String
+ let path: URL
+ let totalBytes: UInt64
+ let freeBytes: UInt64
+ let isRoot: Bool
+ let isRemovable: Bool
+
+ var usedBytes: UInt64 {
+ totalBytes > freeBytes ? totalBytes - freeBytes : 0
+ }
+
+ var usagePercentage: Double {
+ guard totalBytes > 0 else { return 0 }
+ return Double(usedBytes) / Double(totalBytes) * 100
+ }
+}
+
+struct GPUDeviceMetrics: Identifiable, Equatable {
+ let id: String
+ let vendor: String?
+ let model: String
+ let isActive: Bool
+ let utilization: Double?
+ let renderUtilization: Double?
+ let tilerUtilization: Double?
+ let temperature: Double?
+ let fanSpeed: Int?
+ let coreClock: Int?
+ let memoryClock: Int?
+ let cores: Int?
+
+ var formattedVendorModel: String {
+ if let vendor {
+ return vendor == model ? model : "\(vendor) \(model)".trimmingCharacters(in: .whitespaces)
+ }
+ return model
+ }
+
+ var utilizationText: String {
+ guard let utilization else { return "—" }
+ return StatsFormatting.percentage(utilization)
+ }
+
+ var temperatureText: String {
+ guard let temperature else { return "—" }
+ return String(format: "%.0f°C", temperature)
+ }
+}
+
+struct NetworkTotals: Equatable {
+ var downloadedMB: Double
+ var uploadedMB: Double
+
+ static let zero = NetworkTotals(downloadedMB: 0, uploadedMB: 0)
+}
+
+struct DiskTotals: Equatable {
+ var readMB: Double
+ var writtenMB: Double
+
+ static let zero = DiskTotals(readMB: 0, writtenMB: 0)
+}
+
class StatsManager: ObservableObject {
// MARK: - Properties
static let shared = StatsManager()
@@ -27,6 +374,20 @@ class StatsManager: ObservableObject {
@Published var diskRead: Double = 0.0 // MB/s
@Published var diskWrite: Double = 0.0 // MB/s
@Published var lastUpdated: Date = .distantPast
+ @Published private(set) var cpuBreakdown: CPULoadBreakdown = .zero
+ @Published private(set) var cpuLoadAverage: LoadAverage = .zero
+ @Published private(set) var cpuCoreUsage: [CPUCoreUsage] = []
+ @Published private(set) var cpuUptime: TimeInterval = 0
+ @Published private(set) var topCPUProcesses: [ProcessStats] = []
+ @Published private(set) var cpuTemperature: CPUTemperatureMetrics = CPUTemperatureMetrics(celsius: nil)
+ @Published private(set) var cpuFrequency: CPUFrequencyMetrics?
+ @Published private(set) var memoryBreakdown: MemoryBreakdown = .zero
+ @Published private(set) var gpuBreakdown: GPUBreakdown = .zero
+ @Published private(set) var gpuDevices: [GPUDeviceMetrics] = []
+ @Published private(set) var networkTotals: NetworkTotals = .zero
+ @Published private(set) var diskTotals: DiskTotals = .zero
+ @Published private(set) var networkInterfaces: [NetworkInterfaceMetrics] = []
+ @Published private(set) var diskDevices: [DiskDeviceMetrics] = []
// Historical data for graphs (last 30 data points)
@Published var cpuHistory: [Double] = []
@@ -41,6 +402,19 @@ class StatsManager: ObservableObject {
private var delayedStopTimer: Timer?
private var delayedStartTimer: Timer?
private let maxHistoryPoints = 30
+ private let totalPhysicalMemory: UInt64 = {
+ var stats = host_basic_info()
+ var count = mach_msg_type_number_t(MemoryLayout.size / MemoryLayout.size)
+ let result = withUnsafeMutablePointer(to: &stats) {
+ $0.withMemoryRebound(to: integer_t.self, capacity: Int(count)) {
+ host_info(mach_host_self(), HOST_BASIC_INFO, $0, &count)
+ }
+ }
+ if result == KERN_SUCCESS {
+ return UInt64(stats.max_mem)
+ }
+ return UInt64(ProcessInfo.processInfo.physicalMemory)
+ }()
// Smart monitoring state
private var shouldMonitorForStats: Bool = false
@@ -53,6 +427,21 @@ class StatsManager: ObservableObject {
// Disk monitoring state
private var previousDiskStats: (bytesRead: UInt64, bytesWritten: UInt64) = (0, 0)
+ private var previousCPULoadInfo: host_cpu_load_info?
+ private var previousCpuInfo: processor_info_array_t?
+ private var previousCpuInfoCount: mach_msg_type_number_t = 0
+ private var processorCount: natural_t = 0
+ private var previousInterfaceCounters: [String: (bytesIn: UInt64, bytesOut: UInt64)] = [:]
+ private var interfaceTotals: [String: NetworkTotals] = [:]
+
+ // Per-process monitoring cache (updated via ps sampling)
+ private var cachedProcessStats: [ProcessStats] = []
+ private var lastProcessStatsUpdate: Date = .distantPast
+ private let processStatsUpdateInterval: TimeInterval = 2.0
+ private let maxProcessEntries: Int = 20
+ private var isProcessRefreshInFlight = false
+ private let gpuCollector = GPUInfoCollector()
+ private let cpuSensorCollector = CPUSensorCollector()
// MARK: - Initialization
private init() {
@@ -133,6 +522,8 @@ class StatsManager: ObservableObject {
isMonitoring = true
lastUpdated = Date()
+ networkTotals = .zero
+ diskTotals = .zero
// Start periodic monitoring (every 1 second)
monitoringTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
@@ -157,14 +548,37 @@ class StatsManager: ObservableObject {
isMonitoring = false
print("StatsManager: Monitoring stopped")
+ cachedProcessStats.removeAll()
+ lastProcessStatsUpdate = .distantPast
+ isProcessRefreshInFlight = false
+ previousCPULoadInfo = nil
+ if let previousCpuInfo {
+ let size = vm_size_t(previousCpuInfoCount) * vm_size_t(MemoryLayout.size)
+ vm_deallocate(mach_task_self_, vm_address_t(bitPattern: previousCpuInfo), size)
+ }
+ previousCpuInfo = nil
+ previousCpuInfoCount = 0
+ processorCount = 0
+ topCPUProcesses = []
+ cpuCoreUsage = []
+ networkInterfaces = []
+ diskDevices = []
+ previousInterfaceCounters.removeAll()
+ interfaceTotals.removeAll()
+ cpuTemperature = CPUTemperatureMetrics(celsius: nil)
+ cpuFrequency = nil
}
// MARK: - Private Methods
@MainActor
private func updateSystemStats() {
- let newCpuUsage = getCPUUsage()
- let newMemoryUsage = getMemoryUsage()
- let newGpuUsage = getGPUUsage()
+ 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()
@@ -173,12 +587,14 @@ class StatsManager: ObservableObject {
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) {
- let bytesDownloaded = currentNetworkStats.bytesIn > previousNetworkStats.bytesIn ?
+ bytesDownloaded = currentNetworkStats.bytesIn > previousNetworkStats.bytesIn ?
currentNetworkStats.bytesIn - previousNetworkStats.bytesIn : 0
- let 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
@@ -189,27 +605,66 @@ class StatsManager: ObservableObject {
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) {
- let bytesRead = currentDiskStats.bytesRead > previousDiskStats.bytesRead ?
+ bytesRead = currentDiskStats.bytesRead > previousDiskStats.bytesRead ?
currentDiskStats.bytesRead - previousDiskStats.bytesRead : 0
- let bytesWritten = currentDiskStats.bytesWritten > previousDiskStats.bytesWritten ?
+ 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
+ if bytesDownloaded > 0 {
+ var updatedTotals = networkTotals
+ updatedTotals.downloadedMB += Double(bytesDownloaded) / 1_048_576
+ networkTotals = updatedTotals
+ }
+ if bytesUploaded > 0 {
+ var updatedTotals = networkTotals
+ 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
cpuUsage = newCpuUsage
- memoryUsage = newMemoryUsage
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)
updateHistory(value: newCpuUsage, history: &cpuHistory)
@@ -224,6 +679,9 @@ class StatsManager: ObservableObject {
previousNetworkStats = currentNetworkStats
previousDiskStats = currentDiskStats
previousTimestamp = currentTime
+ networkInterfaces = collectNetworkInterfaces(deltaTime: timeInterval)
+ diskDevices = collectDiskDevices()
+ refreshProcessStatsIfNeeded(force: true)
}
private func updateHistory(value: Double, history: inout [Double]) {
@@ -236,32 +694,79 @@ class StatsManager: ObservableObject {
// MARK: - System Monitoring Functions
- private func getCPUUsage() -> Double {
- // Simplified CPU usage monitoring using host_statistics
- var hostStats = host_cpu_load_info()
+ private func getCPULoadBreakdown() -> CPULoadBreakdown {
+ var info = host_cpu_load_info()
var count = mach_msg_type_number_t(MemoryLayout.size / MemoryLayout.size)
-
- let result = withUnsafeMutablePointer(to: &hostStats) {
+
+ let result = withUnsafeMutablePointer(to: &info) {
$0.withMemoryRebound(to: integer_t.self, capacity: Int(count)) {
host_statistics(mach_host_self(), HOST_CPU_LOAD_INFO, $0, &count)
}
}
-
+
guard result == KERN_SUCCESS else {
- return 0.0
+ return cpuBreakdown
}
-
- let totalTicks = hostStats.cpu_ticks.0 + hostStats.cpu_ticks.1 +
- hostStats.cpu_ticks.2 + hostStats.cpu_ticks.3
- guard totalTicks > 0 else { return 0.0 }
-
- let idleTicks = hostStats.cpu_ticks.2 // CPU_STATE_IDLE
- let usage = Double(totalTicks - idleTicks) / Double(totalTicks) * 100.0
- return min(100.0, max(0.0, usage))
+
+ let clamped: (Double) -> Double = { value in
+ return min(max(value, 0), 100)
+ }
+
+ let breakdown: CPULoadBreakdown
+
+ if let previous = previousCPULoadInfo {
+ let userDiff = Double(info.cpu_ticks.0 - previous.cpu_ticks.0)
+ let systemDiff = Double(info.cpu_ticks.1 - previous.cpu_ticks.1)
+ let idleDiff = Double(info.cpu_ticks.2 - previous.cpu_ticks.2)
+ let niceDiff = Double(info.cpu_ticks.3 - previous.cpu_ticks.3)
+ let total = userDiff + systemDiff + idleDiff + niceDiff
+
+ if total > 0 {
+ let userPercent = ((userDiff + niceDiff) / total) * 100
+ let systemPercent = (systemDiff / total) * 100
+ let idlePercent = (idleDiff / total) * 100
+ breakdown = CPULoadBreakdown(
+ user: clamped(userPercent),
+ system: clamped(systemPercent),
+ idle: clamped(idlePercent)
+ )
+ } else {
+ breakdown = cpuBreakdown
+ }
+ } else {
+ let totalTicks = Double(info.cpu_ticks.0 + info.cpu_ticks.1 + info.cpu_ticks.2 + info.cpu_ticks.3)
+ if totalTicks > 0 {
+ let userPercent = ((Double(info.cpu_ticks.0) + Double(info.cpu_ticks.3)) / totalTicks) * 100
+ let systemPercent = (Double(info.cpu_ticks.1) / totalTicks) * 100
+ let idlePercent = (Double(info.cpu_ticks.2) / totalTicks) * 100
+ breakdown = CPULoadBreakdown(
+ user: clamped(userPercent),
+ system: clamped(systemPercent),
+ idle: clamped(idlePercent)
+ )
+ } else {
+ breakdown = cpuBreakdown
+ }
+ }
+
+ previousCPULoadInfo = info
+ return breakdown
+ }
+
+ private func getLoadAverage() -> LoadAverage {
+ var loads = [Double](repeating: 0, count: 3)
+ let result = loads.withUnsafeMutableBufferPointer { buffer -> Int32 in
+ guard let baseAddress = buffer.baseAddress else { return 0 }
+ return getloadavg(baseAddress, 3)
+ }
+ guard result == 3 else {
+ return cpuLoadAverage
+ }
+ return LoadAverage(oneMinute: loads[0], fiveMinutes: loads[1], fifteenMinutes: loads[2])
}
- private func getMemoryUsage() -> Double {
- // Simplified memory usage monitoring using host_statistics
+ private func getMemorySnapshot() -> (usage: Double, breakdown: MemoryBreakdown) {
+ // Memory usage monitoring using host_statistics64
var vmStatistics = vm_statistics64()
var size = mach_msg_type_number_t(MemoryLayout.size / MemoryLayout.size)
@@ -272,32 +777,386 @@ class StatsManager: ObservableObject {
}
guard vmResult == KERN_SUCCESS else {
- return 0.0
+ return (memoryUsage, memoryBreakdown)
}
let pageSize = UInt64(vm_kernel_page_size)
- let totalMemory = (UInt64(vmStatistics.free_count) + UInt64(vmStatistics.active_count) +
- UInt64(vmStatistics.inactive_count) + UInt64(vmStatistics.wire_count)) * pageSize
- let usedMemory = (UInt64(vmStatistics.active_count) + UInt64(vmStatistics.inactive_count) +
- UInt64(vmStatistics.wire_count)) * pageSize
+ let freeBytes = UInt64(vmStatistics.free_count) * pageSize
+ let speculativeBytes = UInt64(vmStatistics.speculative_count) * pageSize
+ let activeBytes = UInt64(vmStatistics.active_count) * pageSize
+ let inactiveBytes = UInt64(vmStatistics.inactive_count) * pageSize
+ let wiredBytes = UInt64(vmStatistics.wire_count) * pageSize
+ let compressedBytes = UInt64(vmStatistics.compressor_page_count) * pageSize
+ let purgeableBytes = UInt64(vmStatistics.purgeable_count) * pageSize
+ let externalBytes = UInt64(vmStatistics.external_page_count) * pageSize
- guard totalMemory > 0 else { return 0.0 }
+ let totalMemoryBytes: UInt64
+ if totalPhysicalMemory > 0 {
+ totalMemoryBytes = totalPhysicalMemory
+ } else {
+ totalMemoryBytes = freeBytes + speculativeBytes + activeBytes + inactiveBytes + wiredBytes + compressedBytes
+ }
+ guard totalMemoryBytes > 0 else {
+ return (0.0, .zero)
+ }
- let usage = Double(usedMemory) / Double(totalMemory) * 100.0
- return min(100.0, max(0.0, usage))
+ let usedWithoutCache = activeBytes + inactiveBytes + speculativeBytes + wiredBytes + compressedBytes
+ let cacheBytes = purgeableBytes + externalBytes
+ let usedBytes = usedWithoutCache > cacheBytes ? usedWithoutCache - cacheBytes : 0
+ let clampedUsedBytes = min(usedBytes, totalMemoryBytes)
+ let freeComputedBytes = totalMemoryBytes > clampedUsedBytes ? totalMemoryBytes - clampedUsedBytes : 0
+ let nonAppBytes = wiredBytes + compressedBytes
+ let appBytes = clampedUsedBytes > nonAppBytes ? clampedUsedBytes - nonAppBytes : 0
+
+ var pressureLevelRaw: Int32 = 0
+ var pressureSize = MemoryLayout.size
+ sysctlbyname("kern.memorystatus_vm_pressure_level", &pressureLevelRaw, &pressureSize, nil, 0)
+ let pressureLevel: MemoryPressureLevel
+ switch pressureLevelRaw {
+ case 2:
+ pressureLevel = .warning
+ case 4:
+ pressureLevel = .critical
+ default:
+ pressureLevel = .normal
+ }
+ let pressure = MemoryPressure(rawValue: Int(pressureLevelRaw), level: pressureLevel)
+
+ var swapUsage = xsw_usage()
+ var swapSize = MemoryLayout.size
+ sysctlbyname("vm.swapusage", &swapUsage, &swapSize, nil, 0)
+ let swapInfo = MemorySwap(
+ totalBytes: UInt64(swapUsage.xsu_total),
+ usedBytes: UInt64(swapUsage.xsu_used),
+ freeBytes: UInt64(swapUsage.xsu_avail)
+ )
+
+ let usage = Double(clampedUsedBytes) / Double(totalMemoryBytes) * 100.0
+ let breakdown = MemoryBreakdown(
+ totalBytes: totalMemoryBytes,
+ usedBytes: clampedUsedBytes,
+ freeBytes: freeComputedBytes,
+ wiredBytes: wiredBytes,
+ activeBytes: activeBytes,
+ inactiveBytes: inactiveBytes,
+ compressedBytes: compressedBytes,
+ appBytes: appBytes,
+ cacheBytes: cacheBytes,
+ swap: swapInfo,
+ pressure: pressure
+ )
+
+ return (min(100.0, max(0.0, usage)), breakdown)
}
- private func getGPUUsage() -> Double {
- // GPU usage monitoring is complex on macOS and requires private APIs
- // For now, we'll provide a placeholder that simulates GPU usage
- // In a production app, this would need Metal Performance Shaders or IOKit GPU monitoring
-
- // Simulate realistic GPU usage based on system load
- let baseUsage = Double.random(in: 5...15)
- let variance = Double.random(in: -5...25)
- let simulatedUsage = baseUsage + variance
-
- return min(100.0, max(0.0, simulatedUsage))
+ 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
+ var numCPUsU: natural_t = 0
+ let result = host_processor_info(mach_host_self(), PROCESSOR_CPU_LOAD_INFO, &numCPUsU, &cpuInfo, &numCpuInfo)
+ guard result == KERN_SUCCESS, let cpuInfo else {
+ return cpuCoreUsage
+ }
+ let cpuCount = Int(numCPUsU)
+ var usages: [CPUCoreUsage] = []
+ usages.reserveCapacity(cpuCount)
+ let cpuStateMax = Int(CPU_STATE_MAX)
+ if let previousCpuInfo {
+ for cpu in 0.. 0 ? Double(inUse) / Double(total) : 0
+ usages.append(CPUCoreUsage(id: cpu, usage: max(0, min(percent * 100, 100))))
+ }
+ } else {
+ for cpu in 0.. 0 ? Double(inUse) / Double(total) : 0
+ usages.append(CPUCoreUsage(id: cpu, usage: max(0, min(percent * 100, 100))))
+ }
+ }
+ if let previousCpuInfo {
+ let size = vm_size_t(previousCpuInfoCount) * vm_size_t(MemoryLayout.size)
+ vm_deallocate(mach_task_self_, vm_address_t(bitPattern: previousCpuInfo), size)
+ }
+ previousCpuInfo = cpuInfo
+ previousCpuInfoCount = numCpuInfo
+ processorCount = numCPUsU
+ return usages
+ }
+
+ private func collectNetworkInterfaces(deltaTime: TimeInterval) -> [NetworkInterfaceMetrics] {
+ var interfacesPointer: UnsafeMutablePointer? = nil
+ guard getifaddrs(&interfacesPointer) == 0, let startPointer = interfacesPointer else {
+ return networkInterfaces
+ }
+ 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 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)
+ if let addr = interface.ifa_addr {
+ switch Int32(addr.pointee.sa_family) {
+ case AF_LINK:
+ if let data = interface.ifa_data?.assumingMemoryBound(to: if_data.self) {
+ accumulator.bytesIn = UInt64(data.pointee.ifi_ibytes)
+ accumulator.bytesOut = UInt64(data.pointee.ifi_obytes)
+ }
+ case AF_INET:
+ accumulator.ipv4 = stringFromSockaddr(addr)
+ case AF_INET6:
+ accumulator.ipv6 = stringFromSockaddr(addr)
+ default:
+ break
+ }
+ }
+ accumulators[name] = accumulator
+ pointer = interface.ifa_next
+ }
+ var results: [NetworkInterfaceMetrics] = []
+ var updatedCounters: [String: (bytesIn: UInt64, bytesOut: UInt64)] = [:]
+ for accumulator in accumulators.values {
+ 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
+ let deltaOut = accumulator.bytesOut >= previous.bytesOut ? accumulator.bytesOut - previous.bytesOut : 0
+ let speedDivider = max(deltaTime, 0.001)
+ let downloadSpeed = Double(deltaIn) / speedDivider / 1_048_576
+ let uploadSpeed = Double(deltaOut) / speedDivider / 1_048_576
+ var totals = interfaceTotals[accumulator.name] ?? .zero
+ totals.downloadedMB += Double(deltaIn) / 1_048_576
+ totals.uploadedMB += Double(deltaOut) / 1_048_576
+ interfaceTotals[accumulator.name] = totals
+ let metrics = NetworkInterfaceMetrics(
+ name: accumulator.name,
+ displayName: interfaceDisplayName(for: accumulator.name),
+ type: interfaceType(for: accumulator.name),
+ ipv4: accumulator.ipv4,
+ ipv6: accumulator.ipv6,
+ isActive: interfaceIsActive(flags: accumulator.flags),
+ currentDownload: max(downloadSpeed, 0),
+ currentUpload: max(uploadSpeed, 0),
+ totalDownloaded: totals.downloadedMB,
+ totalUploaded: totals.uploadedMB
+ )
+ results.append(metrics)
+ updatedCounters[accumulator.name] = (accumulator.bytesIn, accumulator.bytesOut)
+ }
+ previousInterfaceCounters = updatedCounters
+ return results.sorted { lhs, rhs in
+ if lhs.type == rhs.type {
+ return lhs.displayName.localizedCaseInsensitiveCompare(rhs.displayName) == .orderedAscending
+ }
+ return networkTypeSortOrder(lhs.type) < networkTypeSortOrder(rhs.type)
+ }
+ }
+
+ 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)
+ switch family {
+ case AF_INET:
+ var addr = addressPointer.withMemoryRebound(to: sockaddr_in.self, capacity: 1) { $0.pointee.sin_addr }
+ var buffer = [CChar](repeating: 0, count: Int(INET_ADDRSTRLEN))
+ guard inet_ntop(AF_INET, &addr, &buffer, socklen_t(INET_ADDRSTRLEN)) != nil else { return nil }
+ return String(cString: buffer)
+ case AF_INET6:
+ var addr = addressPointer.withMemoryRebound(to: sockaddr_in6.self, capacity: 1) { $0.pointee.sin6_addr }
+ var buffer = [CChar](repeating: 0, count: Int(INET6_ADDRSTRLEN))
+ guard inet_ntop(AF_INET6, &addr, &buffer, socklen_t(INET6_ADDRSTRLEN)) != nil else { return nil }
+ return String(cString: buffer)
+ default:
+ return nil
+ }
+ }
+
+ private func shouldIncludeInterface(name: String) -> Bool {
+ guard !name.hasPrefix("lo"),
+ !name.hasPrefix("gif"),
+ !name.hasPrefix("stf"),
+ !name.hasPrefix("awdl"),
+ !name.hasPrefix("llw"),
+ !name.hasPrefix("utun"),
+ !name.hasPrefix("p2p") else {
+ return false
+ }
+ return true
+ }
+
+ private func interfaceIsActive(flags: UInt32) -> Bool {
+ let isUp = (flags & UInt32(IFF_UP)) == UInt32(IFF_UP)
+ let isRunning = (flags & UInt32(IFF_RUNNING)) == UInt32(IFF_RUNNING)
+ return isUp && isRunning
+ }
+
+ private func interfaceDisplayName(for name: String) -> String {
+ if name.hasPrefix("en") {
+ return name == "en0" ? "Wi-Fi" : "Ethernet"
+ }
+ if name.hasPrefix("bridge") {
+ return "Bridge"
+ }
+ if name.hasPrefix("pd") {
+ return "USB"
+ }
+ if name.hasPrefix("vmnet") {
+ return "Virtual"
+ }
+ if name.hasPrefix("ap") {
+ return "Hotspot"
+ }
+ if name.hasPrefix("awdl") {
+ return "AirDrop"
+ }
+ return name.uppercased()
+ }
+
+ private func interfaceType(for name: String) -> NetworkInterfaceType {
+ if name.hasPrefix("en") {
+ if name == "en0" {
+ return .wifi
+ }
+ return .ethernet
+ }
+ if name.hasPrefix("bridge") || name.hasPrefix("vmnet") {
+ return .ethernet
+ }
+ if name.hasPrefix("ap") {
+ return .wifi
+ }
+ if name.hasPrefix("pdp_ip") {
+ return .cellular
+ }
+ if name.hasPrefix("lo") {
+ return .loopback
+ }
+ return .other
+ }
+
+ private func networkTypeSortOrder(_ type: NetworkInterfaceType) -> Int {
+ switch type {
+ case .wifi:
+ return 0
+ case .ethernet:
+ return 1
+ case .cellular:
+ return 2
+ case .other:
+ return 3
+ case .loopback:
+ return 4
+ }
}
private func getNetworkStats() -> (bytesIn: UInt64, bytesOut: UInt64) {
@@ -351,7 +1210,7 @@ class StatsManager: ObservableObject {
let matchingDict = IOServiceMatching("IOStorage")
var iterator: io_iterator_t = 0
- let result = IOServiceGetMatchingServices(kIOMasterPortDefault, matchingDict, &iterator)
+ let result = IOServiceGetMatchingServices(kIOMainPortDefault, matchingDict, &iterator)
guard result == KERN_SUCCESS else {
return (totalBytesRead, totalBytesWritten)
@@ -402,11 +1261,11 @@ class StatsManager: ObservableObject {
}
var networkDownloadString: String {
- return String(format: "%.1f MB/s", networkDownload)
+ StatsFormatting.throughput(networkDownload)
}
var networkUploadString: String {
- return String(format: "%.1f MB/s", networkUpload)
+ StatsFormatting.throughput(networkUpload)
}
var diskReadString: String {
@@ -459,59 +1318,135 @@ class StatsManager: ObservableObject {
}
// MARK: - Process Monitoring Methods
+ @MainActor
func getProcessesRankedByCPU() -> [ProcessStats] {
- return getRunningProcesses().sorted { $0.cpuUsage > $1.cpuUsage }
+ refreshProcessStatsIfNeeded()
+ return cachedProcessStats.sorted { $0.cpuUsage > $1.cpuUsage }
}
+ @MainActor
func getProcessesRankedByMemory() -> [ProcessStats] {
- return getRunningProcesses().sorted { $0.memoryUsage > $1.memoryUsage }
+ refreshProcessStatsIfNeeded()
+ return cachedProcessStats.sorted { $0.memoryUsage > $1.memoryUsage }
}
+ @MainActor
func getProcessesRankedByGPU() -> [ProcessStats] {
// For now, rank by CPU usage as GPU per-process stats are complex to obtain
- return getRunningProcesses().sorted { $0.cpuUsage > $1.cpuUsage }
+ refreshProcessStatsIfNeeded()
+ return cachedProcessStats.sorted { $0.cpuUsage > $1.cpuUsage }
}
- private func getRunningProcesses() -> [ProcessStats] {
- var processes: [ProcessStats] = []
-
- // Get all running applications from NSWorkspace
- let runningApps = NSWorkspace.shared.runningApplications
-
- for app in runningApps {
- // Skip system processes and processes without bundle identifier
- guard let bundleId = app.bundleIdentifier,
- !bundleId.hasPrefix("com.apple.") || bundleId == "com.apple.finder" else {
- continue
+ @MainActor
+ private func refreshProcessStatsIfNeeded(force: Bool = false) {
+ let now = Date()
+ guard force || now.timeIntervalSince(lastProcessStatsUpdate) >= processStatsUpdateInterval else { return }
+ guard !isProcessRefreshInFlight else { return }
+
+ isProcessRefreshInFlight = true
+
+ Task.detached { [weak self] in
+ guard let self else { return }
+ let processes = StatsManager.collectTopProcesses(limit: self.maxProcessEntries)
+ await MainActor.run {
+ self.cachedProcessStats = processes
+ self.lastProcessStatsUpdate = Date()
+ self.isProcessRefreshInFlight = false
+ self.topCPUProcesses = processes
}
-
- let pid = app.processIdentifier
- var cpuUsage: Double = 0.0
- var memoryUsage: UInt64 = 0
-
- // Get CPU and memory usage using proc_pidinfo
- var taskInfo = proc_taskinfo()
- let result = proc_pidinfo(pid, PROC_PIDTASKINFO, 0, &taskInfo, Int32(MemoryLayout.size))
-
- if result == MemoryLayout.size {
- // Calculate CPU usage (basic approximation)
- cpuUsage = Double(taskInfo.pti_total_user + taskInfo.pti_total_system) / 1000000.0
-
- // Get memory usage
- memoryUsage = taskInfo.pti_resident_size
- }
-
- let processStats = ProcessStats(
+ }
+ }
+
+ private static func collectTopProcesses(limit: Int) -> [ProcessStats] {
+ guard limit > 0 else { return [] }
+
+ let task = Process()
+ task.launchPath = "/bin/ps"
+ task.arguments = ["-Aceo", "pid,pcpu,comm", "-r"]
+
+ let outputPipe = Pipe()
+ let errorPipe = Pipe()
+ task.standardOutput = outputPipe
+ task.standardError = errorPipe
+
+ defer {
+ outputPipe.fileHandleForReading.closeFile()
+ errorPipe.fileHandleForReading.closeFile()
+ }
+
+ do {
+ try task.run()
+ } catch {
+ NSLog("StatsManager: Failed to run ps command: \(error.localizedDescription)")
+ return []
+ }
+
+ let outputData = outputPipe.fileHandleForReading.readDataToEndOfFile()
+ _ = errorPipe.fileHandleForReading.readDataToEndOfFile()
+
+ guard !outputData.isEmpty, let output = String(data: outputData, encoding: .utf8) else {
+ return []
+ }
+
+ var results: [ProcessStats] = []
+
+ let lines = output.split(separator: "\n", omittingEmptySubsequences: true)
+ guard lines.count > 1 else { return [] }
+
+ for line in lines.dropFirst() {
+ guard let parsed = parseProcessLine(String(line)) else { continue }
+ let (pid, cpuUsage, command) = parsed
+ let (displayName, icon) = runningApplicationInfo(for: pid, fallbackCommand: command)
+ let memoryUsage = residentMemory(for: pid)
+
+ let process = ProcessStats(
pid: pid,
- name: app.localizedName ?? bundleId,
+ name: displayName,
cpuUsage: cpuUsage,
memoryUsage: memoryUsage,
- icon: app.icon
+ icon: icon
)
-
- processes.append(processStats)
+
+ results.append(process)
+ if results.count >= limit { break }
}
-
- return processes
+
+ return results
+ }
+
+ private static func parseProcessLine(_ rawLine: String) -> (pid_t, Double, String)? {
+ let trimmed = rawLine.trimmingCharacters(in: .whitespaces)
+ guard !trimmed.isEmpty else { return nil }
+
+ let scanner = Scanner(string: trimmed)
+ scanner.charactersToBeSkipped = .whitespaces
+
+ guard let pidToken = scanner.scanCharacters(from: .decimalDigits),
+ let pidValue = Int32(pidToken) else { return nil }
+
+ let cpuCharacterSet = CharacterSet(charactersIn: "0123456789.,")
+ guard let cpuToken = scanner.scanCharacters(from: cpuCharacterSet) else { return nil }
+ let normalizedCPU = cpuToken.replacingOccurrences(of: ",", with: ".")
+ guard let cpuValue = Double(normalizedCPU), cpuValue.isFinite, cpuValue >= 0 else { return nil }
+ _ = scanner.scanCharacters(from: .whitespaces)
+ let remainingIndex = scanner.currentIndex
+ let command = String(trimmed[remainingIndex...]).trimmingCharacters(in: .whitespaces)
+
+ return (pidValue, cpuValue, command.isEmpty ? "Unknown" : command)
+ }
+
+ private static func runningApplicationInfo(for pid: pid_t, fallbackCommand: String) -> (String, NSImage?) {
+ if let app = NSRunningApplication(processIdentifier: pid) {
+ let name = app.localizedName?.trimmingCharacters(in: .whitespacesAndNewlines)
+ return ((name?.isEmpty ?? true) ? fallbackCommand : name!, app.icon)
+ }
+ return (fallbackCommand, nil)
+ }
+
+ private static func residentMemory(for pid: pid_t) -> UInt64 {
+ var taskInfo = proc_taskinfo()
+ let result = proc_pidinfo(pid, PROC_PIDTASKINFO, 0, &taskInfo, Int32(MemoryLayout.size))
+ guard result == MemoryLayout.size else { return 0 }
+ return taskInfo.pti_resident_size
}
}
diff --git a/DynamicIsland/models/CPUMetrics.swift b/DynamicIsland/models/CPUMetrics.swift
new file mode 100644
index 0000000..7f600f7
--- /dev/null
+++ b/DynamicIsland/models/CPUMetrics.swift
@@ -0,0 +1,39 @@
+//
+// CPUMetrics.swift
+// DynamicIsland
+//
+// Lightweight CPU metric models derived from StatsManager sampling.
+//
+// Created by GitHub Copilot on 18/10/2025.
+//
+
+import Foundation
+
+struct CPULoadBreakdown: Equatable {
+ var user: Double
+ var system: Double
+ var idle: Double
+
+ static let zero = CPULoadBreakdown(user: 0, system: 0, idle: 100)
+
+ var activeUsage: Double {
+ let active = user + system
+ return min(max(active, 0), 100)
+ }
+
+ var normalizedSegments: (user: Double, system: Double, idle: Double) {
+ let total = max(user + system + idle, 0.001)
+ let userFraction = min(max(user / total, 0), 1)
+ let systemFraction = min(max(system / total, 0), 1)
+ let idleFraction = min(max(idle / total, 0), 1)
+ return (userFraction, systemFraction, idleFraction)
+ }
+}
+
+struct LoadAverage: Equatable {
+ var oneMinute: Double
+ var fiveMinutes: Double
+ var fifteenMinutes: Double
+
+ static let zero = LoadAverage(oneMinute: 0, fiveMinutes: 0, fifteenMinutes: 0)
+}
diff --git a/DynamicIsland/utils/AppleHardwareInfo.swift b/DynamicIsland/utils/AppleHardwareInfo.swift
new file mode 100644
index 0000000..c098a6b
--- /dev/null
+++ b/DynamicIsland/utils/AppleHardwareInfo.swift
@@ -0,0 +1,162 @@
+//
+// AppleHardwareInfo.swift
+// DynamicIsland
+//
+// Minimal system introspection helpers derived from the Stats project.
+//
+
+import Foundation
+import IOKit
+import Darwin
+
+enum ApplePlatform: String {
+ case intel
+ case m1
+ case m1Pro
+ case m1Max
+ case m1Ultra
+ case m2
+ case m2Pro
+ case m2Max
+ case m2Ultra
+ case m3
+ case m3Pro
+ case m3Max
+ case m3Ultra
+ case m4
+ case m4Pro
+ case m4Max
+ case m4Ultra
+}
+
+struct CPUClusterFrequencies {
+ let eCoreFrequencies: [Int32]
+ let pCoreFrequencies: [Int32]
+}
+
+struct CPUClusterCounts {
+ let eCores: Int
+ let pCores: Int
+}
+
+final class AppleHardwareInfo {
+ static let shared = AppleHardwareInfo()
+ let cpuName: String?
+ let platform: ApplePlatform?
+ let logicalCoreCount: Int
+ let clusterCounts: CPUClusterCounts
+ let clusterFrequencies: CPUClusterFrequencies
+
+ private init() {
+ let name = AppleHardwareInfo.fetchCPUName()
+ cpuName = name
+ platform = AppleHardwareInfo.detectPlatform(from: name)
+ logicalCoreCount = AppleHardwareInfo.fetchLogicalCoreCount()
+ clusterCounts = AppleHardwareInfo.fetchClusterCounts()
+ clusterFrequencies = AppleHardwareInfo.fetchClusterFrequencies(cpuName: name ?? "")
+ }
+
+ private static func fetchCPUName() -> String? {
+ var size = 0
+ sysctlbyname("machdep.cpu.brand_string", nil, &size, nil, 0)
+ guard size > 0 else { return nil }
+ var buffer = [CChar](repeating: 0, count: size)
+ let result = sysctlbyname("machdep.cpu.brand_string", &buffer, &size, nil, 0)
+ guard result == 0 else { return nil }
+ return String(cString: buffer)
+ }
+
+ private static func fetchLogicalCoreCount() -> Int {
+ var cores: Int32 = 0
+ var size = MemoryLayout.size
+ let result = sysctlbyname("hw.logicalcpu", &cores, &size, nil, 0)
+ return result == 0 ? Int(cores) : ProcessInfo.processInfo.processorCount
+ }
+
+ private static func detectPlatform(from cpuName: String?) -> ApplePlatform? {
+ guard let name = cpuName?.lowercased() else { return nil }
+ if name.contains("intel") { return .intel }
+ if name.contains("m1") {
+ if name.contains("ultra") { return .m1Ultra }
+ if name.contains("max") { return .m1Max }
+ if name.contains("pro") { return .m1Pro }
+ return .m1
+ }
+ if name.contains("m2") {
+ if name.contains("ultra") { return .m2Ultra }
+ if name.contains("max") { return .m2Max }
+ if name.contains("pro") { return .m2Pro }
+ return .m2
+ }
+ if name.contains("m3") {
+ if name.contains("ultra") { return .m3Ultra }
+ if name.contains("max") { return .m3Max }
+ if name.contains("pro") { return .m3Pro }
+ return .m3
+ }
+ if name.contains("m4") {
+ if name.contains("ultra") { return .m4Ultra }
+ if name.contains("max") { return .m4Max }
+ if name.contains("pro") { return .m4Pro }
+ return .m4
+ }
+ return nil
+ }
+
+ private static func fetchClusterCounts() -> CPUClusterCounts {
+ var iterator = io_iterator_t()
+ guard IOServiceGetMatchingServices(kIOMasterPortDefault, IOServiceMatching("AppleARMPE"), &iterator) == KERN_SUCCESS else {
+ return CPUClusterCounts(eCores: 0, pCores: 0)
+ }
+ defer { IOObjectRelease(iterator) }
+ var eCoreCount: Int32 = 0
+ var pCoreCount: Int32 = 0
+
+ while case let service = IOIteratorNext(iterator), service != 0 {
+ defer { IOObjectRelease(service) }
+ var children = io_iterator_t()
+ guard IORegistryEntryGetChildIterator(service, kIOServicePlane, &children) == KERN_SUCCESS else { continue }
+ defer { IOObjectRelease(children) }
+
+ while case let child = IOIteratorNext(children), child != 0 {
+ defer { IOObjectRelease(child) }
+ guard let name = di_getIOName(child), name == "cpus", let props = di_getIOProperties(child) else { continue }
+ if let data = props.object(forKey: "e-core-count") as? Data {
+ eCoreCount = data.withUnsafeBytes { $0.load(as: Int32.self) }
+ }
+ if let data = props.object(forKey: "p-core-count") as? Data {
+ pCoreCount = data.withUnsafeBytes { $0.load(as: Int32.self) }
+ }
+ }
+ }
+
+ return CPUClusterCounts(eCores: Int(eCoreCount), pCores: Int(pCoreCount))
+ }
+
+ private static func fetchClusterFrequencies(cpuName: String) -> CPUClusterFrequencies {
+ var iterator = io_iterator_t()
+ guard IOServiceGetMatchingServices(kIOMasterPortDefault, IOServiceMatching("AppleARMIODevice"), &iterator) == KERN_SUCCESS else {
+ return CPUClusterFrequencies(eCoreFrequencies: [], pCoreFrequencies: [])
+ }
+ defer { IOObjectRelease(iterator) }
+
+ let lowerName = cpuName.lowercased()
+ let isM4 = lowerName.contains("m4")
+ var eFreq: [Int32] = []
+ var pFreq: [Int32] = []
+
+ while case let service = IOIteratorNext(iterator), service != 0 {
+ defer { IOObjectRelease(service) }
+ guard let name = di_getIOName(service), name == "pmgr", let props = di_getIOProperties(service) else { continue }
+
+ if let data = props["voltage-states1-sram"] as? Data {
+ eFreq = di_convertCFDataToFrequencies(data, isM4: isM4)
+ }
+ if let data = props["voltage-states5-sram"] as? Data {
+ pFreq = di_convertCFDataToFrequencies(data, isM4: isM4)
+ }
+ }
+
+ return CPUClusterFrequencies(eCoreFrequencies: eFreq, pCoreFrequencies: pFreq)
+ }
+}
diff --git a/DynamicIsland/utils/CPUSensorCollector.swift b/DynamicIsland/utils/CPUSensorCollector.swift
new file mode 100644
index 0000000..427e8fb
--- /dev/null
+++ b/DynamicIsland/utils/CPUSensorCollector.swift
@@ -0,0 +1,237 @@
+//
+// CPUSensorCollector.swift
+// DynamicIsland
+//
+// Ports the Stats project's CPU temperature and frequency readers.
+//
+
+import Foundation
+import IOKit
+
+struct CPUFrequencyMetrics: Equatable {
+ let overallGHz: Double
+ let eCoreGHz: Double?
+ let pCoreGHz: Double?
+ let maxOverallGHz: Double
+ let maxECoreGHz: Double?
+ let maxPCoreGHz: Double?
+}
+
+struct CPUTemperatureMetrics: Equatable {
+ let celsius: Double?
+}
+
+final class CPUSensorCollector {
+ private let hardware = AppleHardwareInfo.shared
+ private var channels: CFMutableDictionary?
+ private var subscription: IOReportSubscriptionRef?
+ private var previousSample: (samples: CFDictionary, time: TimeInterval)?
+
+ init() {
+ setupFrequencyChannel()
+ }
+
+ deinit {
+ if let subscription {
+ IOReportCreateSamples(subscription, nil, nil) // best-effort flush
+ }
+ }
+
+ func readTemperature() -> CPUTemperatureMetrics {
+ let platform = hardware.platform
+ if let value = primaryTemperatureKeyCandidates.compactMap({ SMC.shared.getValue($0) }).first(where: { $0 < 110 }) {
+ return CPUTemperatureMetrics(celsius: value)
+ }
+ let list = temperatureFallbackKeys(for: platform)
+ var total: Double = 0
+ var count: Double = 0
+ for key in list {
+ if let value = SMC.shared.getValue(key), value < 110 {
+ total += value
+ count += 1
+ }
+ }
+ guard total > 0, count > 0 else {
+ return CPUTemperatureMetrics(celsius: nil)
+ }
+ return CPUTemperatureMetrics(celsius: total / count)
+ }
+
+ func readFrequency() -> CPUFrequencyMetrics? {
+ guard let channels, let subscription else { return nil }
+ let timestamp = Date().timeIntervalSince1970
+ guard let currentSample = IOReportCreateSamples(subscription, channels, nil)?.takeRetainedValue() else {
+ return nil
+ }
+ defer {
+ previousSample = (currentSample, timestamp)
+ }
+ guard let previous = previousSample,
+ let diff = IOReportCreateSamplesDelta(previous.samples, currentSample, nil)?.takeRetainedValue() else {
+ return nil
+ }
+
+ let samples = collectIOSamples(from: diff)
+ let eFrequencies = hardware.clusterFrequencies.eCoreFrequencies
+ let pFrequencies = hardware.clusterFrequencies.pCoreFrequencies
+ let eCount = max(hardware.clusterCounts.eCores, 0)
+ let pCount = max(hardware.clusterCounts.pCores, 0)
+
+ var eValues: [Double] = []
+ var pValues: [Double] = []
+
+ for sample in samples where sample.group == "CPU Stats" {
+ if sample.channel.hasPrefix("ECPU") {
+ let value = calculateFrequency(from: sample.delta, freqs: eFrequencies)
+ if value > 0 { eValues.append(value) }
+ } else if sample.channel.hasPrefix("PCPU") {
+ let value = calculateFrequency(from: sample.delta, freqs: pFrequencies)
+ if value > 0 { pValues.append(value) }
+ }
+ }
+
+ let eAverage = eValues.isEmpty ? nil : (eValues.reduce(0, +) / Double(eValues.count))
+ let pAverage = pValues.isEmpty ? nil : (pValues.reduce(0, +) / Double(pValues.count))
+
+ guard eAverage != nil || pAverage != nil else {
+ return nil
+ }
+
+ let overall: Double
+ if let eAverage, let pAverage, (eCount + pCount) > 0 {
+ overall = ((eAverage * Double(eCount)) + (pAverage * Double(pCount))) / Double(eCount + pCount)
+ } else if let eAverage {
+ overall = eAverage
+ } else if let pAverage {
+ overall = pAverage
+ } else {
+ return nil
+ }
+
+ let maxE = eFrequencies.max().map { Double($0) / 1000 }
+ let maxP = pFrequencies.max().map { Double($0) / 1000 }
+ let maxOverall: Double
+ if let maxE, let maxP, (eCount + pCount) > 0 {
+ maxOverall = ((maxE * Double(eCount)) + (maxP * Double(pCount))) / Double(max(eCount + pCount, 1))
+ } else if let maxE {
+ maxOverall = maxE
+ } else if let maxP {
+ maxOverall = maxP
+ } else {
+ maxOverall = (overall / 1000)
+ }
+
+ return CPUFrequencyMetrics(
+ overallGHz: overall / 1000,
+ eCoreGHz: eAverage.map { $0 / 1000 },
+ pCoreGHz: pAverage.map { $0 / 1000 },
+ maxOverallGHz: maxOverall,
+ maxECoreGHz: maxE,
+ maxPCoreGHz: maxP
+ )
+ }
+
+ // MARK: - Private Helpers
+ private func setupFrequencyChannel() {
+ let names = [
+ ("CPU Stats" as CFString, "CPU Complex Performance States" as CFString),
+ ("CPU Stats" as CFString, "CPU Core Performance States" as CFString)
+ ]
+ var mergedChannels: CFMutableDictionary?
+ var list: [CFDictionary] = []
+ for (group, subgroup) in names {
+ if let channel = IOReportCopyChannelsInGroup(group, subgroup, 0, 0, 0)?.takeRetainedValue() {
+ list.append(channel)
+ }
+ }
+ guard let first = list.first else { return }
+ for channel in list.dropFirst() {
+ IOReportMergeChannels(first, channel, nil)
+ }
+ guard let copy = CFDictionaryCreateMutableCopy(kCFAllocatorDefault, CFDictionaryGetCount(first), first) else {
+ return
+ }
+ mergedChannels = copy
+ channels = mergedChannels
+ var dictionary: Unmanaged?
+ subscription = IOReportCreateSubscription(nil, mergedChannels, &dictionary, 0, nil)
+ dictionary?.release()
+ }
+
+ private struct IOSample {
+ let group: String
+ let subGroup: String
+ let channel: String
+ let unit: String
+ let delta: CFDictionary
+ }
+
+ private func collectIOSamples(from data: CFDictionary) -> [IOSample] {
+ let key = "IOReportChannels" as CFString
+ var rawValue: UnsafeRawPointer?
+ let found = withUnsafeMutablePointer(to: &rawValue) { pointer -> Bool in
+ CFDictionaryGetValueIfPresent(data, Unmanaged.passUnretained(key).toOpaque(), pointer)
+ }
+ guard found, let rawValue else { return [] }
+ let array = unsafeBitCast(rawValue, to: CFArray.self)
+ var result: [IOSample] = []
+ for index in 0.. [(label: String, value: Int64)] {
+ let count = IOReportStateGetCount(dict)
+ var result: [(String, Int64)] = []
+ for index in 0.. Double {
+ guard !freqs.isEmpty else { return 0 }
+ let states = getResidencies(dict: dict)
+ guard let offset = states.firstIndex(where: { !$0.label.elementsEqual("IDLE") && !$0.label.elementsEqual("DOWN") && !$0.label.elementsEqual("OFF") }) else {
+ return 0
+ }
+ let usage = states.dropFirst(offset).reduce(0.0) { $0 + Double($1.value) }
+ guard usage > 0 else { return 0 }
+ var total: Double = 0
+ for (index, frequency) in freqs.enumerated() {
+ let stateIndex = offset + index
+ guard states.indices.contains(stateIndex) else { continue }
+ let fraction = Double(states[stateIndex].value) / usage
+ total += fraction * Double(frequency)
+ }
+ return total
+ }
+
+ private var primaryTemperatureKeyCandidates: [String] {
+ ["TC0D", "TC0E", "TC0F", "TC0P", "TC0H"]
+ }
+
+ private func temperatureFallbackKeys(for platform: ApplePlatform?) -> [String] {
+ switch platform {
+ case .m1, .m1Pro, .m1Max, .m1Ultra:
+ return ["Tp09", "Tp0T", "Tp01", "Tp05", "Tp0D", "Tp0H", "Tp0L", "Tp0P", "Tp0X", "Tp0b"]
+ case .m2, .m2Pro, .m2Max, .m2Ultra:
+ return ["Tp1h", "Tp1t", "Tp1p", "Tp1l", "Tp01", "Tp05", "Tp09", "Tp0D", "Tp0X", "Tp0b", "Tp0f", "Tp0j"]
+ case .m3, .m3Pro, .m3Max, .m3Ultra:
+ return ["Te05", "Te0L", "Te0P", "Te0S", "Tf04", "Tf09", "Tf0A", "Tf0B", "Tf0D", "Tf0E", "Tf44", "Tf49", "Tf4A", "Tf4B", "Tf4D", "Tf4E"]
+ case .m4, .m4Pro, .m4Max, .m4Ultra:
+ return ["Te05", "Te09", "Te0H", "Te0S", "Tp01", "Tp05", "Tp09", "Tp0D", "Tp0V", "Tp0Y", "Tp0b", "Tp0e"]
+ default:
+ return ["TC0P", "TC0E", "TC0F", "TC0H", "TC0D"]
+ }
+ }
+}
diff --git a/DynamicIsland/utils/IOKitHelpers.swift b/DynamicIsland/utils/IOKitHelpers.swift
new file mode 100644
index 0000000..44694fe
--- /dev/null
+++ b/DynamicIsland/utils/IOKitHelpers.swift
@@ -0,0 +1,52 @@
+//
+// IOKitHelpers.swift
+// DynamicIsland
+//
+// Helper utilities borrowed from the Stats project for working with IORegistry and CFData blobs.
+//
+
+import Foundation
+import IOKit
+import Darwin
+
+func di_getIOProperties(_ entry: io_registry_entry_t) -> NSDictionary? {
+ var properties: Unmanaged? = nil
+ guard IORegistryEntryCreateCFProperties(entry, &properties, kCFAllocatorDefault, 0) == kIOReturnSuccess else {
+ return nil
+ }
+ defer { properties?.release() }
+ return properties?.takeUnretainedValue()
+}
+
+func di_getIOName(_ entry: io_registry_entry_t) -> String? {
+ let pointer = UnsafeMutablePointer.allocate(capacity: 1)
+ defer { pointer.deallocate() }
+ let result = IORegistryEntryGetName(entry, pointer)
+ guard result == kIOReturnSuccess else {
+ if let message = String(validatingUTF8: mach_error_string(result)) {
+ print("IORegistryEntryGetName error: \(message)")
+ }
+ return nil
+ }
+ return String(cString: UnsafeRawPointer(pointer).assumingMemoryBound(to: CChar.self))
+}
+
+func di_convertCFDataToFrequencies(_ data: Data, isM4: Bool = false) -> [Int32] {
+ guard !data.isEmpty else { return [] }
+ let bytes = [UInt8](data)
+
+ let multiplier: UInt32 = isM4 ? 1_000 : 1_000_000
+ var frequencies: [Int32] = []
+
+ for chunkStart in stride(from: 0, to: bytes.count, by: 8) {
+ let upperBound = min(chunkStart + 8, bytes.count)
+ let chunk = bytes[chunkStart..(_ name: String, as type: T.Type) -> T? {
+ guard let handle = libraryHandle, let pointer = dlsym(handle, name) else {
+ return nil
+ }
+ return unsafeBitCast(pointer, to: T.self)
+ }
+}
+
+func IOReportCopyChannelsInGroup(_ group: CFString?, _ subGroup: CFString?, _ a: UInt64, _ b: UInt64, _ c: UInt64) -> Unmanaged? {
+ typealias Fn = @convention(c) (CFString?, CFString?, UInt64, UInt64, UInt64) -> Unmanaged?
+ guard let fn: Fn = IOReportLoader.symbol("IOReportCopyChannelsInGroup", as: Fn.self) else { return nil }
+ return fn(group, subGroup, a, b, c)
+}
+
+func IOReportMergeChannels(_ a: CFDictionary, _ b: CFDictionary, _ c: CFTypeRef?) {
+ typealias Fn = @convention(c) (CFDictionary, CFDictionary, CFTypeRef?) -> Void
+ guard let fn: Fn = IOReportLoader.symbol("IOReportMergeChannels", as: Fn.self) else { return }
+ fn(a, b, c)
+}
+
+func IOReportCreateSubscription(_ a: UnsafeMutableRawPointer?, _ b: CFMutableDictionary?, _ c: UnsafeMutablePointer?>?, _ d: UInt64, _ e: CFTypeRef?) -> IOReportSubscriptionRef? {
+ typealias Fn = @convention(c) (UnsafeMutableRawPointer?, CFMutableDictionary?, UnsafeMutablePointer?>?, UInt64, CFTypeRef?) -> IOReportSubscriptionRef?
+ guard let fn: Fn = IOReportLoader.symbol("IOReportCreateSubscription", as: Fn.self) else { return nil }
+ return fn(a, b, c, d, e)
+}
+
+func IOReportCreateSamples(_ subscription: IOReportSubscriptionRef?, _ channels: CFMutableDictionary?, _ client: CFTypeRef?) -> Unmanaged? {
+ typealias Fn = @convention(c) (IOReportSubscriptionRef?, CFMutableDictionary?, CFTypeRef?) -> Unmanaged?
+ guard let fn: Fn = IOReportLoader.symbol("IOReportCreateSamples", as: Fn.self) else { return nil }
+ return fn(subscription, channels, client)
+}
+
+func IOReportCreateSamplesDelta(_ previous: CFDictionary, _ current: CFDictionary, _ zone: CFTypeRef?) -> Unmanaged? {
+ typealias Fn = @convention(c) (CFDictionary, CFDictionary, CFTypeRef?) -> Unmanaged?
+ guard let fn: Fn = IOReportLoader.symbol("IOReportCreateSamplesDelta", as: Fn.self) else { return nil }
+ return fn(previous, current, zone)
+}
+
+func IOReportChannelGetGroup(_ dictionary: CFDictionary) -> Unmanaged? {
+ typealias Fn = @convention(c) (CFDictionary) -> Unmanaged?
+ guard let fn: Fn = IOReportLoader.symbol("IOReportChannelGetGroup", as: Fn.self) else { return nil }
+ return fn(dictionary)
+}
+
+func IOReportChannelGetSubGroup(_ dictionary: CFDictionary) -> Unmanaged? {
+ typealias Fn = @convention(c) (CFDictionary) -> Unmanaged?
+ guard let fn: Fn = IOReportLoader.symbol("IOReportChannelGetSubGroup", as: Fn.self) else { return nil }
+ return fn(dictionary)
+}
+
+func IOReportChannelGetChannelName(_ dictionary: CFDictionary) -> Unmanaged? {
+ typealias Fn = @convention(c) (CFDictionary) -> Unmanaged?
+ guard let fn: Fn = IOReportLoader.symbol("IOReportChannelGetChannelName", as: Fn.self) else { return nil }
+ return fn(dictionary)
+}
+
+func IOReportChannelGetUnitLabel(_ dictionary: CFDictionary) -> Unmanaged? {
+ typealias Fn = @convention(c) (CFDictionary) -> Unmanaged?
+ guard let fn: Fn = IOReportLoader.symbol("IOReportChannelGetUnitLabel", as: Fn.self) else { return nil }
+ return fn(dictionary)
+}
+
+func IOReportStateGetCount(_ dictionary: CFDictionary) -> Int32 {
+ typealias Fn = @convention(c) (CFDictionary) -> Int32
+ guard let fn: Fn = IOReportLoader.symbol("IOReportStateGetCount", as: Fn.self) else { return 0 }
+ return fn(dictionary)
+}
+
+func IOReportStateGetNameForIndex(_ dictionary: CFDictionary, _ index: Int32) -> Unmanaged? {
+ typealias Fn = @convention(c) (CFDictionary, Int32) -> Unmanaged?
+ guard let fn: Fn = IOReportLoader.symbol("IOReportStateGetNameForIndex", as: Fn.self) else { return nil }
+ return fn(dictionary, index)
+}
+
+func IOReportStateGetResidency(_ dictionary: CFDictionary, _ index: Int32) -> Int64 {
+ typealias Fn = @convention(c) (CFDictionary, Int32) -> Int64
+ guard let fn: Fn = IOReportLoader.symbol("IOReportStateGetResidency", as: Fn.self) else { return 0 }
+ return fn(dictionary, index)
+}
+
+func IOReportSimpleGetIntegerValue(_ dictionary: CFDictionary, _ index: Int32) -> Int64 {
+ typealias Fn = @convention(c) (CFDictionary, Int32) -> Int64
+ guard let fn: Fn = IOReportLoader.symbol("IOReportSimpleGetIntegerValue", as: Fn.self) else { return 0 }
+ return fn(dictionary, index)
+}
diff --git a/DynamicIsland/utils/SMC.swift b/DynamicIsland/utils/SMC.swift
new file mode 100644
index 0000000..dc42e72
--- /dev/null
+++ b/DynamicIsland/utils/SMC.swift
@@ -0,0 +1,532 @@
+//
+// smc.swift
+// SMC
+//
+// Created by Serhiy Mytrovtsiy on 25/05/2021.
+// Using Swift 5.0.
+// Running on macOS 10.15.
+//
+// Copyright © 2021 Serhiy Mytrovtsiy. All rights reserved.
+//
+
+import Foundation
+import IOKit
+
+internal enum SMCDataType: String {
+ case UI8 = "ui8 "
+ case UI16 = "ui16"
+ case UI32 = "ui32"
+ case SP1E = "sp1e"
+ case SP3C = "sp3c"
+ case SP4B = "sp5b"
+ case SP5A = "sp5a"
+ case SPA5 = "spa5"
+ case SP69 = "sp669"
+ case SP78 = "sp78"
+ case SP87 = "sp87"
+ case SP96 = "sp96"
+ case SPB4 = "spb4"
+ case SPF0 = "spf0"
+ case FLT = "flt "
+ case FPE2 = "fpe2"
+ case FP2E = "fp2e"
+ case FDS = "{fds"
+}
+
+internal enum SMCKeys: UInt8 {
+ case kernelIndex = 2
+ case readBytes = 5
+ case writeBytes = 6
+ case readIndex = 8
+ case readKeyInfo = 9
+ case readPLimit = 11
+ case readVers = 12
+}
+
+public enum FanMode: Int, Codable {
+ case automatic = 0
+ case forced = 1
+}
+
+internal struct SMCKeyData_t {
+ typealias SMCBytes_t = (UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8,
+ UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8,
+ UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8,
+ UInt8, UInt8, UInt8, UInt8, UInt8, UInt8, UInt8,
+ UInt8, UInt8, UInt8, UInt8)
+
+ struct vers_t {
+ var major: CUnsignedChar = 0
+ var minor: CUnsignedChar = 0
+ var build: CUnsignedChar = 0
+ var reserved: CUnsignedChar = 0
+ var release: CUnsignedShort = 0
+ }
+
+ struct LimitData_t {
+ var version: UInt16 = 0
+ var length: UInt16 = 0
+ var cpuPLimit: UInt32 = 0
+ var gpuPLimit: UInt32 = 0
+ var memPLimit: UInt32 = 0
+ }
+
+ struct keyInfo_t {
+ var dataSize: IOByteCount32 = 0
+ var dataType: UInt32 = 0
+ var dataAttributes: UInt8 = 0
+ }
+
+ var key: UInt32 = 0
+ var vers = vers_t()
+ var pLimitData = LimitData_t()
+ var keyInfo = keyInfo_t()
+ var padding: UInt16 = 0
+ var result: UInt8 = 0
+ var status: UInt8 = 0
+ var data8: UInt8 = 0
+ var data32: UInt32 = 0
+ var bytes: SMCBytes_t = (UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0),
+ UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0),
+ UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0),
+ UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0),
+ UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0),
+ UInt8(0), UInt8(0))
+}
+
+internal struct SMCVal_t {
+ var key: String
+ var dataSize: UInt32 = 0
+ var dataType: String = ""
+ var bytes: [UInt8] = Array(repeating: 0, count: 32)
+
+ init(_ key: String) {
+ self.key = key
+ }
+}
+
+extension FourCharCode {
+ init(fromString str: String) {
+ precondition(str.count == 4)
+
+ self = str.utf8.reduce(0) { sum, character in
+ return sum << 8 | UInt32(character)
+ }
+ }
+
+ func toString() -> String {
+ return String(describing: UnicodeScalar(self >> 24 & 0xff)!) +
+ String(describing: UnicodeScalar(self >> 16 & 0xff)!) +
+ String(describing: UnicodeScalar(self >> 8 & 0xff)!) +
+ String(describing: UnicodeScalar(self & 0xff)!)
+ }
+}
+
+extension UInt16 {
+ init(bytes: (UInt8, UInt8)) {
+ self = UInt16(bytes.0) << 8 | UInt16(bytes.1)
+ }
+}
+
+extension UInt32 {
+ init(bytes: (UInt8, UInt8, UInt8, UInt8)) {
+ self = UInt32(bytes.0) << 24 | UInt32(bytes.1) << 16 | UInt32(bytes.2) << 8 | UInt32(bytes.3)
+ }
+}
+
+extension Int {
+ init(fromFPE2 bytes: (UInt8, UInt8)) {
+ self = (Int(bytes.0) << 6) + (Int(bytes.1) >> 2)
+ }
+}
+
+extension Float {
+ init?(_ bytes: [UInt8]) {
+ self = bytes.withUnsafeBytes {
+ return $0.load(fromByteOffset: 0, as: Self.self)
+ }
+ }
+
+ var bytes: [UInt8] {
+ withUnsafeBytes(of: self, Array.init)
+ }
+}
+
+public class SMC {
+ public static let shared = SMC()
+ private var conn: io_connect_t = 0
+
+ public init() {
+ var result: kern_return_t
+ var iterator: io_iterator_t = 0
+ let device: io_object_t
+
+ let matchingDictionary: CFMutableDictionary = IOServiceMatching("AppleSMC")
+ result = IOServiceGetMatchingServices(kIOMasterPortDefault, matchingDictionary, &iterator)
+ if result != kIOReturnSuccess {
+ print("Error IOServiceGetMatchingServices(): " + (String(cString: mach_error_string(result), encoding: String.Encoding.ascii) ?? "unknown error"))
+ return
+ }
+
+ device = IOIteratorNext(iterator)
+ IOObjectRelease(iterator)
+ if device == 0 {
+ print("Error IOIteratorNext(): " + (String(cString: mach_error_string(result), encoding: String.Encoding.ascii) ?? "unknown error"))
+ return
+ }
+
+ result = IOServiceOpen(device, mach_task_self_, 0, &conn)
+ IOObjectRelease(device)
+ if result != kIOReturnSuccess {
+ print("Error IOServiceOpen(): " + (String(cString: mach_error_string(result), encoding: String.Encoding.ascii) ?? "unknown error"))
+ return
+ }
+ }
+
+ deinit {
+ let result = self.close()
+ if result != kIOReturnSuccess {
+ print("error close smc connection: " + (String(cString: mach_error_string(result), encoding: String.Encoding.ascii) ?? "unknown error"))
+ }
+ }
+
+ public func close() -> kern_return_t {
+ return IOServiceClose(conn)
+ }
+
+ public func getValue(_ key: String) -> Double? {
+ var result: kern_return_t = 0
+ var val: SMCVal_t = SMCVal_t(key)
+
+ result = read(&val)
+ if result != kIOReturnSuccess {
+ print("Error read(\(key)): " + (String(cString: mach_error_string(result), encoding: String.Encoding.ascii) ?? "unknown error"))
+ return nil
+ }
+
+ if val.dataSize > 0 {
+ if val.bytes.first(where: { $0 != 0 }) == nil && val.key != "FS! " && val.key != "F0Md" && val.key != "F1Md" {
+ return nil
+ }
+
+ switch val.dataType {
+ case SMCDataType.UI8.rawValue:
+ return Double(val.bytes[0])
+ case SMCDataType.UI16.rawValue:
+ return Double(UInt16(bytes: (val.bytes[0], val.bytes[1])))
+ case SMCDataType.UI32.rawValue:
+ return Double(UInt32(bytes: (val.bytes[0], val.bytes[1], val.bytes[2], val.bytes[3])))
+ case SMCDataType.SP1E.rawValue:
+ let result: Double = Double(UInt16(val.bytes[0]) * 256 + UInt16(val.bytes[1]))
+ return Double(result / 16384)
+ case SMCDataType.SP3C.rawValue:
+ let result: Double = Double(UInt16(val.bytes[0]) * 256 + UInt16(val.bytes[1]))
+ return Double(result / 4096)
+ case SMCDataType.SP4B.rawValue:
+ let result: Double = Double(UInt16(val.bytes[0]) * 256 + UInt16(val.bytes[1]))
+ return Double(result / 2048)
+ case SMCDataType.SP5A.rawValue:
+ let result: Double = Double(UInt16(val.bytes[0]) * 256 + UInt16(val.bytes[1]))
+ return Double(result / 1024)
+ case SMCDataType.SP69.rawValue:
+ let result: Double = Double(UInt16(val.bytes[0]) * 256 + UInt16(val.bytes[1]))
+ return Double(result / 512)
+ case SMCDataType.SP78.rawValue:
+ let intValue: Double = Double(Int(val.bytes[0]) * 256 + Int(val.bytes[1]))
+ return Double(intValue / 256)
+ case SMCDataType.SP87.rawValue:
+ let intValue: Double = Double(Int(val.bytes[0]) * 256 + Int(val.bytes[1]))
+ return Double(intValue / 128)
+ case SMCDataType.SP96.rawValue:
+ let intValue: Double = Double(Int(val.bytes[0]) * 256 + Int(val.bytes[1]))
+ return Double(intValue / 64)
+ case SMCDataType.SPA5.rawValue:
+ let result: Double = Double(UInt16(val.bytes[0]) * 256 + UInt16(val.bytes[1]))
+ return Double(result / 32)
+ case SMCDataType.SPB4.rawValue:
+ let intValue: Double = Double(Int(val.bytes[0]) * 256 + Int(val.bytes[1]))
+ return Double(intValue / 16)
+ case SMCDataType.SPF0.rawValue:
+ let intValue: Double = Double(Int(val.bytes[0]) * 256 + Int(val.bytes[1]))
+ return intValue
+ case SMCDataType.FLT.rawValue:
+ let value: Float? = Float(val.bytes)
+ if value != nil {
+ return Double(value!)
+ }
+ return nil
+ case SMCDataType.FPE2.rawValue:
+ return Double(Int(fromFPE2: (val.bytes[0], val.bytes[1])))
+ default:
+ return nil
+ }
+ }
+
+ return nil
+ }
+
+ public func getStringValue(_ key: String) -> String? {
+ var result: kern_return_t = 0
+ var val: SMCVal_t = SMCVal_t(key)
+
+ result = read(&val)
+ if result != kIOReturnSuccess {
+ print("Error read(): " + (String(cString: mach_error_string(result), encoding: String.Encoding.ascii) ?? "unknown error"))
+ return nil
+ }
+
+ if val.dataSize > 0 {
+ if val.bytes.first(where: { $0 != 0}) == nil {
+ return nil
+ }
+
+ switch val.dataType {
+ case SMCDataType.FDS.rawValue:
+ let c1 = String(UnicodeScalar(val.bytes[4]))
+ let c2 = String(UnicodeScalar(val.bytes[5]))
+ let c3 = String(UnicodeScalar(val.bytes[6]))
+ let c4 = String(UnicodeScalar(val.bytes[7]))
+ let c5 = String(UnicodeScalar(val.bytes[8]))
+ let c6 = String(UnicodeScalar(val.bytes[9]))
+ let c7 = String(UnicodeScalar(val.bytes[10]))
+ let c8 = String(UnicodeScalar(val.bytes[11]))
+ let c9 = String(UnicodeScalar(val.bytes[12]))
+ let c10 = String(UnicodeScalar(val.bytes[13]))
+ let c11 = String(UnicodeScalar(val.bytes[14]))
+ let c12 = String(UnicodeScalar(val.bytes[15]))
+
+ return (c1 + c2 + c3 + c4 + c5 + c6 + c7 + c8 + c9 + c10 + c11 + c12).trimmingCharacters(in: .whitespaces)
+ default:
+ print("unsupported data type \(val.dataType) for key: \(key)")
+ return nil
+ }
+ }
+
+ return nil
+ }
+
+ public func getAllKeys() -> [String] {
+ var list: [String] = []
+
+ let keysNum: Double? = self.getValue("#KEY")
+ if keysNum == nil {
+ print("ERROR no keys count found")
+ return list
+ }
+
+ var result: kern_return_t = 0
+ var input: SMCKeyData_t = SMCKeyData_t()
+ var output: SMCKeyData_t = SMCKeyData_t()
+
+ for i in 0...Int(keysNum!) {
+ input = SMCKeyData_t()
+ output = SMCKeyData_t()
+
+ input.data8 = SMCKeys.readIndex.rawValue
+ input.data32 = UInt32(i)
+
+ result = call(SMCKeys.kernelIndex.rawValue, input: &input, output: &output)
+ if result != kIOReturnSuccess {
+ continue
+ }
+
+ list.append(output.key.toString())
+ }
+
+ return list
+ }
+
+ public func write(_ key: String, _ newValue: Int) -> kern_return_t {
+ var value = SMCVal_t(key)
+ value.dataSize = 2
+ value.bytes = [UInt8(newValue >> 6), UInt8((newValue << 2) ^ ((newValue >> 6) << 8)), UInt8(0), UInt8(0), UInt8(0), UInt8(0),
+ UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0),
+ UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0),
+ UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0),
+ UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0),
+ UInt8(0), UInt8(0)]
+
+ return write(value)
+ }
+
+ // MARK: - fans
+
+ public func setFanMode(_ id: Int, mode: FanMode) {
+ if self.getValue("F\(id)Md") != nil {
+ var result: kern_return_t = 0
+ var value = SMCVal_t("F\(id)Md")
+
+ result = read(&value)
+ if result != kIOReturnSuccess {
+ print("Error read fan mode: " + (String(cString: mach_error_string(result), encoding: String.Encoding.ascii) ?? "unknown error"))
+ return
+ }
+
+ value.bytes = [UInt8(mode.rawValue), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0),
+ UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0),
+ UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0),
+ UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0),
+ UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0),
+ UInt8(0), UInt8(0)]
+
+ result = write(value)
+ if result != kIOReturnSuccess {
+ print("Error write: " + (String(cString: mach_error_string(result), encoding: String.Encoding.ascii) ?? "unknown error"))
+ return
+ }
+ }
+
+ let fansMode = Int(self.getValue("FS! ") ?? 0)
+ var newMode: UInt8 = 0
+
+ if fansMode == 0 && id == 0 && mode == .forced {
+ newMode = 1
+ } else if fansMode == 0 && id == 1 && mode == .forced {
+ newMode = 2
+ } else if fansMode == 1 && id == 0 && mode == .automatic {
+ newMode = 0
+ } else if fansMode == 1 && id == 1 && mode == .forced {
+ newMode = 3
+ } else if fansMode == 2 && id == 1 && mode == .automatic {
+ newMode = 0
+ } else if fansMode == 2 && id == 0 && mode == .forced {
+ newMode = 3
+ } else if fansMode == 3 && id == 0 && mode == .automatic {
+ newMode = 2
+ } else if fansMode == 3 && id == 1 && mode == .automatic {
+ newMode = 1
+ }
+
+ if fansMode == newMode {
+ return
+ }
+
+ var result: kern_return_t = 0
+ var value = SMCVal_t("FS! ")
+
+ result = read(&value)
+ if result != kIOReturnSuccess {
+ print("Error read fan mode: " + (String(cString: mach_error_string(result), encoding: String.Encoding.ascii) ?? "unknown error"))
+ return
+ }
+
+ value.bytes = [0, newMode, UInt8(0), UInt8(0), UInt8(0), UInt8(0),
+ UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0),
+ UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0),
+ UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0),
+ UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0), UInt8(0),
+ UInt8(0), UInt8(0)]
+
+ result = write(value)
+ if result != kIOReturnSuccess {
+ print("Error write: " + (String(cString: mach_error_string(result), encoding: String.Encoding.ascii) ?? "unknown error"))
+ return
+ }
+ }
+
+ public func setFanSpeed(_ id: Int, speed: Int) {
+ let maxSpeed = Int(self.getValue("F\(id)Mx") ?? 4000)
+
+ if speed > maxSpeed {
+ print("new fan speed (\(speed)) is more than maximum speed (\(maxSpeed))")
+ return
+ }
+
+ var result: kern_return_t = 0
+ var value = SMCVal_t("F\(id)Tg")
+
+ result = read(&value)
+ if result != kIOReturnSuccess {
+ print("Error read fan value: " + (String(cString: mach_error_string(result), encoding: String.Encoding.ascii) ?? "unknown error"))
+ return
+ }
+
+ if value.dataType == "flt " {
+ let bytes = Float(speed).bytes
+ value.bytes[0] = bytes[0]
+ value.bytes[1] = bytes[1]
+ value.bytes[2] = bytes[2]
+ value.bytes[3] = bytes[3]
+ } else if value.dataType == "fpe2" {
+ value.bytes[0] = UInt8(speed >> 6)
+ value.bytes[1] = UInt8((speed << 2) ^ ((speed >> 6) << 8))
+ value.bytes[2] = UInt8(0)
+ value.bytes[3] = UInt8(0)
+ }
+
+ result = write(value)
+ if result != kIOReturnSuccess {
+ print("Error write: " + (String(cString: mach_error_string(result), encoding: String.Encoding.ascii) ?? "unknown error"))
+ return
+ }
+ }
+
+ public func resetFans() {
+ var value = SMCVal_t("FS! ")
+ value.dataSize = 2
+
+ let result = write(value)
+ if result != kIOReturnSuccess {
+ print("Error write: " + (String(cString: mach_error_string(result), encoding: String.Encoding.ascii) ?? "unknown error"))
+ }
+ }
+
+ // MARK: - internal functions
+
+ private func read(_ value: UnsafeMutablePointer) -> kern_return_t {
+ var result: kern_return_t = 0
+ var input = SMCKeyData_t()
+ var output = SMCKeyData_t()
+
+ input.key = FourCharCode(fromString: value.pointee.key)
+ input.data8 = SMCKeys.readKeyInfo.rawValue
+
+ result = call(SMCKeys.kernelIndex.rawValue, input: &input, output: &output)
+ if result != kIOReturnSuccess {
+ return result
+ }
+
+ value.pointee.dataSize = UInt32(output.keyInfo.dataSize)
+ value.pointee.dataType = output.keyInfo.dataType.toString()
+ input.keyInfo.dataSize = output.keyInfo.dataSize
+ input.data8 = SMCKeys.readBytes.rawValue
+
+ result = call(SMCKeys.kernelIndex.rawValue, input: &input, output: &output)
+ if result != kIOReturnSuccess {
+ return result
+ }
+
+ memcpy(&value.pointee.bytes, &output.bytes, Int(value.pointee.dataSize))
+
+ return kIOReturnSuccess
+ }
+
+ private func write(_ value: SMCVal_t) -> kern_return_t {
+ var input = SMCKeyData_t()
+ var output = SMCKeyData_t()
+
+ input.key = FourCharCode(fromString: value.key)
+ input.data8 = SMCKeys.writeBytes.rawValue
+ input.keyInfo.dataSize = IOByteCount32(value.dataSize)
+ input.bytes = (value.bytes[0], value.bytes[1], value.bytes[2], value.bytes[3], value.bytes[4], value.bytes[5],
+ value.bytes[6], value.bytes[7], value.bytes[8], value.bytes[9], value.bytes[10], value.bytes[11],
+ value.bytes[12], value.bytes[13], value.bytes[14], value.bytes[15], value.bytes[16], value.bytes[17],
+ value.bytes[18], value.bytes[19], value.bytes[20], value.bytes[21], value.bytes[22], value.bytes[23],
+ value.bytes[24], value.bytes[25], value.bytes[26], value.bytes[27], value.bytes[28], value.bytes[29],
+ value.bytes[30], value.bytes[31])
+
+ let result = self.call(SMCKeys.kernelIndex.rawValue, input: &input, output: &output)
+ if result != kIOReturnSuccess {
+ return result
+ }
+
+ return kIOReturnSuccess
+ }
+
+ private func call(_ index: UInt8, input: inout SMCKeyData_t, output: inout SMCKeyData_t) -> kern_return_t {
+ let inputSize = MemoryLayout.stride
+ var outputSize = MemoryLayout.stride
+
+ return IOConnectCallStructMethod(conn, UInt32(index), &input, inputSize, &output, &outputSize)
+ }
+}