mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-27 15:14:33 +00:00
Merge pull request #50 from Ebullioscopic/feature/stats
Each and every stats is working
This commit is contained in:
commit
6595266f4a
23 changed files with 4274 additions and 381 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -142,6 +142,7 @@ SHEET_PRESENTATION_FIX.md
|
|||
lock_mode_reference.md
|
||||
liquid_glass.md
|
||||
huds.md
|
||||
|
||||
borrow_migrations.md
|
||||
borrowing/
|
||||
MoveToSky.xcodeproj
|
||||
/MoveToSky/
|
||||
|
|
@ -40,11 +40,8 @@
|
|||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES">
|
||||
<PathRunnable
|
||||
runnableDebuggingMode = "0"
|
||||
FilePath = "/Applications/DynamicIsland.app">
|
||||
</PathRunnable>
|
||||
<MacroExpansion>
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "F54406E62CD4987A00C7AFD9"
|
||||
|
|
@ -52,7 +49,7 @@
|
|||
BlueprintName = "DynamicIsland"
|
||||
ReferencedContainer = "container:DynamicIsland.xcodeproj">
|
||||
</BuildableReference>
|
||||
</MacroExpansion>
|
||||
</BuildableProductRunnable>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
|
|
|
|||
|
|
@ -36,35 +36,20 @@ struct ContentView: View {
|
|||
|
||||
// Dynamic sizing based on view type and graph count with smooth transitions
|
||||
var dynamicNotchSize: CGSize {
|
||||
// Use minimalistic or normal size based on settings
|
||||
let baseSize = Defaults[.enableMinimalisticUI] ? minimalisticOpenNotchSize : openNotchSize
|
||||
|
||||
// Always return the target size for the current view to ensure smooth transitions
|
||||
let targetSize: CGSize
|
||||
|
||||
if coordinator.currentView == .stats {
|
||||
// Count enabled graphs using the same logic as NotchStatsView
|
||||
var enabledGraphsCount = 0
|
||||
if showCpuGraph { enabledGraphsCount += 1 }
|
||||
if showMemoryGraph { enabledGraphsCount += 1 }
|
||||
if showGpuGraph { enabledGraphsCount += 1 }
|
||||
if showNetworkGraph { enabledGraphsCount += 1 }
|
||||
if showDiskGraph { enabledGraphsCount += 1 }
|
||||
|
||||
if enabledGraphsCount > 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
|
||||
|
|
|
|||
|
|
@ -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" : {
|
||||
|
||||
|
|
|
|||
|
|
@ -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<Bool> {
|
||||
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)
|
||||
|
|
|
|||
57
DynamicIsland/components/Stats/CPUStatsDetailView.swift
Normal file
57
DynamicIsland/components/Stats/CPUStatsDetailView.swift
Normal file
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
545
DynamicIsland/components/Stats/CPUStatsSharedViews.swift
Normal file
545
DynamicIsland/components/Stats/CPUStatsSharedViews.swift
Normal file
|
|
@ -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<Content: View>: 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
48
DynamicIsland/components/Stats/CircularGaugeView.swift
Normal file
48
DynamicIsland/components/Stats/CircularGaugeView.swift
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
220
DynamicIsland/components/Stats/DiskStatsDetailView.swift
Normal file
220
DynamicIsland/components/Stats/DiskStatsDetailView.swift
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
305
DynamicIsland/components/Stats/GPUStatsDetailView.swift
Normal file
305
DynamicIsland/components/Stats/GPUStatsDetailView.swift
Normal file
|
|
@ -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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
246
DynamicIsland/components/Stats/MemoryStatsDetailView.swift
Normal file
246
DynamicIsland/components/Stats/MemoryStatsDetailView.swift
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
264
DynamicIsland/components/Stats/NetworkStatsDetailView.swift
Normal file
264
DynamicIsland/components/Stats/NetworkStatsDetailView.swift
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
#endif
|
||||
78
DynamicIsland/components/Stats/StatsFormatting.swift
Normal file
78
DynamicIsland/components/Stats/StatsFormatting.swift
Normal file
|
|
@ -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) ?? "—"
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
39
DynamicIsland/models/CPUMetrics.swift
Normal file
39
DynamicIsland/models/CPUMetrics.swift
Normal file
|
|
@ -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)
|
||||
}
|
||||
162
DynamicIsland/utils/AppleHardwareInfo.swift
Normal file
162
DynamicIsland/utils/AppleHardwareInfo.swift
Normal file
|
|
@ -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<Int32>.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)
|
||||
}
|
||||
}
|
||||
237
DynamicIsland/utils/CPUSensorCollector.swift
Normal file
237
DynamicIsland/utils/CPUSensorCollector.swift
Normal file
|
|
@ -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<CFMutableDictionary>?
|
||||
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..<CFArrayGetCount(array) {
|
||||
let element = CFArrayGetValueAtIndex(array, index)
|
||||
let entry = unsafeBitCast(element, to: CFDictionary.self)
|
||||
let group = IOReportChannelGetGroup(entry)?.takeUnretainedValue() as String? ?? ""
|
||||
let subGroup = IOReportChannelGetSubGroup(entry)?.takeUnretainedValue() as String? ?? ""
|
||||
let channel = IOReportChannelGetChannelName(entry)?.takeUnretainedValue() as String? ?? ""
|
||||
let unit = IOReportChannelGetUnitLabel(entry)?.takeUnretainedValue() as String? ?? ""
|
||||
result.append(IOSample(group: group, subGroup: subGroup, channel: channel, unit: unit, delta: entry))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private func getResidencies(dict: CFDictionary) -> [(label: String, value: Int64)] {
|
||||
let count = IOReportStateGetCount(dict)
|
||||
var result: [(String, Int64)] = []
|
||||
for index in 0..<count {
|
||||
let name = IOReportStateGetNameForIndex(dict, index)?.takeUnretainedValue() as String? ?? ""
|
||||
let value = IOReportStateGetResidency(dict, index)
|
||||
result.append((name, value))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private func calculateFrequency(from dict: CFDictionary, freqs: [Int32]) -> 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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
52
DynamicIsland/utils/IOKitHelpers.swift
Normal file
52
DynamicIsland/utils/IOKitHelpers.swift
Normal file
|
|
@ -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<CFMutableDictionary>? = 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<io_name_t>.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..<upperBound]
|
||||
if chunk.count < 4 { continue }
|
||||
let value = chunk.prefix(4).enumerated().reduce(UInt32(0)) { partial, element in
|
||||
partial | (UInt32(element.element) << (UInt32(element.offset) * 8))
|
||||
}
|
||||
frequencies.append(Int32(value / multiplier))
|
||||
}
|
||||
|
||||
return frequencies
|
||||
}
|
||||
112
DynamicIsland/utils/IOReportBridging.swift
Normal file
112
DynamicIsland/utils/IOReportBridging.swift
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
//
|
||||
// IOReportBridging.swift
|
||||
// DynamicIsland
|
||||
//
|
||||
// Lightweight runtime loader for private IOReport symbols used by the GPU/CPU collectors.
|
||||
// Avoids static linking against the private framework by resolving symbols via dlopen/dlsym.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Darwin
|
||||
|
||||
public typealias IOReportSubscriptionRef = OpaquePointer
|
||||
|
||||
private enum IOReportLoader {
|
||||
private static let libraryHandle: UnsafeMutableRawPointer? = {
|
||||
let paths = [
|
||||
"/System/Library/PrivateFrameworks/IOReport.framework/IOReport",
|
||||
"/usr/lib/libIOReport.dylib"
|
||||
]
|
||||
for path in paths {
|
||||
if let handle = dlopen(path, RTLD_LAZY | RTLD_LOCAL) {
|
||||
return handle
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}()
|
||||
|
||||
static func symbol<T>(_ 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<CFDictionary>? {
|
||||
typealias Fn = @convention(c) (CFString?, CFString?, UInt64, UInt64, UInt64) -> Unmanaged<CFDictionary>?
|
||||
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<Unmanaged<CFMutableDictionary>?>?, _ d: UInt64, _ e: CFTypeRef?) -> IOReportSubscriptionRef? {
|
||||
typealias Fn = @convention(c) (UnsafeMutableRawPointer?, CFMutableDictionary?, UnsafeMutablePointer<Unmanaged<CFMutableDictionary>?>?, 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<CFDictionary>? {
|
||||
typealias Fn = @convention(c) (IOReportSubscriptionRef?, CFMutableDictionary?, CFTypeRef?) -> Unmanaged<CFDictionary>?
|
||||
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<CFDictionary>? {
|
||||
typealias Fn = @convention(c) (CFDictionary, CFDictionary, CFTypeRef?) -> Unmanaged<CFDictionary>?
|
||||
guard let fn: Fn = IOReportLoader.symbol("IOReportCreateSamplesDelta", as: Fn.self) else { return nil }
|
||||
return fn(previous, current, zone)
|
||||
}
|
||||
|
||||
func IOReportChannelGetGroup(_ dictionary: CFDictionary) -> Unmanaged<CFString>? {
|
||||
typealias Fn = @convention(c) (CFDictionary) -> Unmanaged<CFString>?
|
||||
guard let fn: Fn = IOReportLoader.symbol("IOReportChannelGetGroup", as: Fn.self) else { return nil }
|
||||
return fn(dictionary)
|
||||
}
|
||||
|
||||
func IOReportChannelGetSubGroup(_ dictionary: CFDictionary) -> Unmanaged<CFString>? {
|
||||
typealias Fn = @convention(c) (CFDictionary) -> Unmanaged<CFString>?
|
||||
guard let fn: Fn = IOReportLoader.symbol("IOReportChannelGetSubGroup", as: Fn.self) else { return nil }
|
||||
return fn(dictionary)
|
||||
}
|
||||
|
||||
func IOReportChannelGetChannelName(_ dictionary: CFDictionary) -> Unmanaged<CFString>? {
|
||||
typealias Fn = @convention(c) (CFDictionary) -> Unmanaged<CFString>?
|
||||
guard let fn: Fn = IOReportLoader.symbol("IOReportChannelGetChannelName", as: Fn.self) else { return nil }
|
||||
return fn(dictionary)
|
||||
}
|
||||
|
||||
func IOReportChannelGetUnitLabel(_ dictionary: CFDictionary) -> Unmanaged<CFString>? {
|
||||
typealias Fn = @convention(c) (CFDictionary) -> Unmanaged<CFString>?
|
||||
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<CFString>? {
|
||||
typealias Fn = @convention(c) (CFDictionary, Int32) -> Unmanaged<CFString>?
|
||||
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)
|
||||
}
|
||||
532
DynamicIsland/utils/SMC.swift
Normal file
532
DynamicIsland/utils/SMC.swift
Normal file
|
|
@ -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<SMCVal_t>) -> 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<SMCKeyData_t>.stride
|
||||
var outputSize = MemoryLayout<SMCKeyData_t>.stride
|
||||
|
||||
return IOConnectCallStructMethod(conn, UInt32(index), &input, inputSize, &output, &outputSize)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue