├── preview ├── dark.png └── light.png ├── .gitignore ├── Sources ├── ReportObservable.swift ├── GaugesWrapper.swift ├── SwiftUIPreview.swift ├── PerformanceReport.swift ├── LinkedFrameList.swift ├── ShakeGestureModifier.swift ├── SizeClass.swift ├── PerformanceMonitor.swift ├── Draggable.swift ├── GaugesModifier.swift ├── Calculator.swift └── Gauges.swift ├── Package.swift └── README.md /preview/dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IgorMuzyka/PerformanceMonitor/HEAD/preview/dark.png -------------------------------------------------------------------------------- /preview/light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IgorMuzyka/PerformanceMonitor/HEAD/preview/light.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | xcuserdata/ 5 | DerivedData/ 6 | .swiftpm/configuration/registries.json 7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 8 | .netrc 9 | -------------------------------------------------------------------------------- /Sources/ReportObservable.swift: -------------------------------------------------------------------------------- 1 | 2 | import SwiftUI 3 | 4 | public class ReportObservable: ObservableObject { 5 | @Published public private(set) var report: PerformanceReport? 6 | internal func assign(_ report: PerformanceReport) { 7 | self.report = report 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.7 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "PerformanceMonitor", 7 | platforms: [ 8 | .macOS(.v13), 9 | .iOS(.v16), 10 | ], 11 | products: [ 12 | .library(name: "PerformanceMonitor", targets: ["PerformanceMonitor"]), 13 | ], 14 | dependencies: [ 15 | .package(url: "https://github.com/IgorMuzyka/DisplayLink", .upToNextMajor(from: "1.0.0")), 16 | ], 17 | targets: [ 18 | .target( 19 | name: "PerformanceMonitor", 20 | dependencies: [ 21 | .byName(name: "DisplayLink"), 22 | ], 23 | path: "./Sources/" 24 | ), 25 | ] 26 | ) 27 | -------------------------------------------------------------------------------- /Sources/GaugesWrapper.swift: -------------------------------------------------------------------------------- 1 | 2 | import SwiftUI 3 | 4 | struct GaugesWrapper: View { 5 | @Environment(\.scenePhase) private var scenePhase 6 | weak var monitor: PerformanceMonitor? 7 | let content: () -> Content 8 | var body: some View { 9 | content() 10 | .onAppear { 11 | monitor?.resume() 12 | } 13 | .onDisappear { 14 | monitor?.pause() 15 | } 16 | .onChange(of: scenePhase) { scenePhase in 17 | switch scenePhase { 18 | case .active: monitor?.resume() 19 | case .inactive: monitor?.pause() 20 | case .background: monitor?.pause() 21 | @unknown default: break 22 | } 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Sources/SwiftUIPreview.swift: -------------------------------------------------------------------------------- 1 | 2 | #if DEBUG && targetEnvironment(simulator) 3 | import SwiftUI 4 | struct Preview: View { 5 | @State var isPresented: Bool = true 6 | var body: some View { 7 | Rectangle() 8 | .fill(.clear) 9 | .performanceMonitor( 10 | isPresented: $isPresented, 11 | shakeGestureTogglesPresentation: true, 12 | sizeClass: .compact 13 | ) 14 | } 15 | } 16 | @available(iOS 17.0, *) 17 | #Preview("Dark", traits: .fixedLayout(width: 262, height: 64)) { 18 | Preview() 19 | .background(.clear) 20 | .preferredColorScheme(.dark) 21 | } 22 | @available(iOS 17.0, *) 23 | #Preview("Light", traits: .fixedLayout(width: 262, height: 64)) { 24 | Preview() 25 | .background(.clear) 26 | .preferredColorScheme(.light) 27 | } 28 | #endif 29 | -------------------------------------------------------------------------------- /Sources/PerformanceReport.swift: -------------------------------------------------------------------------------- 1 | 2 | import Foundation.NSProcessInfo 3 | 4 | public struct PerformanceReport { 5 | public let uuid = UUID() 6 | public let cpuUsage: Double 7 | public struct MemoryUsage { 8 | public let used: UInt64 9 | public let total: UInt64 10 | } 11 | public let memoryUsage: MemoryUsage 12 | public let fps: Int 13 | public enum ThermalState: String, CaseIterable { 14 | case nominal = "Nominal" 15 | case fair = "Fair" 16 | case serious = "Serious" 17 | case critical = "Critical" 18 | case unknown = "Unknown" 19 | init(thermalState: ProcessInfo.ThermalState) { 20 | switch thermalState { 21 | case .nominal: self = .nominal 22 | case .fair: self = .fair 23 | case .serious: self = .serious 24 | case .critical: self = .critical 25 | @unknown default: self = .unknown 26 | } 27 | } 28 | } 29 | public let thermalState: ThermalState 30 | } 31 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Performance Monitor 2 | ![Swift Version](https://img.shields.io/badge/Swift-5.7+-orange.svg) ![iOS Version](https://img.shields.io/badge/iOS-16+-orange.svg) ![macOS Version](https://img.shields.io/badge/macOS-13+-orange.svg) 3 | 4 | # Preview 5 | 6 | ![Dark Preview](./preview/dark.png#gh-dark-mode-only) 7 | ![Light Preview](./preview/light.png/#gh-light-mode-only) 8 | 9 | ## Usage 10 | 11 | ```swift 12 | import PerformanceMonitor 13 | 14 | // add it somewhere inside your root most SwiftUI View. 15 | @State isPresented: Bool = false 16 | var body: some View { 17 | NavigationStack { 18 | ... 19 | } 20 | .performanceMonitor( 21 | isPresented: $isPresented, 22 | shakeGestureTogglesPresentation: true, 23 | sizeClass: .compact 24 | ) 25 | } 26 | 27 | // if you want to toggle it's presentation via button for example 28 | PerformanceMonitor.shared.togglePresentation() 29 | ``` 30 | Or just shake your iOS device from side to side, if you didn't pass `false` to `shakeGestureTogglesPresentation`. 31 | 32 | 33 | ## Installation 34 | 35 | ### [Swift Package Manager](https://swift.org/package-manager/) 36 | 37 | ```swift 38 | dependencies: [ 39 | .package(url: "https://github.com/IgorMuzyka/PerformanceMonitor", .upToNextMajor(from: "1.0.0")), 40 | ] 41 | ``` 42 | 43 | ## Acknowledgements 44 | 45 | This project was heavily inspired by this [GDPerformanceView-Swift](https://github.com/dani-gavrilov/GDPerformanceView-Swift). 46 | -------------------------------------------------------------------------------- /Sources/LinkedFrameList.swift: -------------------------------------------------------------------------------- 1 | 2 | import Foundation 3 | 4 | internal class LinkedFramesList { 5 | internal class FrameNode { 6 | var next: FrameNode? 7 | weak var previous: FrameNode? 8 | private(set) var timestamp: TimeInterval 9 | public init(timestamp: TimeInterval) { 10 | self.timestamp = timestamp 11 | } 12 | } 13 | private var head: FrameNode? 14 | private var tail: FrameNode? 15 | private(set) var count = 0 16 | 17 | internal func append(frameWithTimestamp timestamp: TimeInterval) { 18 | let newNode = FrameNode(timestamp: timestamp) 19 | if let lastNode = self.tail { 20 | newNode.previous = lastNode 21 | lastNode.next = newNode 22 | self.tail = newNode 23 | } else { 24 | self.head = newNode 25 | self.tail = newNode 26 | } 27 | 28 | self.count += 1 29 | self.removeFrameNodes(olderThanTimestampMoreThanSecond: timestamp) 30 | } 31 | 32 | private func removeFrameNodes(olderThanTimestampMoreThanSecond timestamp: TimeInterval) { 33 | while let firstNode = self.head { 34 | guard timestamp - firstNode.timestamp > 1.0 else { 35 | break 36 | } 37 | 38 | let nextNode = firstNode.next 39 | nextNode?.previous = nil 40 | firstNode.next = nil 41 | self.head = nextNode 42 | 43 | self.count -= 1 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /Sources/ShakeGestureModifier.swift: -------------------------------------------------------------------------------- 1 | 2 | #if os(iOS) && canImport(UIKit) 3 | import SwiftUI 4 | import UIKit 5 | 6 | // The notification we'll send when a shake gesture happens. 7 | fileprivate extension UIDevice { 8 | static let deviceDidShakeNotification = Notification.Name(rawValue: "deviceDidShakeNotification") 9 | } 10 | // Override the default behavior of shake gestures to send our notification instead. 11 | extension UIWindow { 12 | open override func motionEnded(_ motion: UIEvent.EventSubtype, with event: UIEvent?) { 13 | super.motionEnded(motion, with: event) 14 | if motion == .motionShake { 15 | NotificationCenter.default.post(name: UIDevice.deviceDidShakeNotification, object: nil) 16 | } 17 | } 18 | } 19 | // A view modifier that detects shaking and calls a function of our choosing. 20 | fileprivate struct ShakeGestureModifier: ViewModifier { 21 | let action: () -> Void 22 | func body(content: Content) -> some View { 23 | content 24 | .onAppear() 25 | .onReceive(NotificationCenter.default.publisher(for: UIDevice.deviceDidShakeNotification)) { _ in 26 | action() 27 | } 28 | } 29 | } 30 | // A View extension to make the modifier easier to use. 31 | public extension View { 32 | func onShake(_ action: @escaping () -> Void) -> some View { 33 | self.modifier(ShakeGestureModifier(action: action)) 34 | } 35 | } 36 | 37 | #elseif os(macOS) 38 | import SwiftUI 39 | 40 | public extension View { 41 | func onShake(_ action: @escaping () -> Void) -> some View { 42 | /// just a stub 43 | return self 44 | } 45 | } 46 | #endif 47 | -------------------------------------------------------------------------------- /Sources/SizeClass.swift: -------------------------------------------------------------------------------- 1 | 2 | import CoreFoundation.CFCGTypes 3 | 4 | public enum SizeClass: Equatable, CaseIterable { 5 | public static var allCases: [Self] { 6 | ToolbarSizeClass.allCases.map { .toolbar($0) } + [.compact, .regular] 7 | } 8 | func next() -> SizeClass? { 9 | guard let index = Self.allCases.firstIndex(of: self) else { return .none } 10 | let next = Self.allCases.index(after: index) 11 | guard next < Self.allCases.endIndex else { return .none } 12 | return Self.allCases[next] 13 | } 14 | func previous() -> SizeClass? { 15 | guard let index = Self.allCases.firstIndex(of: self) else { return .none } 16 | let previous = Self.allCases.index(before: index) 17 | guard previous >= Self.allCases.startIndex else { return .none } 18 | return Self.allCases[previous] 19 | } 20 | public enum ToolbarSizeClass: Equatable, CaseIterable { 21 | case compact 22 | case regular 23 | case expanded 24 | public static var allCases: [Self] { 25 | [.compact, .regular] 26 | } 27 | } 28 | case toolbar(ToolbarSizeClass = .regular) 29 | case regular 30 | case compact 31 | var scale: CGSize { 32 | switch self { 33 | case .compact: return CGSize(width: 1, height: 1) 34 | case .regular: return CGSize(width: 1, height: 1) 35 | case .toolbar(let toolbar): 36 | switch toolbar { 37 | case .compact: return CGSize(width: 0.5, height: 0.5) 38 | case .regular: return CGSize(width: 0.75, height: 0.75) 39 | case .expanded: return CGSize(width: 1, height: 1) 40 | } 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /Sources/PerformanceMonitor.swift: -------------------------------------------------------------------------------- 1 | 2 | import SwiftUI 3 | import Combine 4 | 5 | class PerformanceMonitor { 6 | private enum State { 7 | case active 8 | case paused 9 | } 10 | private var state: State = .paused 11 | public private(set) static var shared: PerformanceMonitor = .init() 12 | private let calculator: Calculator 13 | private var receiveingReport: AnyCancellable! 14 | public private(set) lazy var reportObservable: ReportObservable = { .init() }() 15 | public let presentationToggle: Notification.Name = .init("PerformanceMonitorPresentationToggle") 16 | public init( 17 | meteringTime: DispatchQueue.SchedulerTimeType.Stride = .milliseconds(500), 18 | throttle: DispatchQueue.SchedulerTimeType.Stride = .milliseconds(500) 19 | ) { 20 | calculator = Calculator(meteringTime: meteringTime) 21 | receiveingReport = calculator.report 22 | .receive(on: DispatchQueue.main) 23 | .throttle(for: throttle, scheduler: DispatchQueue.main, latest: true) 24 | .sink { [unowned self] report in 25 | guard state == .active else { return } 26 | reportObservable.assign(report) 27 | } 28 | } 29 | public var presentationTogglePublisher: NotificationCenter.Publisher { 30 | NotificationCenter.default.publisher(for: presentationToggle) 31 | } 32 | public var togglePresentationAction: () -> Void {{ [weak self] in 33 | self?.togglePresentation() 34 | }} 35 | public func togglePresentation() { 36 | NotificationCenter.default.post(name: presentationToggle, object: .none) 37 | } 38 | public func resume() { 39 | guard state != .active else { return } 40 | state = .active 41 | calculator.resume() 42 | } 43 | public func pause() { 44 | guard state != .paused else { return } 45 | state = .paused 46 | calculator.pause() 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /Sources/Draggable.swift: -------------------------------------------------------------------------------- 1 | 2 | import SwiftUI 3 | 4 | public struct Draggable: View { 5 | @State private var location: CGPoint = CGPoint(x: 350, y: 350) 6 | @State public var color: Color 7 | 8 | private let content: (_ touch: CGPoint?) -> Content 9 | @GestureState private var fingerLocation: CGPoint? = .none 10 | @GestureState private var startLocation: CGPoint? = .none 11 | 12 | public init( 13 | location: CGPoint = .zero, 14 | color: Color = .clear, 15 | content: @escaping (_ touch: CGPoint?) -> Content 16 | ) { 17 | self.location = location 18 | self.content = content 19 | self.color = color 20 | } 21 | 22 | private var simpleDrag: some Gesture { 23 | DragGesture() 24 | .onChanged { value in 25 | var newLocation = startLocation ?? location 26 | newLocation.x += value.translation.width 27 | newLocation.y += value.translation.height 28 | self.location = newLocation 29 | } 30 | .updating($startLocation) { value, startLocation, transaction in 31 | startLocation = startLocation ?? location 32 | } 33 | } 34 | 35 | private var fingerDrag: some Gesture { 36 | DragGesture() 37 | .updating($fingerLocation) { value, fingerLocation, transaction in 38 | fingerLocation = value.location 39 | } 40 | } 41 | 42 | public var body: some View { 43 | ZStack { 44 | content(fingerLocation) 45 | .position(location) 46 | .gesture( 47 | simpleDrag.simultaneously(with: fingerDrag) 48 | ) 49 | if let fingerLocation = fingerLocation { 50 | Circle() 51 | .stroke(color, lineWidth: 2) 52 | .frame(width: 44, height: 44) 53 | .position(fingerLocation) 54 | } 55 | } 56 | } 57 | } 58 | 59 | 60 | -------------------------------------------------------------------------------- /Sources/GaugesModifier.swift: -------------------------------------------------------------------------------- 1 | 2 | import SwiftUI 3 | 4 | public extension View { 5 | func performanceMonitor( 6 | isPresented: Binding, 7 | shakeGestureTogglesPresentation: Bool = true, 8 | sizeClass: SizeClass = .compact 9 | ) -> some View { 10 | modifier(GaugesModifier( 11 | isPresented: isPresented, 12 | sizeClass: sizeClass, 13 | shakeGestureTogglesPresentation: shakeGestureTogglesPresentation, 14 | menuItems: {} 15 | )) 16 | } 17 | func performanceMonitor( 18 | isPresented: Binding, 19 | sizeClass: SizeClass = .compact, 20 | shakeGestureTogglesPresentation: Bool = true, 21 | @ViewBuilder menuItems: @escaping () -> MenuItems? 22 | ) -> some View { 23 | modifier(GaugesModifier( 24 | isPresented: isPresented, 25 | sizeClass: sizeClass, 26 | shakeGestureTogglesPresentation: shakeGestureTogglesPresentation, 27 | menuItems: menuItems 28 | )) 29 | } 30 | } 31 | 32 | struct GaugesModifier: ViewModifier { 33 | @Binding private var isPresented: Bool 34 | @State private var sizeClass: SizeClass 35 | @ViewBuilder private let menuItems: () -> MenuItems? 36 | private let shakeGestureTogglesPresentation: Bool 37 | init( 38 | isPresented: Binding, 39 | sizeClass: SizeClass, 40 | shakeGestureTogglesPresentation: Bool, 41 | @ViewBuilder menuItems: @escaping () -> MenuItems? 42 | ) { 43 | _isPresented = isPresented 44 | self.sizeClass = sizeClass 45 | self.shakeGestureTogglesPresentation = shakeGestureTogglesPresentation 46 | self.menuItems = menuItems 47 | 48 | } 49 | func body(content: Content) -> some View { 50 | content 51 | .overlay { 52 | if isPresented { 53 | GeometryReader { geometry in 54 | let center = CGPoint(x: geometry.size.width / 2, y: geometry.size.height / 2) 55 | Draggable(location: center) { touch in 56 | GaugesWrapper(monitor: PerformanceMonitor.shared) { 57 | Gauges(sizeClass: sizeClass) 58 | } 59 | .environmentObject(PerformanceMonitor.shared.reportObservable) 60 | .allowsHitTesting(touch == .none) 61 | .onTapGesture(count: 2) { 62 | isPresented.toggle() 63 | } 64 | .contextMenu { 65 | contextMenu 66 | } 67 | } 68 | } 69 | .transition(.opacity) 70 | .animation(.default, value: isPresented) 71 | } else { 72 | EmptyView() 73 | } 74 | } 75 | .onShake { 76 | guard shakeGestureTogglesPresentation else { return } 77 | withAnimation(.default) { 78 | isPresented.toggle() 79 | } 80 | } 81 | .onReceive(PerformanceMonitor.shared.presentationTogglePublisher) { _ in 82 | withAnimation(.default) { 83 | isPresented.toggle() 84 | } 85 | } 86 | } 87 | @ViewBuilder private var contextMenu: some View { 88 | if let menuItems = menuItems() { 89 | menuItems 90 | Divider() 91 | } 92 | Button { 93 | guard let previous = sizeClass.previous() else { return } 94 | withAnimation(.default) { 95 | sizeClass = previous 96 | } 97 | } label: { 98 | Label("Zoom -", systemImage: "minus.circle") 99 | } 100 | .disabled(sizeClass.previous() == .none) 101 | Button { 102 | guard let next = sizeClass.next() else { return } 103 | withAnimation(.default) { 104 | sizeClass = next 105 | } 106 | } label: { 107 | Label("Zoom +", systemImage: "plus.circle") 108 | } 109 | .disabled(sizeClass.next() == .none) 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /Sources/Calculator.swift: -------------------------------------------------------------------------------- 1 | 2 | import Foundation 3 | import Combine 4 | import DisplayLink 5 | 6 | // MARK: - Calculator 7 | final class Calculator { 8 | public let meteringTime: DispatchQueue.SchedulerTimeType.Stride 9 | public let report = PassthroughSubject() 10 | private let linkedFrameList = LinkedFramesList() 11 | private var displayLink: DisplayLink 12 | private var receiveFrame: AnyCancellable! 13 | private var metrics: AnyCancellable! 14 | 15 | init(meteringTime: DispatchQueue.SchedulerTimeType.Stride) { 16 | self.meteringTime = meteringTime 17 | displayLink = .init() 18 | bindDisplayLink() 19 | } 20 | private func bindDisplayLink() { 21 | receiveFrame = displayLink.frameSubject.sink(receiveValue: { [weak self] in 22 | self?.linkedFrameList.append(frameWithTimestamp: $0) 23 | }) 24 | metrics = displayLink.frameSubject 25 | .throttle(for: meteringTime, scheduler: DispatchQueue.main, latest: true) 26 | .sink(receiveValue: { [weak self] _ in 27 | self?.collectMetrics() 28 | }) 29 | } 30 | // MARK: - Execution control 31 | func resume() { 32 | displayLink.activate() 33 | } 34 | func pause() { 35 | displayLink = .init() 36 | bindDisplayLink() 37 | } 38 | // MARK: - Monitoring 39 | private var now: CFTimeInterval { 40 | Double(DispatchTime.now().uptimeNanoseconds) / Double(NSEC_PER_SEC) /// = `1_000_000_000` 41 | } 42 | private func collectMetrics() { 43 | report.send(.init( 44 | cpuUsage: cpuUsage(), 45 | memoryUsage: memoryUsage(), 46 | fps: fps(), 47 | thermalState: thermalState() 48 | )) 49 | } 50 | } 51 | // MARK: - CPU Usage 52 | extension Calculator { 53 | func cpuUsage() -> Double { 54 | var totalUsageOfCPU: Double = 0.0 55 | var threadsList: thread_act_array_t? 56 | var threadsCount = mach_msg_type_number_t(0) 57 | let threadsResult = withUnsafeMutablePointer(to: &threadsList) { 58 | return $0.withMemoryRebound(to: thread_act_array_t?.self, capacity: 1) { 59 | task_threads(mach_task_self_, $0, &threadsCount) 60 | } 61 | } 62 | if threadsResult == KERN_SUCCESS, let threadsList = threadsList { 63 | for index in 0...stride)) 81 | return totalUsageOfCPU 82 | } 83 | } 84 | // MARK: - FPS 85 | extension Calculator { 86 | func fps() -> Int { 87 | linkedFrameList.count 88 | } 89 | } 90 | // MARK: - Memory Usage 91 | extension Calculator { 92 | func memoryUsage() -> PerformanceReport.MemoryUsage { 93 | var taskInfo = task_vm_info_data_t() 94 | var count = mach_msg_type_number_t(MemoryLayout.size) / 4 95 | let result: kern_return_t = withUnsafeMutablePointer(to: &taskInfo) { 96 | $0.withMemoryRebound(to: integer_t.self, capacity: 1) { 97 | task_info(mach_task_self_, task_flavor_t(TASK_VM_INFO), $0, &count) 98 | } 99 | } 100 | var used: UInt64 = 0 101 | if result == KERN_SUCCESS { 102 | used = UInt64(taskInfo.phys_footprint) 103 | } 104 | let total = ProcessInfo.processInfo.physicalMemory 105 | return .init(used: used, total: total) 106 | } 107 | } 108 | // MARK: - Thermal State 109 | extension Calculator { 110 | func thermalState() -> PerformanceReport.ThermalState { 111 | .init(thermalState: ProcessInfo.processInfo.thermalState) 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /Sources/Gauges.swift: -------------------------------------------------------------------------------- 1 | 2 | import SwiftUI 3 | 4 | struct Gauges: View { 5 | @EnvironmentObject private var reportObservable: ReportObservable 6 | @Environment(\.colorScheme) var colorScheme 7 | private var report: PerformanceReport? { reportObservable.report } 8 | let sizeClass: SizeClass 9 | 10 | internal init(sizeClass: SizeClass) { 11 | self.sizeClass = sizeClass 12 | } 13 | private var backgroundColor: Color { 14 | colorScheme == .dark ? .black : .white 15 | } 16 | var body: some View { 17 | ZStack { 18 | if case .regular = sizeClass { 19 | Grid { 20 | GridRow { 21 | cpuAndMemory 22 | } 23 | GridRow { 24 | thermalAndGraphics 25 | } 26 | } 27 | .padding(3) 28 | .background(.ultraThickMaterial, in: RoundedRectangle(cornerRadius: 30, style: .circular)) 29 | } else { 30 | HStack { 31 | cpuAndMemory 32 | thermalAndGraphics 33 | } 34 | .padding(3) 35 | .background(.ultraThickMaterial, in: Capsule(style: .continuous)) 36 | } 37 | } 38 | .scaleEffect(sizeClass.scale, anchor: .center) 39 | .gaugeStyle(.accessoryCircular) 40 | } 41 | @ViewBuilder private var thermalAndGraphics: some View { 42 | if let report { 43 | fps(report.fps) 44 | thermalState(report.thermalState) 45 | } 46 | } 47 | @ViewBuilder private var cpuAndMemory: some View { 48 | if let report { 49 | cpu(report.cpuUsage) 50 | ram(report.memoryUsage) 51 | } 52 | } 53 | @ViewBuilder private func cpu(_ cpu: Double) -> some View { 54 | let colors: [Color] = [.blue, .green, .yellow, .orange, .pink] 55 | let bounds: ClosedRange = 0 ... 100 56 | let value = clamp(value: cpu, cap: 100) 57 | let color = stop(for: value, in: bounds, from: colors) 58 | Gauge( 59 | value: value, 60 | in: bounds, 61 | label: { 62 | Label("CPU", systemImage: "cpu") 63 | }, 64 | currentValueLabel: { 65 | Text("\(Int(cpu))%") 66 | .foregroundColor(color) 67 | } 68 | ) 69 | .tint(Gradient(colors: colors)) 70 | } 71 | @ViewBuilder private func ram(_ usage: PerformanceReport.MemoryUsage) -> some View { 72 | let colors: [Color] = [.blue, .green, .yellow, .orange, .pink] 73 | let bounds = 0 ... Double(usage.total) 74 | let value = Double(clamp(value: usage.used, cap: usage.total)) 75 | let text = formatter.string(fromByteCount: Int64(usage.used)) 76 | let color = stop(for: value, in: bounds, from: colors) 77 | Gauge( 78 | value: value, 79 | in: bounds, 80 | label: { 81 | Label("RAM", systemImage: "memorychip") 82 | }, 83 | currentValueLabel: { 84 | Text(text) 85 | .foregroundColor(color) 86 | } 87 | ) 88 | .tint(Gradient(colors: colors)) 89 | } 90 | @ViewBuilder private func fps(_ fps: Int) -> some View { 91 | let colors: [Color] = [.blue, .green, .yellow, .orange, .pink] 92 | let bounds: ClosedRange = 0 ... maxFPS 93 | let value = clamp(value: fps, cap: bounds.upperBound) 94 | let color = stop(for: value, in: bounds, from: colors.reversed()) 95 | Gauge( 96 | value: Double(value), 97 | in: 0 ... Double(maxFPS), 98 | label: { 99 | Label("FPS", systemImage: "display") 100 | }, 101 | currentValueLabel: { 102 | Text("\(clamp(value: fps, cap: maxFPS))") 103 | .foregroundColor(color) 104 | } 105 | ) 106 | .tint(Gradient(colors: colors.reversed())) 107 | } 108 | @ViewBuilder private func thermalState(_ state: PerformanceReport.ThermalState) -> some View { 109 | let colors: [Color] = [.blue, .green, .orange, .pink] 110 | let bounds = PerformanceReport.ThermalState.nominal.float ... PerformanceReport.ThermalState.critical.float 111 | let value = state.float 112 | let color = stop(for: value, in: bounds, from: colors) 113 | Gauge( 114 | value: state.float, 115 | in: bounds, 116 | label: { 117 | #warning("symbol") 118 | Label("Thermal State", systemImage: state.symbol) 119 | }, 120 | currentValueLabel: { 121 | Text(state.rawValue) 122 | .foregroundColor(color) 123 | } 124 | ) 125 | .tint(Gradient(colors: colors)) 126 | } 127 | } 128 | 129 | 130 | #if os(macOS) 131 | import CoreGraphics.CGDirectDisplay 132 | #elseif os(iOS) 133 | import UIKit.UIScreen 134 | #endif 135 | 136 | fileprivate extension Gauges { 137 | static var maxFPS: Int = { 138 | #if os(macOS) 139 | guard let mode = CGDisplayCopyDisplayMode(CGMainDisplayID()) else { return 0 } 140 | return Int(mode.refreshRate) 141 | #elseif os(iOS) 142 | return UIScreen.main.maximumFramesPerSecond 143 | #endif 144 | }() 145 | var maxFPS: Int { Self.maxFPS } 146 | static var byteCountFormatter: ByteCountFormatter = { 147 | let formatter = ByteCountFormatter() 148 | formatter.allowedUnits = [.useMB, .useGB] 149 | formatter.countStyle = .memory 150 | return formatter 151 | }() 152 | var formatter: ByteCountFormatter { Self.byteCountFormatter } 153 | } 154 | 155 | fileprivate extension PerformanceReport.ThermalState { 156 | var float: Float { 157 | switch self { 158 | case .nominal: return 0 159 | case .fair: return 1 160 | case .serious: return 2 161 | case .critical: return 3 162 | case .unknown: return -1 163 | } 164 | } 165 | var symbol: String { 166 | switch self { 167 | case .nominal: return "thermometer.snowflake" 168 | case .fair: return "thermometer.low" 169 | case .serious: return "thermometer.medium" 170 | case .critical: return "thermometer.high" 171 | case .unknown: return "questionmark" 172 | } 173 | } 174 | } 175 | 176 | fileprivate extension Gauges { 177 | func stop( 178 | for value: Value, 179 | in bounds: ClosedRange, 180 | from items: [Item] 181 | ) -> Item { 182 | let stop = remap(value: value, from: bounds, to: 0 ... Value(items.count - 1)) 183 | let index = min(max(Int(stop), 0), items.count - 1) 184 | return items[index] 185 | } 186 | func stop( 187 | for value: Value, 188 | in bounds: ClosedRange, 189 | from items: [Item] 190 | ) -> Item { 191 | let stop = remap(value: value, from: bounds, to: 0 ... Value(items.count - 1)) 192 | let index = min(max(Int(stop), 0), items.count - 1) 193 | return items[index] 194 | } 195 | func remap( 196 | value: Value, 197 | from source: ClosedRange, 198 | to destination: ClosedRange 199 | ) -> Value { 200 | destination.lowerBound 201 | + (value - source.lowerBound) 202 | * (destination.upperBound - source.lowerBound) 203 | / (source.upperBound - source.lowerBound) 204 | } 205 | func remap( 206 | value: Value, 207 | from source: ClosedRange, 208 | to destination: ClosedRange 209 | ) -> Value { 210 | destination.lowerBound 211 | + (value - source.lowerBound) 212 | * (destination.upperBound - source.lowerBound) 213 | / (source.upperBound - source.lowerBound) 214 | } 215 | func clamp(value: Value, cap: Value) -> Value { 216 | min(value, cap) 217 | } 218 | } 219 | --------------------------------------------------------------------------------