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">
-
-
-
+
-
+
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 +194,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 +203,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 +220,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 +237,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 +244,7 @@ struct NotchStatsView: View {
))
}
}
-
+
private func handleGraphClick(for graphData: GraphData) {
switch graphData.title {
case "CPU":
@@ -245,11 +253,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 +270,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 +287,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 +343,6 @@ struct NotchStatsView: View {
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding()
} else {
- // Stats content with smart grid layout
VStack(spacing: 8) {
statsGridLayout
}
@@ -349,7 +368,7 @@ struct NotchStatsView: View {
}
.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) {
@@ -365,6 +384,12 @@ struct NotchStatsView: View {
.onChange(of: showingGPUPopover) { _, _ in
updateStatsPopoverState()
}
+ .onChange(of: showingNetworkPopover) { _, _ in
+ updateStatsPopoverState()
+ }
+ .onChange(of: showingDiskPopover) { _, _ in
+ updateStatsPopoverState()
+ }
.onChange(of: isHoveringCPUPopover) { _, _ in
updateStatsPopoverState()
}
@@ -374,15 +399,23 @@ struct NotchStatsView: View {
.onChange(of: isHoveringGPUPopover) { _, _ in
updateStatsPopoverState()
}
+ .onChange(of: isHoveringNetworkPopover) { _, _ in
+ updateStatsPopoverState()
+ }
+ .onChange(of: isHoveringDiskPopover) { _, _ in
+ updateStatsPopoverState()
+ }
}
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 newState = (showingCPUPopover && isHoveringCPUPopover) ||
+ (showingMemoryPopover && isHoveringMemoryPopover) ||
+ (showingGPUPopover && isHoveringGPUPopover) ||
+ (showingNetworkPopover && isHoveringNetworkPopover) ||
+ (showingDiskPopover && isHoveringDiskPopover) ||
+ isResizingForStats
if vm.isStatsPopoverActive != newState {
vm.isStatsPopoverActive = newState
#if DEBUG
@@ -390,6 +423,8 @@ struct NotchStatsView: View {
print(" CPU: shown=\(showingCPUPopover), hovering=\(isHoveringCPUPopover)")
print(" Memory: shown=\(showingMemoryPopover), hovering=\(isHoveringMemoryPopover)")
print(" GPU: shown=\(showingGPUPopover), hovering=\(isHoveringGPUPopover)")
+ print(" Network: shown=\(showingNetworkPopover), hovering=\(isHoveringNetworkPopover)")
+ print(" Disk: shown=\(showingDiskPopover), hovering=\(isHoveringDiskPopover)")
print(" Resizing: \(isResizingForStats)")
#endif
}
@@ -460,8 +495,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..baeef4d
--- /dev/null
+++ b/DynamicIsland/components/Stats/CPUStatsDetailView.swift
@@ -0,0 +1,66 @@
+//
+// 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
+ )
+ }
+
+ if !statsManager.cpuCoreUsage.isEmpty {
+ StatsCard(title: "Per-Core Usage") {
+ CPUCoreUsageGrid(cores: statsManager.cpuCoreUsage, accentColor: userColor)
+ }
+ }
+
+ StatsCard(title: "Details") {
+ CPUDetailsGrid(
+ breakdown: statsManager.cpuBreakdown,
+ loadAverage: statsManager.cpuLoadAverage,
+ uptime: statsManager.cpuUptime,
+ coreCount: statsManager.cpuCoreUsage.count,
+ systemColor: systemColor,
+ userColor: userColor
+ )
+ }
+
+ StatsCard(title: "Top Processes") {
+ CPUProcessList(processes: topProcesses, 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..bb0abac
--- /dev/null
+++ b/DynamicIsland/components/Stats/CPUStatsSharedViews.swift
@@ -0,0 +1,410 @@
+//
+// 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
+
+ var body: some View {
+ HStack(alignment: .center, spacing: 28) {
+ CPUSegmentDonut(
+ breakdown: breakdown,
+ systemColor: systemColor,
+ userColor: userColor,
+ idleColor: idleColor
+ )
+ .frame(width: 132, height: 132)
+
+ 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)
+
+ 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)
+ }
+ }
+}
+
+private struct CPUSegmentDonut: View {
+ let breakdown: CPULoadBreakdown
+ let systemColor: Color
+ let userColor: Color
+ let idleColor: Color
+
+ var body: some View {
+ ZStack {
+ Circle()
+ .stroke(idleColor.opacity(0.3), lineWidth: 14)
+ CPUUsageRing(
+ breakdown: breakdown,
+ systemColor: systemColor,
+ userColor: userColor,
+ lineWidth: 14
+ )
+ 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)
+ Spacer()
+ Text(value)
+ .font(.system(size: 13, weight: .semibold, design: .monospaced))
+ .foregroundColor(.primary)
+ }
+ }
+}
+
+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/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..62d993b
--- /dev/null
+++ b/DynamicIsland/components/Stats/GPUStatsDetailView.swift
@@ -0,0 +1,190 @@
+//
+// 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
+ )
+ }
+
+ if !statsManager.gpuDevices.isEmpty {
+ StatsCard(title: "Devices", padding: 16, background: cardBackground, cornerRadius: 12) {
+ GPUDeviceList(devices: statsManager.gpuDevices, accentColor: accentColor)
+ }
+ }
+
+ StatsCard(title: "Top Processes", padding: 16, background: cardBackground, cornerRadius: 12) {
+ CPUProcessList(processes: topProcesses, accentColor: accentColor, displayLimit: processDisplayLimit)
+ }
+ }
+ .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 {
+ HStack(alignment: .center, spacing: 24) {
+ ZStack {
+ Circle()
+ .stroke(accentColor.opacity(0.25), lineWidth: 14)
+ .frame(width: 120, height: 120)
+
+ RingArc(start: 0, end: CGFloat(min(max(usage / 100, 0), 1)), color: accentColor, lineWidth: 14)
+ .frame(width: 120, height: 120)
+
+ VStack(spacing: 4) {
+ Text(StatsFormatting.percentage(usage))
+ .font(.system(size: 28, weight: .bold, design: .rounded))
+ Text("Active")
+ .font(.footnote)
+ .foregroundColor(.secondary)
+ }
+ }
+
+ 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)
+
+ 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 func formattedTimestamp(_ date: Date) -> String {
+ let formatter = DateFormatter()
+ formatter.dateStyle = .none
+ formatter.timeStyle = .medium
+ return formatter.string(from: date)
+ }
+}
+
+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..3279b96
--- /dev/null
+++ b/DynamicIsland/components/Stats/MemoryStatsDetailView.swift
@@ -0,0 +1,255 @@
+//
+// 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: "Pressure & Swap", padding: 16, background: cardBackground, cornerRadius: 12) {
+ MemoryHealthView(breakdown: statsManager.memoryBreakdown, accentColor: accentColor)
+ }
+
+ StatsCard(title: "Details", padding: 16, background: cardBackground, cornerRadius: 12) {
+ MemoryDetailsGrid(breakdown: statsManager.memoryBreakdown, accentColor: accentColor)
+ }
+
+ StatsCard(title: "Top Processes", padding: 16, background: cardBackground, cornerRadius: 12) {
+ CPUProcessList(processes: topProcesses, accentColor: accentColor, displayLimit: processDisplayLimit)
+ }
+ }
+ .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 {
+ HStack(alignment: .center, spacing: 28) {
+ 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)
+ }
+ }
+
+ 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)
+
+ 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)
+ }
+ }
+
+ private func formattedBytes(_ value: UInt64) -> String {
+ StatsFormatting.bytes(value)
+ }
+}
+
+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 MemoryDetailsGrid: View {
+ let breakdown: MemoryBreakdown
+ let accentColor: Color
+
+ var body: some View {
+ VStack(spacing: 10) {
+ DetailRow(color: accentColor.opacity(0.85), label: "App Memory", value: StatsFormatting.bytes(breakdown.appBytes))
+ DetailRow(color: accentColor.opacity(0.7), label: "Cached Files", value: StatsFormatting.bytes(breakdown.cacheBytes))
+ DetailRow(color: accentColor.opacity(0.6), label: "Active", value: StatsFormatting.bytes(breakdown.activeBytes))
+ DetailRow(color: accentColor.opacity(0.45), label: "Inactive", value: StatsFormatting.bytes(breakdown.inactiveBytes))
+ DetailRow(color: accentColor.opacity(0.35), label: "Wired", value: StatsFormatting.bytes(breakdown.wiredBytes))
+ DetailRow(color: accentColor.opacity(0.25), label: "Compressed", value: StatsFormatting.bytes(breakdown.compressedBytes))
+ DetailRow(color: accentColor.opacity(0.18), label: "Free", value: StatsFormatting.bytes(breakdown.freeBytes))
+ Divider().padding(.vertical, 4)
+ DetailRow(color: nil, label: "Total Memory", value: StatsFormatting.bytes(breakdown.totalBytes))
+ }
+ }
+}
+
+private struct MemoryHealthView: View {
+ let breakdown: MemoryBreakdown
+ let accentColor: Color
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 14) {
+ 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..8b36c72
--- /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.mbPerSecond(download))
+ .font(.system(size: 26, weight: .bold, design: .rounded))
+ .foregroundColor(.primary)
+ DetailRow(color: downloadColor.opacity(0.6), label: "Average", value: StatsFormatting.mbPerSecond(avgDownload))
+ DetailRow(color: downloadColor.opacity(0.45), label: "Peak", value: StatsFormatting.mbPerSecond(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.mbPerSecond(upload))
+ .font(.system(size: 26, weight: .bold, design: .rounded))
+ .foregroundColor(.primary)
+ DetailRow(color: uploadColor.opacity(0.6), label: "Average", value: StatsFormatting.mbPerSecond(avgUpload))
+ DetailRow(color: uploadColor.opacity(0.45), label: "Peak", value: StatsFormatting.mbPerSecond(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.mbPerSecond(interface.currentDownload), color: downloadColor)
+ metricRow(label: "Up", value: StatsFormatting.mbPerSecond(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..1f35dfb 100644
--- a/DynamicIsland/components/Stats/RankedProcessPopover.swift
+++ b/DynamicIsland/components/Stats/RankedProcessPopover.swift
@@ -9,188 +9,59 @@ import SwiftUI
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
- )
- }
- }
- .padding(.vertical, 4)
+ .onHover { hovering in
+ onHoverChange?(hovering)
}
- .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)
- }
}
-}
-
-struct RankedProcessRow: View {
- let process: ProcessStats
- let rank: Int
- let rankingType: ProcessRankingType
- @State private var isHovered = false
- private var primaryValue: String {
+ @ViewBuilder
+ private var popoverContent: some View {
switch rankingType {
case .cpu:
- return process.cpuUsageString
+ CPUStatsDetailView()
case .memory:
- return process.memoryUsageString
+ MemoryStatsDetailView()
case .gpu:
- return process.cpuUsageString // Using CPU as proxy for GPU
+ GPUStatsDetailView()
+ case .network:
+ NetworkStatsDetailView()
+ case .disk:
+ DiskStatsDetailView()
}
}
-
- private var secondaryValue: String {
- switch rankingType {
- case .cpu:
- return process.memoryUsageString
- case .memory:
- return process.cpuUsageString
- case .gpu:
- return process.memoryUsageString
- }
- }
-
- 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)
- }
- }
- .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)
- }
- .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
- }
- }
- }
-}
-
-#Preview {
- RankedProcessPopover(rankingType: .cpu)
- .background(Color.black)
}
\ 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..d11bd26
--- /dev/null
+++ b/DynamicIsland/components/Stats/StatsFormatting.swift
@@ -0,0 +1,48 @@
+//
+// 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 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 9308c88..8340757 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,18 @@ 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 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] = []
@@ -53,6 +412,12 @@ 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] = []
@@ -60,6 +425,7 @@ class StatsManager: ObservableObject {
private let processStatsUpdateInterval: TimeInterval = 2.0
private let maxProcessEntries: Int = 20
private var isProcessRefreshInFlight = false
+ private let gpuCollector = GPUInfoCollector()
// MARK: - Initialization
private init() {
@@ -140,6 +506,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
@@ -167,14 +535,32 @@ class StatsManager: ObservableObject {
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()
}
// 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()
@@ -183,12 +569,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
@@ -199,27 +587,62 @@ 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
// Update history arrays (sliding window)
updateHistory(value: newCpuUsage, history: &cpuHistory)
@@ -234,6 +657,8 @@ class StatsManager: ObservableObject {
previousNetworkStats = currentNetworkStats
previousDiskStats = currentDiskStats
previousTimestamp = currentTime
+ networkInterfaces = collectNetworkInterfaces(deltaTime: timeInterval)
+ diskDevices = collectDiskDevices()
refreshProcessStatsIfNeeded(force: true)
}
@@ -247,32 +672,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)
@@ -283,32 +755,384 @@ 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 freeCount = UInt64(vmStatistics.free_count) + UInt64(vmStatistics.speculative_count)
+ let activeCount = UInt64(vmStatistics.active_count)
+ let inactiveCount = UInt64(vmStatistics.inactive_count)
+ let wiredCount = UInt64(vmStatistics.wire_count)
+ let compressorCount = UInt64(vmStatistics.compressor_page_count)
- guard totalMemory > 0 else { return 0.0 }
+ let totalMemory = (freeCount + activeCount + inactiveCount + wiredCount) * pageSize
+ guard totalMemory > 0 else {
+ return (0.0, .zero)
+ }
+
+ let freeMemory = freeCount * pageSize
+ let speculativeCount = UInt64(vmStatistics.speculative_count)
+ let externalCount = UInt64(vmStatistics.external_page_count)
+ let purgeableCount = UInt64(vmStatistics.purgeable_count)
+ let activeMemory = activeCount * pageSize
+ let inactiveMemory = inactiveCount * pageSize
+ let wiredMemory = wiredCount * pageSize
+ let compressedMemory = compressorCount * pageSize
+ let purgeableMemory = purgeableCount * pageSize
+ let externalMemory = externalCount * pageSize
+ let usedMemory = totalMemory > freeMemory ? totalMemory - freeMemory : 0
+ let appMemory = usedMemory > (wiredMemory + compressedMemory) ? usedMemory - wiredMemory - compressedMemory : 0
+ let cacheMemory = purgeableMemory + externalMemory
+
+ 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(usedMemory) / Double(totalMemory) * 100.0
- return min(100.0, max(0.0, usage))
+ let breakdown = MemoryBreakdown(
+ totalBytes: totalMemory,
+ usedBytes: usedMemory,
+ freeBytes: freeMemory,
+ wiredBytes: wiredMemory,
+ activeBytes: activeMemory,
+ inactiveBytes: inactiveMemory,
+ compressedBytes: compressedMemory,
+ appBytes: appMemory,
+ cacheBytes: cacheMemory,
+ 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) {
@@ -362,7 +1186,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)
@@ -504,6 +1328,7 @@ class StatsManager: ObservableObject {
self.cachedProcessStats = processes
self.lastProcessStatsUpdate = Date()
self.isProcessRefreshInFlight = false
+ self.topCPUProcesses = processes
}
}
}
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)
+}