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