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) +}