Initial commit: TopManager macOS system monitor
A native SwiftUI app for monitoring macOS system resources: - Processes tab with sorting, filtering, and process control - Apps tab for running applications - Performance tab with CPU, memory, and network charts - Power & Storage tab with system info, GPU, storage, and network Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
22
TopManager/Info.plist
Normal file
@@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
</dict>
|
||||
</plist>
|
||||
64
TopManager/Models/CPUInfo.swift
Normal file
@@ -0,0 +1,64 @@
|
||||
import Foundation
|
||||
|
||||
struct CPUInfo {
|
||||
let globalUsage: Double
|
||||
let userUsage: Double
|
||||
let systemUsage: Double
|
||||
let idleUsage: Double
|
||||
let coreUsages: [CoreUsage]
|
||||
let pCoreUsages: [CoreUsage]
|
||||
let eCoreUsages: [CoreUsage]
|
||||
let timestamp: Date
|
||||
|
||||
init(
|
||||
globalUsage: Double,
|
||||
userUsage: Double,
|
||||
systemUsage: Double,
|
||||
idleUsage: Double,
|
||||
coreUsages: [CoreUsage],
|
||||
pCoreUsages: [CoreUsage] = [],
|
||||
eCoreUsages: [CoreUsage] = [],
|
||||
timestamp: Date = Date()
|
||||
) {
|
||||
self.globalUsage = globalUsage
|
||||
self.userUsage = userUsage
|
||||
self.systemUsage = systemUsage
|
||||
self.idleUsage = idleUsage
|
||||
self.coreUsages = coreUsages
|
||||
self.pCoreUsages = pCoreUsages
|
||||
self.eCoreUsages = eCoreUsages
|
||||
self.timestamp = timestamp
|
||||
}
|
||||
}
|
||||
|
||||
struct CoreUsage: Identifiable {
|
||||
let id: Int
|
||||
let usage: Double
|
||||
let coreType: CoreType
|
||||
|
||||
enum CoreType: String {
|
||||
case performance = "P"
|
||||
case efficiency = "E"
|
||||
case unknown = ""
|
||||
}
|
||||
|
||||
init(id: Int, usage: Double, coreType: CoreType = .unknown) {
|
||||
self.id = id
|
||||
self.usage = usage
|
||||
self.coreType = coreType
|
||||
}
|
||||
}
|
||||
|
||||
struct CPUHistoryPoint: Identifiable {
|
||||
let id = UUID()
|
||||
let timestamp: Date
|
||||
let usage: Double
|
||||
let userUsage: Double
|
||||
let systemUsage: Double
|
||||
}
|
||||
|
||||
struct CoreHistoryPoint: Identifiable {
|
||||
let id = UUID()
|
||||
let timestamp: Date
|
||||
let usage: Double
|
||||
}
|
||||
57
TopManager/Models/DiskInfo.swift
Normal file
@@ -0,0 +1,57 @@
|
||||
import Foundation
|
||||
|
||||
struct DiskInfo {
|
||||
let volumes: [VolumeInfo]
|
||||
let timestamp: Date
|
||||
|
||||
init(volumes: [VolumeInfo], timestamp: Date = Date()) {
|
||||
self.volumes = volumes
|
||||
self.timestamp = timestamp
|
||||
}
|
||||
}
|
||||
|
||||
struct VolumeInfo: Identifiable {
|
||||
let id: String
|
||||
let name: String
|
||||
let mountPoint: String
|
||||
let totalSpace: UInt64
|
||||
let freeSpace: UInt64
|
||||
let usedSpace: UInt64
|
||||
let fileSystem: String
|
||||
let isRemovable: Bool
|
||||
let isInternal: Bool
|
||||
|
||||
var usagePercentage: Double {
|
||||
guard totalSpace > 0 else { return 0 }
|
||||
return Double(usedSpace) / Double(totalSpace) * 100
|
||||
}
|
||||
|
||||
init(
|
||||
name: String,
|
||||
mountPoint: String,
|
||||
totalSpace: UInt64,
|
||||
freeSpace: UInt64,
|
||||
fileSystem: String,
|
||||
isRemovable: Bool,
|
||||
isInternal: Bool
|
||||
) {
|
||||
self.id = mountPoint
|
||||
self.name = name
|
||||
self.mountPoint = mountPoint
|
||||
self.totalSpace = totalSpace
|
||||
self.freeSpace = freeSpace
|
||||
self.usedSpace = totalSpace > freeSpace ? totalSpace - freeSpace : 0
|
||||
self.fileSystem = fileSystem
|
||||
self.isRemovable = isRemovable
|
||||
self.isInternal = isInternal
|
||||
}
|
||||
}
|
||||
|
||||
struct DiskIOStats: Identifiable {
|
||||
let id = UUID()
|
||||
let timestamp: Date
|
||||
let readBytes: UInt64
|
||||
let writeBytes: UInt64
|
||||
let readRate: Double
|
||||
let writeRate: Double
|
||||
}
|
||||
45
TopManager/Models/GPUInfo.swift
Normal file
@@ -0,0 +1,45 @@
|
||||
import Foundation
|
||||
|
||||
struct GPUInfo {
|
||||
let name: String
|
||||
let vramUsed: UInt64
|
||||
let vramTotal: UInt64?
|
||||
let utilizationPercent: Double?
|
||||
let isUnifiedMemory: Bool
|
||||
let coreCount: Int?
|
||||
let timestamp: Date
|
||||
|
||||
var vramUsagePercentage: Double? {
|
||||
guard let total = vramTotal, total > 0 else { return nil }
|
||||
return Double(vramUsed) / Double(total) * 100
|
||||
}
|
||||
|
||||
var memoryLabel: String {
|
||||
isUnifiedMemory ? "GPU Memory" : "VRAM"
|
||||
}
|
||||
|
||||
init(
|
||||
name: String,
|
||||
vramUsed: UInt64,
|
||||
vramTotal: UInt64?,
|
||||
utilizationPercent: Double?,
|
||||
isUnifiedMemory: Bool = false,
|
||||
coreCount: Int? = nil,
|
||||
timestamp: Date = Date()
|
||||
) {
|
||||
self.name = name
|
||||
self.vramUsed = vramUsed
|
||||
self.vramTotal = vramTotal
|
||||
self.utilizationPercent = utilizationPercent
|
||||
self.isUnifiedMemory = isUnifiedMemory
|
||||
self.coreCount = coreCount
|
||||
self.timestamp = timestamp
|
||||
}
|
||||
}
|
||||
|
||||
struct GPUHistoryPoint: Identifiable {
|
||||
let id = UUID()
|
||||
let timestamp: Date
|
||||
let vramUsed: UInt64
|
||||
let utilizationPercent: Double?
|
||||
}
|
||||
81
TopManager/Models/MemoryInfo.swift
Normal file
@@ -0,0 +1,81 @@
|
||||
import Foundation
|
||||
|
||||
struct MemoryInfo {
|
||||
let totalMemory: UInt64
|
||||
let usedMemory: UInt64
|
||||
let freeMemory: UInt64
|
||||
let activeMemory: UInt64
|
||||
let inactiveMemory: UInt64
|
||||
let wiredMemory: UInt64
|
||||
let compressedMemory: UInt64
|
||||
let cachedMemory: UInt64
|
||||
let swapUsed: UInt64
|
||||
let swapTotal: UInt64
|
||||
let memoryPressure: MemoryPressure
|
||||
let timestamp: Date
|
||||
|
||||
var usagePercentage: Double {
|
||||
guard totalMemory > 0 else { return 0 }
|
||||
return Double(usedMemory) / Double(totalMemory) * 100
|
||||
}
|
||||
|
||||
var appMemory: UInt64 {
|
||||
activeMemory + inactiveMemory
|
||||
}
|
||||
|
||||
init(
|
||||
totalMemory: UInt64,
|
||||
usedMemory: UInt64,
|
||||
freeMemory: UInt64,
|
||||
activeMemory: UInt64,
|
||||
inactiveMemory: UInt64,
|
||||
wiredMemory: UInt64,
|
||||
compressedMemory: UInt64,
|
||||
cachedMemory: UInt64,
|
||||
swapUsed: UInt64,
|
||||
swapTotal: UInt64,
|
||||
memoryPressure: MemoryPressure,
|
||||
timestamp: Date = Date()
|
||||
) {
|
||||
self.totalMemory = totalMemory
|
||||
self.usedMemory = usedMemory
|
||||
self.freeMemory = freeMemory
|
||||
self.activeMemory = activeMemory
|
||||
self.inactiveMemory = inactiveMemory
|
||||
self.wiredMemory = wiredMemory
|
||||
self.compressedMemory = compressedMemory
|
||||
self.cachedMemory = cachedMemory
|
||||
self.swapUsed = swapUsed
|
||||
self.swapTotal = swapTotal
|
||||
self.memoryPressure = memoryPressure
|
||||
self.timestamp = timestamp
|
||||
}
|
||||
}
|
||||
|
||||
enum MemoryPressure: String {
|
||||
case nominal = "Normal"
|
||||
case warning = "Warning"
|
||||
case critical = "Critical"
|
||||
case unknown = "Unknown"
|
||||
|
||||
var color: String {
|
||||
switch self {
|
||||
case .nominal: return "green"
|
||||
case .warning: return "yellow"
|
||||
case .critical: return "red"
|
||||
case .unknown: return "gray"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct MemoryHistoryPoint: Identifiable {
|
||||
let id = UUID()
|
||||
let timestamp: Date
|
||||
let usedMemory: UInt64
|
||||
let totalMemory: UInt64
|
||||
|
||||
var usagePercentage: Double {
|
||||
guard totalMemory > 0 else { return 0 }
|
||||
return Double(usedMemory) / Double(totalMemory) * 100
|
||||
}
|
||||
}
|
||||
63
TopManager/Models/NetworkInfo.swift
Normal file
@@ -0,0 +1,63 @@
|
||||
import Foundation
|
||||
|
||||
struct NetworkInfo {
|
||||
let interfaces: [NetworkInterface]
|
||||
let totalDownloadRate: Double
|
||||
let totalUploadRate: Double
|
||||
let totalDownloadBytes: UInt64
|
||||
let totalUploadBytes: UInt64
|
||||
let timestamp: Date
|
||||
|
||||
init(
|
||||
interfaces: [NetworkInterface],
|
||||
totalDownloadRate: Double,
|
||||
totalUploadRate: Double,
|
||||
totalDownloadBytes: UInt64,
|
||||
totalUploadBytes: UInt64,
|
||||
timestamp: Date = Date()
|
||||
) {
|
||||
self.interfaces = interfaces
|
||||
self.totalDownloadRate = totalDownloadRate
|
||||
self.totalUploadRate = totalUploadRate
|
||||
self.totalDownloadBytes = totalDownloadBytes
|
||||
self.totalUploadBytes = totalUploadBytes
|
||||
self.timestamp = timestamp
|
||||
}
|
||||
}
|
||||
|
||||
struct NetworkInterface: Identifiable {
|
||||
let id: String
|
||||
let name: String
|
||||
let displayName: String
|
||||
let downloadBytes: UInt64
|
||||
let uploadBytes: UInt64
|
||||
let downloadRate: Double
|
||||
let uploadRate: Double
|
||||
let isActive: Bool
|
||||
|
||||
init(
|
||||
name: String,
|
||||
displayName: String,
|
||||
downloadBytes: UInt64,
|
||||
uploadBytes: UInt64,
|
||||
downloadRate: Double,
|
||||
uploadRate: Double,
|
||||
isActive: Bool
|
||||
) {
|
||||
self.id = name
|
||||
self.name = name
|
||||
self.displayName = displayName
|
||||
self.downloadBytes = downloadBytes
|
||||
self.uploadBytes = uploadBytes
|
||||
self.downloadRate = downloadRate
|
||||
self.uploadRate = uploadRate
|
||||
self.isActive = isActive
|
||||
}
|
||||
}
|
||||
|
||||
struct NetworkHistoryPoint: Identifiable {
|
||||
let id = UUID()
|
||||
let timestamp: Date
|
||||
let downloadRate: Double
|
||||
let uploadRate: Double
|
||||
}
|
||||
73
TopManager/Models/ProcessInfo.swift
Normal file
@@ -0,0 +1,73 @@
|
||||
import Foundation
|
||||
import AppKit
|
||||
|
||||
struct ProcessItem: Identifiable, Hashable {
|
||||
let id: pid_t
|
||||
let pid: pid_t
|
||||
let name: String
|
||||
let user: String
|
||||
let cpuUsage: Double
|
||||
let memoryUsage: Int64
|
||||
let threadCount: Int32
|
||||
let state: ProcessState
|
||||
let icon: NSImage?
|
||||
let parentPid: pid_t
|
||||
let startTime: Date?
|
||||
|
||||
var iconPlaceholder: String { "" }
|
||||
|
||||
init(
|
||||
pid: pid_t,
|
||||
name: String,
|
||||
user: String,
|
||||
cpuUsage: Double,
|
||||
memoryUsage: Int64,
|
||||
threadCount: Int32,
|
||||
state: ProcessState,
|
||||
icon: NSImage?,
|
||||
parentPid: pid_t,
|
||||
startTime: Date?
|
||||
) {
|
||||
self.id = pid
|
||||
self.pid = pid
|
||||
self.name = name
|
||||
self.user = user
|
||||
self.cpuUsage = cpuUsage
|
||||
self.memoryUsage = memoryUsage
|
||||
self.threadCount = threadCount
|
||||
self.state = state
|
||||
self.icon = icon
|
||||
self.parentPid = parentPid
|
||||
self.startTime = startTime
|
||||
}
|
||||
|
||||
func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(pid)
|
||||
}
|
||||
|
||||
static func == (lhs: ProcessItem, rhs: ProcessItem) -> Bool {
|
||||
lhs.pid == rhs.pid &&
|
||||
lhs.cpuUsage == rhs.cpuUsage &&
|
||||
lhs.memoryUsage == rhs.memoryUsage &&
|
||||
lhs.threadCount == rhs.threadCount &&
|
||||
lhs.state == rhs.state
|
||||
}
|
||||
}
|
||||
|
||||
enum ProcessState: String {
|
||||
case running = "Running"
|
||||
case sleeping = "Sleeping"
|
||||
case stopped = "Stopped"
|
||||
case zombie = "Zombie"
|
||||
case unknown = "Unknown"
|
||||
|
||||
var symbol: String {
|
||||
switch self {
|
||||
case .running: return "play.circle.fill"
|
||||
case .sleeping: return "moon.fill"
|
||||
case .stopped: return "pause.circle.fill"
|
||||
case .zombie: return "exclamationmark.triangle.fill"
|
||||
case .unknown: return "questionmark.circle"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "icon_16x16.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "16x16"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_16x16@2x.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "16x16"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_32x32.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "32x32"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_32x32@2x.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "32x32"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_128x128.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "128x128"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_128x128@2x.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "128x128"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_256x256.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "256x256"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_256x256@2x.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "256x256"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_512x512.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "512x512"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_512x512@2x.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "512x512"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BINáris
TopManager/Resources/Assets.xcassets/AppIcon.appiconset/icon_128x128.png
Normal file
|
After Width: | Height: | Méret: 27 KiB |
BINáris
TopManager/Resources/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png
Normal file
|
After Width: | Height: | Méret: 27 KiB |
BINáris
TopManager/Resources/Assets.xcassets/AppIcon.appiconset/icon_16x16.png
Normal file
|
After Width: | Height: | Méret: 969 B |
BINáris
TopManager/Resources/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png
Normal file
|
After Width: | Height: | Méret: 969 B |
BINáris
TopManager/Resources/Assets.xcassets/AppIcon.appiconset/icon_256x256.png
Normal file
|
After Width: | Height: | Méret: 99 KiB |
BINáris
TopManager/Resources/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png
Normal file
|
After Width: | Height: | Méret: 99 KiB |
BINáris
TopManager/Resources/Assets.xcassets/AppIcon.appiconset/icon_32x32.png
Normal file
|
After Width: | Height: | Méret: 2.6 KiB |
BINáris
TopManager/Resources/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png
Normal file
|
After Width: | Height: | Méret: 2.6 KiB |
BINáris
TopManager/Resources/Assets.xcassets/AppIcon.appiconset/icon_512x512.png
Normal file
|
After Width: | Height: | Méret: 385 KiB |
BINáris
TopManager/Resources/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png
Normal file
|
After Width: | Height: | Méret: 337 KiB |
6
TopManager/Resources/Assets.xcassets/Contents.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
180
TopManager/Services/CPUMonitor.swift
Normal file
@@ -0,0 +1,180 @@
|
||||
import Foundation
|
||||
import Darwin
|
||||
|
||||
final class CPUMonitor {
|
||||
private var previousCpuInfo: [processor_cpu_load_info]?
|
||||
private var previousGlobalInfo: host_cpu_load_info?
|
||||
private var pCoreCount: Int = 0
|
||||
private var eCoreCount: Int = 0
|
||||
private var isAppleSiliconCPU: Bool = false
|
||||
|
||||
init() {
|
||||
detectCPUArchitecture()
|
||||
}
|
||||
|
||||
private func detectCPUArchitecture() {
|
||||
var sysinfo = utsname()
|
||||
uname(&sysinfo)
|
||||
let machine = withUnsafePointer(to: &sysinfo.machine) {
|
||||
$0.withMemoryRebound(to: CChar.self, capacity: 1) {
|
||||
String(cString: $0)
|
||||
}
|
||||
}
|
||||
isAppleSiliconCPU = machine.hasPrefix("arm64")
|
||||
|
||||
if isAppleSiliconCPU {
|
||||
detectPerfLevelCounts()
|
||||
}
|
||||
}
|
||||
|
||||
private func detectPerfLevelCounts() {
|
||||
var pCores: Int32 = 0
|
||||
var eCores: Int32 = 0
|
||||
var size = MemoryLayout<Int32>.size
|
||||
|
||||
if sysctlbyname("hw.perflevel1.logicalcpu", &pCores, &size, nil, 0) == 0,
|
||||
sysctlbyname("hw.perflevel0.logicalcpu", &eCores, &size, nil, 0) == 0 {
|
||||
self.pCoreCount = Int(pCores)
|
||||
self.eCoreCount = Int(eCores)
|
||||
}
|
||||
}
|
||||
|
||||
func fetchCPUInfo() -> CPUInfo? {
|
||||
guard let perCoreUsages = fetchPerCoreUsages(),
|
||||
let globalUsage = fetchGlobalUsage() else {
|
||||
return nil
|
||||
}
|
||||
|
||||
var pCoreUsages: [CoreUsage] = []
|
||||
var eCoreUsages: [CoreUsage] = []
|
||||
|
||||
if isAppleSiliconCPU && pCoreCount > 0 && eCoreCount > 0 {
|
||||
for core in perCoreUsages {
|
||||
if core.id < pCoreCount {
|
||||
pCoreUsages.append(CoreUsage(id: core.id, usage: core.usage, coreType: .performance))
|
||||
} else {
|
||||
eCoreUsages.append(CoreUsage(id: core.id, usage: core.usage, coreType: .efficiency))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return CPUInfo(
|
||||
globalUsage: globalUsage.usage,
|
||||
userUsage: globalUsage.user,
|
||||
systemUsage: globalUsage.system,
|
||||
idleUsage: globalUsage.idle,
|
||||
coreUsages: perCoreUsages,
|
||||
pCoreUsages: pCoreUsages,
|
||||
eCoreUsages: eCoreUsages
|
||||
)
|
||||
}
|
||||
|
||||
private func fetchPerCoreUsages() -> [CoreUsage]? {
|
||||
var numCPUs: natural_t = 0
|
||||
var cpuInfo: processor_info_array_t?
|
||||
var numCpuInfo: mach_msg_type_number_t = 0
|
||||
|
||||
let result = host_processor_info(
|
||||
mach_host_self(),
|
||||
PROCESSOR_CPU_LOAD_INFO,
|
||||
&numCPUs,
|
||||
&cpuInfo,
|
||||
&numCpuInfo
|
||||
)
|
||||
|
||||
guard result == KERN_SUCCESS, let cpuInfo = cpuInfo else {
|
||||
return nil
|
||||
}
|
||||
|
||||
defer {
|
||||
vm_deallocate(
|
||||
mach_task_self_,
|
||||
vm_address_t(bitPattern: cpuInfo),
|
||||
vm_size_t(Int(numCpuInfo) * MemoryLayout<integer_t>.size)
|
||||
)
|
||||
}
|
||||
|
||||
var coreUsages: [CoreUsage] = []
|
||||
let cpuLoadInfo = cpuInfo.withMemoryRebound(to: processor_cpu_load_info.self, capacity: Int(numCPUs)) { ptr in
|
||||
Array(UnsafeBufferPointer(start: ptr, count: Int(numCPUs)))
|
||||
}
|
||||
|
||||
for (index, currentInfo) in cpuLoadInfo.enumerated() {
|
||||
var usage = 0.0
|
||||
|
||||
if let previous = previousCpuInfo, index < previous.count {
|
||||
let prevInfo = previous[index]
|
||||
|
||||
let userDelta = Int64(currentInfo.cpu_ticks.0) - Int64(prevInfo.cpu_ticks.0)
|
||||
let systemDelta = Int64(currentInfo.cpu_ticks.1) - Int64(prevInfo.cpu_ticks.1)
|
||||
let idleDelta = Int64(currentInfo.cpu_ticks.2) - Int64(prevInfo.cpu_ticks.2)
|
||||
let niceDelta = Int64(currentInfo.cpu_ticks.3) - Int64(prevInfo.cpu_ticks.3)
|
||||
|
||||
let totalDelta = userDelta + systemDelta + idleDelta + niceDelta
|
||||
if totalDelta > 0 {
|
||||
usage = Double(userDelta + systemDelta) / Double(totalDelta) * 100
|
||||
}
|
||||
}
|
||||
|
||||
let coreType: CoreUsage.CoreType
|
||||
if isAppleSiliconCPU && pCoreCount > 0 {
|
||||
coreType = index < pCoreCount ? .performance : .efficiency
|
||||
} else {
|
||||
coreType = .unknown
|
||||
}
|
||||
|
||||
coreUsages.append(CoreUsage(id: index, usage: usage, coreType: coreType))
|
||||
}
|
||||
|
||||
previousCpuInfo = cpuLoadInfo
|
||||
return coreUsages
|
||||
}
|
||||
|
||||
private func fetchGlobalUsage() -> (usage: Double, user: Double, system: Double, idle: Double)? {
|
||||
var size = mach_msg_type_number_t(MemoryLayout<host_cpu_load_info_data_t>.size / MemoryLayout<integer_t>.size)
|
||||
var cpuLoadInfo = host_cpu_load_info_data_t()
|
||||
|
||||
let result = withUnsafeMutablePointer(to: &cpuLoadInfo) { ptr in
|
||||
ptr.withMemoryRebound(to: integer_t.self, capacity: Int(size)) { intPtr in
|
||||
host_statistics(mach_host_self(), HOST_CPU_LOAD_INFO, intPtr, &size)
|
||||
}
|
||||
}
|
||||
|
||||
guard result == KERN_SUCCESS else { return nil }
|
||||
|
||||
var usage = 0.0
|
||||
var userPct = 0.0
|
||||
var systemPct = 0.0
|
||||
var idlePct = 0.0
|
||||
|
||||
if let previous = previousGlobalInfo {
|
||||
let userDelta = Int64(cpuLoadInfo.cpu_ticks.0) - Int64(previous.cpu_ticks.0)
|
||||
let systemDelta = Int64(cpuLoadInfo.cpu_ticks.1) - Int64(previous.cpu_ticks.1)
|
||||
let idleDelta = Int64(cpuLoadInfo.cpu_ticks.2) - Int64(previous.cpu_ticks.2)
|
||||
let niceDelta = Int64(cpuLoadInfo.cpu_ticks.3) - Int64(previous.cpu_ticks.3)
|
||||
|
||||
let totalDelta = userDelta + systemDelta + idleDelta + niceDelta
|
||||
if totalDelta > 0 {
|
||||
userPct = Double(userDelta + niceDelta) / Double(totalDelta) * 100
|
||||
systemPct = Double(systemDelta) / Double(totalDelta) * 100
|
||||
idlePct = Double(idleDelta) / Double(totalDelta) * 100
|
||||
usage = userPct + systemPct
|
||||
}
|
||||
}
|
||||
|
||||
previousGlobalInfo = cpuLoadInfo
|
||||
return (usage, userPct, systemPct, idlePct)
|
||||
}
|
||||
|
||||
var isAppleSilicon: Bool {
|
||||
isAppleSiliconCPU
|
||||
}
|
||||
|
||||
var performanceCoreCount: Int {
|
||||
pCoreCount
|
||||
}
|
||||
|
||||
var efficiencyCoreCount: Int {
|
||||
eCoreCount
|
||||
}
|
||||
}
|
||||
80
TopManager/Services/DiskMonitor.swift
Normal file
@@ -0,0 +1,80 @@
|
||||
import Foundation
|
||||
|
||||
final class DiskMonitor {
|
||||
func fetchDiskInfo() -> DiskInfo {
|
||||
let volumes = fetchVolumes()
|
||||
return DiskInfo(volumes: volumes)
|
||||
}
|
||||
|
||||
private func fetchVolumes() -> [VolumeInfo] {
|
||||
var volumes: [VolumeInfo] = []
|
||||
|
||||
let fileManager = FileManager.default
|
||||
guard let mountedVolumeURLs = fileManager.mountedVolumeURLs(
|
||||
includingResourceValuesForKeys: [
|
||||
.volumeNameKey,
|
||||
.volumeTotalCapacityKey,
|
||||
.volumeAvailableCapacityKey,
|
||||
.volumeIsRemovableKey,
|
||||
.volumeIsInternalKey,
|
||||
.volumeLocalizedFormatDescriptionKey
|
||||
],
|
||||
options: [.skipHiddenVolumes]
|
||||
) else {
|
||||
return []
|
||||
}
|
||||
|
||||
for volumeURL in mountedVolumeURLs {
|
||||
do {
|
||||
let resourceValues = try volumeURL.resourceValues(forKeys: [
|
||||
.volumeNameKey,
|
||||
.volumeTotalCapacityKey,
|
||||
.volumeAvailableCapacityKey,
|
||||
.volumeIsRemovableKey,
|
||||
.volumeIsInternalKey,
|
||||
.volumeLocalizedFormatDescriptionKey
|
||||
])
|
||||
|
||||
let name = resourceValues.volumeName ?? volumeURL.lastPathComponent
|
||||
let totalSpace = UInt64(resourceValues.volumeTotalCapacity ?? 0)
|
||||
let freeSpace = UInt64(resourceValues.volumeAvailableCapacity ?? 0)
|
||||
let isRemovable = resourceValues.volumeIsRemovable ?? false
|
||||
let isInternal = resourceValues.volumeIsInternal ?? true
|
||||
let fileSystem = resourceValues.volumeLocalizedFormatDescription ?? "Unknown"
|
||||
|
||||
let mountPoint = volumeURL.path
|
||||
|
||||
// Skip system volumes
|
||||
guard shouldIncludeVolume(mountPoint: mountPoint) else {
|
||||
continue
|
||||
}
|
||||
|
||||
volumes.append(VolumeInfo(
|
||||
name: name,
|
||||
mountPoint: mountPoint,
|
||||
totalSpace: totalSpace,
|
||||
freeSpace: freeSpace,
|
||||
fileSystem: fileSystem,
|
||||
isRemovable: isRemovable,
|
||||
isInternal: isInternal
|
||||
))
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return volumes.sorted { $0.mountPoint < $1.mountPoint }
|
||||
}
|
||||
|
||||
private func shouldIncludeVolume(mountPoint: String) -> Bool {
|
||||
let excludedPaths = [
|
||||
"/System/Volumes/VM",
|
||||
"/System/Volumes/Preboot",
|
||||
"/System/Volumes/Update",
|
||||
"/System/Volumes/xarts",
|
||||
"/System/Volumes/iSCPreboot",
|
||||
"/System/Volumes/Hardware"
|
||||
]
|
||||
return !excludedPaths.contains(where: { mountPoint.hasPrefix($0) })
|
||||
}
|
||||
}
|
||||
253
TopManager/Services/GPUMonitor.swift
Normal file
@@ -0,0 +1,253 @@
|
||||
import Foundation
|
||||
import IOKit
|
||||
import Metal
|
||||
|
||||
final class GPUMonitor {
|
||||
private let isAppleSilicon: Bool
|
||||
private let chipName: String
|
||||
private let gpuCoreCount: Int?
|
||||
|
||||
init() {
|
||||
var sysinfo = utsname()
|
||||
uname(&sysinfo)
|
||||
let machine = withUnsafePointer(to: &sysinfo.machine) {
|
||||
$0.withMemoryRebound(to: CChar.self, capacity: 1) {
|
||||
String(cString: $0)
|
||||
}
|
||||
}
|
||||
isAppleSilicon = machine.hasPrefix("arm64")
|
||||
chipName = GPUMonitor.getChipName()
|
||||
gpuCoreCount = GPUMonitor.getGPUCoreCount()
|
||||
}
|
||||
|
||||
private static func getGPUCoreCount() -> Int? {
|
||||
// Try to get GPU core count from Metal
|
||||
guard let device = MTLCreateSystemDefaultDevice() else { return nil }
|
||||
|
||||
// For Apple Silicon, we can infer from the chip name or use Metal properties
|
||||
// Metal doesn't directly expose core count, but we can check recommended working set size
|
||||
// as a proxy or use the chip name to look up known values
|
||||
|
||||
// Try IOKit for more detailed info
|
||||
let matchingDict = IOServiceMatching("AGXAccelerator")
|
||||
var iterator: io_iterator_t = 0
|
||||
|
||||
if IOServiceGetMatchingServices(kIOMainPortDefault, matchingDict, &iterator) == KERN_SUCCESS {
|
||||
defer { IOObjectRelease(iterator) }
|
||||
|
||||
let service = IOIteratorNext(iterator)
|
||||
if service != 0 {
|
||||
defer { IOObjectRelease(service) }
|
||||
|
||||
if let props = IORegistryEntryCreateCFProperty(
|
||||
service,
|
||||
"gpu-core-count" as CFString,
|
||||
kCFAllocatorDefault,
|
||||
0
|
||||
)?.takeRetainedValue() as? Int {
|
||||
return props
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: infer from device name
|
||||
let name = device.name.lowercased()
|
||||
if name.contains("m3 max") { return 40 }
|
||||
if name.contains("m3 pro") { return 18 }
|
||||
if name.contains("m3") { return 10 }
|
||||
if name.contains("m2 ultra") { return 76 }
|
||||
if name.contains("m2 max") { return 38 }
|
||||
if name.contains("m2 pro") { return 19 }
|
||||
if name.contains("m2") { return 10 }
|
||||
if name.contains("m1 ultra") { return 64 }
|
||||
if name.contains("m1 max") { return 32 }
|
||||
if name.contains("m1 pro") { return 16 }
|
||||
if name.contains("m1") { return 8 }
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func getChipName() -> String {
|
||||
var size: size_t = 0
|
||||
sysctlbyname("machdep.cpu.brand_string", nil, &size, nil, 0)
|
||||
|
||||
var brand = [CChar](repeating: 0, count: size)
|
||||
sysctlbyname("machdep.cpu.brand_string", &brand, &size, nil, 0)
|
||||
|
||||
let brandString = String(cString: brand)
|
||||
|
||||
// For Apple Silicon, this returns "Apple M1", "Apple M2", etc.
|
||||
if brandString.hasPrefix("Apple") {
|
||||
return brandString
|
||||
}
|
||||
|
||||
// Fallback: try to get chip from IOKit
|
||||
return getAppleSiliconChipName() ?? "Apple GPU"
|
||||
}
|
||||
|
||||
private static func getAppleSiliconChipName() -> String? {
|
||||
let matchingDict = IOServiceMatching("IOPlatformExpertDevice")
|
||||
var service: io_service_t = 0
|
||||
|
||||
guard IOServiceGetMatchingServices(kIOMainPortDefault, matchingDict, &service) == KERN_SUCCESS else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let platformExpert = IOServiceGetMatchingService(kIOMainPortDefault, IOServiceMatching("IOPlatformExpertDevice"))
|
||||
defer { IOObjectRelease(platformExpert) }
|
||||
|
||||
if let modelData = IORegistryEntryCreateCFProperty(
|
||||
platformExpert,
|
||||
"model" as CFString,
|
||||
kCFAllocatorDefault,
|
||||
0
|
||||
)?.takeRetainedValue() as? Data {
|
||||
let model = String(data: modelData, encoding: .utf8)?.trimmingCharacters(in: .controlCharacters) ?? ""
|
||||
|
||||
// Map model identifiers to chip names
|
||||
if model.contains("Mac14") || model.contains("Mac15") {
|
||||
return "Apple M2/M3"
|
||||
} else if model.contains("Mac13") {
|
||||
return "Apple M2"
|
||||
} else if model.contains("Mac12") || model.contains("MacBookAir10") || model.contains("MacBookPro17") || model.contains("MacBookPro18") || model.contains("Macmini9") || model.contains("iMac21") {
|
||||
return "Apple M1"
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func fetchGPUInfo() -> GPUInfo? {
|
||||
if isAppleSilicon {
|
||||
return fetchAppleSiliconGPUInfo()
|
||||
} else {
|
||||
return fetchDiscreteGPUInfo()
|
||||
}
|
||||
}
|
||||
|
||||
private func fetchAppleSiliconGPUInfo() -> GPUInfo? {
|
||||
// Apple Silicon uses unified memory - get memory pressure instead
|
||||
let totalMemory = ProcessInfo.processInfo.physicalMemory
|
||||
|
||||
// Try to get GPU utilization from IOKit
|
||||
var utilizationPercent: Double? = nil
|
||||
var inUseSystemMemory: UInt64 = 0
|
||||
|
||||
let matchingDict = IOServiceMatching("IOAccelerator")
|
||||
var iterator: io_iterator_t = 0
|
||||
|
||||
if IOServiceGetMatchingServices(kIOMainPortDefault, matchingDict, &iterator) == KERN_SUCCESS {
|
||||
defer { IOObjectRelease(iterator) }
|
||||
|
||||
var service = IOIteratorNext(iterator)
|
||||
while service != 0 {
|
||||
defer {
|
||||
IOObjectRelease(service)
|
||||
service = IOIteratorNext(iterator)
|
||||
}
|
||||
|
||||
if let props = IORegistryEntryCreateCFProperty(
|
||||
service,
|
||||
"PerformanceStatistics" as CFString,
|
||||
kCFAllocatorDefault,
|
||||
0
|
||||
)?.takeRetainedValue() as? [String: Any] {
|
||||
|
||||
// Try various keys for GPU memory on Apple Silicon
|
||||
if let inUse = props["In use system memory"] as? UInt64 {
|
||||
inUseSystemMemory = inUse
|
||||
} else if let inUse = props["Alloc system memory"] as? UInt64 {
|
||||
inUseSystemMemory = inUse
|
||||
} else if let inUse = props["inUseSystemMemory"] as? UInt64 {
|
||||
inUseSystemMemory = inUse
|
||||
}
|
||||
|
||||
// GPU utilization
|
||||
if let util = props["Device Utilization %"] as? Int {
|
||||
utilizationPercent = Double(util)
|
||||
} else if let util = props["GPU Activity(%)"] as? Int {
|
||||
utilizationPercent = Double(util)
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// For Apple Silicon, show unified memory usage
|
||||
return GPUInfo(
|
||||
name: chipName + " GPU",
|
||||
vramUsed: inUseSystemMemory,
|
||||
vramTotal: inUseSystemMemory > 0 ? totalMemory : nil,
|
||||
utilizationPercent: utilizationPercent,
|
||||
isUnifiedMemory: true,
|
||||
coreCount: gpuCoreCount
|
||||
)
|
||||
}
|
||||
|
||||
private func fetchDiscreteGPUInfo() -> GPUInfo? {
|
||||
let matchingDict = IOServiceMatching("IOAccelerator")
|
||||
|
||||
var iterator: io_iterator_t = 0
|
||||
guard IOServiceGetMatchingServices(kIOMainPortDefault, matchingDict, &iterator) == KERN_SUCCESS else {
|
||||
return nil
|
||||
}
|
||||
defer { IOObjectRelease(iterator) }
|
||||
|
||||
var service = IOIteratorNext(iterator)
|
||||
while service != 0 {
|
||||
defer {
|
||||
IOObjectRelease(service)
|
||||
service = IOIteratorNext(iterator)
|
||||
}
|
||||
|
||||
var name = "GPU"
|
||||
if let modelData = IORegistryEntryCreateCFProperty(
|
||||
service,
|
||||
"model" as CFString,
|
||||
kCFAllocatorDefault,
|
||||
0
|
||||
)?.takeRetainedValue() as? Data {
|
||||
name = String(data: modelData, encoding: .utf8)?
|
||||
.trimmingCharacters(in: .controlCharacters) ?? "GPU"
|
||||
}
|
||||
|
||||
if let props = IORegistryEntryCreateCFProperty(
|
||||
service,
|
||||
"PerformanceStatistics" as CFString,
|
||||
kCFAllocatorDefault,
|
||||
0
|
||||
)?.takeRetainedValue() as? [String: Any] {
|
||||
|
||||
let vramUsed = (props["vramUsedBytes"] as? UInt64)
|
||||
?? (props["VRAM,totalMB"] as? UInt64).map { $0 * 1024 * 1024 }
|
||||
?? (props["inUseVidMemoryBytes"] as? UInt64)
|
||||
?? 0
|
||||
|
||||
let vramFree = props["vramFreeBytes"] as? UInt64
|
||||
let vramTotal: UInt64?
|
||||
|
||||
if let free = vramFree {
|
||||
vramTotal = vramUsed + free
|
||||
} else if let totalMB = props["VRAM,totalMB"] as? UInt64 {
|
||||
vramTotal = totalMB * 1024 * 1024
|
||||
} else {
|
||||
vramTotal = nil
|
||||
}
|
||||
|
||||
let utilization = props["Device Utilization %"] as? Double
|
||||
?? props["GPU Core Utilization"] as? Double
|
||||
|
||||
return GPUInfo(
|
||||
name: name,
|
||||
vramUsed: vramUsed,
|
||||
vramTotal: vramTotal,
|
||||
utilizationPercent: utilization,
|
||||
isUnifiedMemory: false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
97
TopManager/Services/MemoryMonitor.swift
Normal file
@@ -0,0 +1,97 @@
|
||||
import Foundation
|
||||
import Darwin
|
||||
|
||||
final class MemoryMonitor {
|
||||
private let pageSize: UInt64
|
||||
|
||||
init() {
|
||||
var pageSizeValue: vm_size_t = 0
|
||||
host_page_size(mach_host_self(), &pageSizeValue)
|
||||
pageSize = UInt64(pageSizeValue)
|
||||
}
|
||||
|
||||
func fetchMemoryInfo() -> MemoryInfo? {
|
||||
guard let vmStats = fetchVMStatistics() else { return nil }
|
||||
|
||||
let totalMemory = ProcessInfo.processInfo.physicalMemory
|
||||
let freeMemory = vmStats.freePages * pageSize
|
||||
let activeMemory = vmStats.activePages * pageSize
|
||||
let inactiveMemory = vmStats.inactivePages * pageSize
|
||||
let wiredMemory = vmStats.wiredPages * pageSize
|
||||
let compressedMemory = vmStats.compressedPages * pageSize
|
||||
let cachedMemory = vmStats.cachedPages * pageSize
|
||||
|
||||
let usedMemory = activeMemory + wiredMemory + compressedMemory
|
||||
|
||||
let swapInfo = fetchSwapInfo()
|
||||
let memoryPressure = fetchMemoryPressure()
|
||||
|
||||
return MemoryInfo(
|
||||
totalMemory: totalMemory,
|
||||
usedMemory: usedMemory,
|
||||
freeMemory: freeMemory,
|
||||
activeMemory: activeMemory,
|
||||
inactiveMemory: inactiveMemory,
|
||||
wiredMemory: wiredMemory,
|
||||
compressedMemory: compressedMemory,
|
||||
cachedMemory: cachedMemory,
|
||||
swapUsed: swapInfo.used,
|
||||
swapTotal: swapInfo.total,
|
||||
memoryPressure: memoryPressure
|
||||
)
|
||||
}
|
||||
|
||||
private func fetchVMStatistics() -> (
|
||||
freePages: UInt64,
|
||||
activePages: UInt64,
|
||||
inactivePages: UInt64,
|
||||
wiredPages: UInt64,
|
||||
compressedPages: UInt64,
|
||||
cachedPages: UInt64
|
||||
)? {
|
||||
var stats = vm_statistics64_data_t()
|
||||
var count = mach_msg_type_number_t(MemoryLayout<vm_statistics64_data_t>.size / MemoryLayout<integer_t>.size)
|
||||
|
||||
let result = withUnsafeMutablePointer(to: &stats) { ptr in
|
||||
ptr.withMemoryRebound(to: integer_t.self, capacity: Int(count)) { intPtr in
|
||||
host_statistics64(mach_host_self(), HOST_VM_INFO64, intPtr, &count)
|
||||
}
|
||||
}
|
||||
|
||||
guard result == KERN_SUCCESS else { return nil }
|
||||
|
||||
return (
|
||||
freePages: UInt64(stats.free_count),
|
||||
activePages: UInt64(stats.active_count),
|
||||
inactivePages: UInt64(stats.inactive_count),
|
||||
wiredPages: UInt64(stats.wire_count),
|
||||
compressedPages: UInt64(stats.compressor_page_count),
|
||||
cachedPages: UInt64(stats.external_page_count)
|
||||
)
|
||||
}
|
||||
|
||||
private func fetchSwapInfo() -> (used: UInt64, total: UInt64) {
|
||||
var swapUsage = xsw_usage()
|
||||
var size = MemoryLayout<xsw_usage>.size
|
||||
|
||||
if sysctlbyname("vm.swapusage", &swapUsage, &size, nil, 0) == 0 {
|
||||
return (UInt64(swapUsage.xsu_used), UInt64(swapUsage.xsu_total))
|
||||
}
|
||||
return (0, 0)
|
||||
}
|
||||
|
||||
private func fetchMemoryPressure() -> MemoryPressure {
|
||||
var pressure: Int32 = 0
|
||||
var size = MemoryLayout<Int32>.size
|
||||
|
||||
if sysctlbyname("kern.memorystatus_vm_pressure_level", &pressure, &size, nil, 0) == 0 {
|
||||
switch pressure {
|
||||
case 1: return .nominal
|
||||
case 2: return .warning
|
||||
case 4: return .critical
|
||||
default: return .nominal
|
||||
}
|
||||
}
|
||||
return .unknown
|
||||
}
|
||||
}
|
||||
124
TopManager/Services/NetworkMonitor.swift
Normal file
@@ -0,0 +1,124 @@
|
||||
import Foundation
|
||||
import Darwin
|
||||
|
||||
final class NetworkMonitor {
|
||||
private var previousStats: [String: (download: UInt64, upload: UInt64, timestamp: Date)] = [:]
|
||||
|
||||
func fetchNetworkInfo() -> NetworkInfo {
|
||||
let interfaces = fetchInterfaces()
|
||||
|
||||
let totalDownload = interfaces.reduce(0.0) { $0 + $1.downloadRate }
|
||||
let totalUpload = interfaces.reduce(0.0) { $0 + $1.uploadRate }
|
||||
let totalDownloadBytes = interfaces.reduce(UInt64(0)) { $0 + $1.downloadBytes }
|
||||
let totalUploadBytes = interfaces.reduce(UInt64(0)) { $0 + $1.uploadBytes }
|
||||
|
||||
return NetworkInfo(
|
||||
interfaces: interfaces,
|
||||
totalDownloadRate: totalDownload,
|
||||
totalUploadRate: totalUpload,
|
||||
totalDownloadBytes: totalDownloadBytes,
|
||||
totalUploadBytes: totalUploadBytes
|
||||
)
|
||||
}
|
||||
|
||||
private func fetchInterfaces() -> [NetworkInterface] {
|
||||
var ifaddr: UnsafeMutablePointer<ifaddrs>?
|
||||
|
||||
guard getifaddrs(&ifaddr) == 0, let firstAddr = ifaddr else {
|
||||
return []
|
||||
}
|
||||
|
||||
defer { freeifaddrs(ifaddr) }
|
||||
|
||||
var interfaceStats: [String: (download: UInt64, upload: UInt64)] = [:]
|
||||
var currentAddr: UnsafeMutablePointer<ifaddrs>? = firstAddr
|
||||
|
||||
while let addr = currentAddr {
|
||||
let interface = addr.pointee
|
||||
|
||||
if interface.ifa_addr.pointee.sa_family == UInt8(AF_LINK) {
|
||||
let name = String(cString: interface.ifa_name)
|
||||
|
||||
if shouldIncludeInterface(name: name),
|
||||
let data = interface.ifa_data {
|
||||
let networkData = data.assumingMemoryBound(to: if_data.self).pointee
|
||||
|
||||
let download = UInt64(networkData.ifi_ibytes)
|
||||
let upload = UInt64(networkData.ifi_obytes)
|
||||
|
||||
if let existing = interfaceStats[name] {
|
||||
interfaceStats[name] = (
|
||||
download: existing.download + download,
|
||||
upload: existing.upload + upload
|
||||
)
|
||||
} else {
|
||||
interfaceStats[name] = (download: download, upload: upload)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
currentAddr = interface.ifa_next
|
||||
}
|
||||
|
||||
let now = Date()
|
||||
var interfaces: [NetworkInterface] = []
|
||||
|
||||
for (name, stats) in interfaceStats {
|
||||
var downloadRate: Double = 0
|
||||
var uploadRate: Double = 0
|
||||
|
||||
if let previous = previousStats[name] {
|
||||
let timeDelta = now.timeIntervalSince(previous.timestamp)
|
||||
if timeDelta > 0 {
|
||||
// Handle counter reset/overflow: only compute rate if current >= previous
|
||||
if stats.download >= previous.download {
|
||||
downloadRate = Double(stats.download - previous.download) / timeDelta
|
||||
}
|
||||
if stats.upload >= previous.upload {
|
||||
uploadRate = Double(stats.upload - previous.upload) / timeDelta
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
previousStats[name] = (stats.download, stats.upload, now)
|
||||
|
||||
let isActive = downloadRate > 0 || uploadRate > 0 || stats.download > 0 || stats.upload > 0
|
||||
|
||||
interfaces.append(NetworkInterface(
|
||||
name: name,
|
||||
displayName: displayName(for: name),
|
||||
downloadBytes: stats.download,
|
||||
uploadBytes: stats.upload,
|
||||
downloadRate: downloadRate,
|
||||
uploadRate: uploadRate,
|
||||
isActive: isActive
|
||||
))
|
||||
}
|
||||
|
||||
return interfaces.sorted { $0.name < $1.name }
|
||||
}
|
||||
|
||||
private func shouldIncludeInterface(name: String) -> Bool {
|
||||
// Include common physical interfaces
|
||||
let includedPrefixes = ["en", "bridge", "awdl", "llw", "utun"]
|
||||
return includedPrefixes.contains { name.hasPrefix($0) }
|
||||
}
|
||||
|
||||
private func displayName(for interface: String) -> String {
|
||||
if interface.hasPrefix("en") {
|
||||
if interface == "en0" {
|
||||
return "Wi-Fi / Ethernet"
|
||||
}
|
||||
return "Ethernet \(interface)"
|
||||
} else if interface.hasPrefix("bridge") {
|
||||
return "Bridge"
|
||||
} else if interface.hasPrefix("awdl") {
|
||||
return "AirDrop"
|
||||
} else if interface.hasPrefix("llw") {
|
||||
return "Low Latency WLAN"
|
||||
} else if interface.hasPrefix("utun") {
|
||||
return "VPN Tunnel"
|
||||
}
|
||||
return interface
|
||||
}
|
||||
}
|
||||
296
TopManager/Services/ProcessMonitor.swift
Normal file
@@ -0,0 +1,296 @@
|
||||
import Foundation
|
||||
import Darwin
|
||||
import AppKit
|
||||
|
||||
final class ProcessMonitor {
|
||||
private var previousCPUTimes: [pid_t: (user: UInt64, system: UInt64, timestamp: Date)] = [:]
|
||||
private let iconCache = NSCache<NSNumber, NSImage>()
|
||||
private var nameCache: [pid_t: String] = [:]
|
||||
private var userCache: [uid_t: String] = [:]
|
||||
private let timebaseInfo: mach_timebase_info_data_t
|
||||
|
||||
init() {
|
||||
var info = mach_timebase_info_data_t()
|
||||
mach_timebase_info(&info)
|
||||
self.timebaseInfo = info
|
||||
}
|
||||
|
||||
func fetchProcesses() -> [ProcessItem] {
|
||||
var pids = [pid_t](repeating: 0, count: 2048)
|
||||
let bytesUsed = proc_listpids(UInt32(PROC_ALL_PIDS), 0, &pids, Int32(pids.count * MemoryLayout<pid_t>.size))
|
||||
|
||||
guard bytesUsed > 0 else { return [] }
|
||||
|
||||
let pidCount = Int(bytesUsed) / MemoryLayout<pid_t>.size
|
||||
var processes: [ProcessItem] = []
|
||||
var currentPids = Set<pid_t>()
|
||||
|
||||
for i in 0..<pidCount {
|
||||
let pid = pids[i]
|
||||
guard pid > 0 else { continue }
|
||||
currentPids.insert(pid)
|
||||
|
||||
if let process = fetchProcessInfo(pid: pid) {
|
||||
processes.append(process)
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up caches for terminated processes
|
||||
let stalePids = Set(nameCache.keys).subtracting(currentPids)
|
||||
for pid in stalePids {
|
||||
nameCache.removeValue(forKey: pid)
|
||||
iconCache.removeObject(forKey: NSNumber(value: pid))
|
||||
previousCPUTimes.removeValue(forKey: pid)
|
||||
}
|
||||
|
||||
return processes
|
||||
}
|
||||
|
||||
private func fetchProcessInfo(pid: pid_t) -> ProcessItem? {
|
||||
var bsdInfo = proc_bsdinfo()
|
||||
let bsdInfoSize = Int32(MemoryLayout<proc_bsdinfo>.size)
|
||||
let bsdResult = proc_pidinfo(pid, PROC_PIDTBSDINFO, 0, &bsdInfo, bsdInfoSize)
|
||||
|
||||
// If proc_pidinfo fails, use sysctl to get basic info (works for processes we don't own)
|
||||
if bsdResult != bsdInfoSize {
|
||||
return fetchBasicProcessInfo(pid: pid)
|
||||
}
|
||||
|
||||
var taskInfo = proc_taskinfo()
|
||||
let taskInfoSize = Int32(MemoryLayout<proc_taskinfo>.size)
|
||||
let taskResult = proc_pidinfo(pid, PROC_PIDTASKINFO, 0, &taskInfo, taskInfoSize)
|
||||
let hasTaskInfo = taskResult == taskInfoSize
|
||||
|
||||
let name = fetchProcessName(pid: pid, bsdInfo: bsdInfo)
|
||||
let user = fetchUsername(uid: bsdInfo.pbi_uid)
|
||||
let parentPid = pid_t(bsdInfo.pbi_ppid)
|
||||
let startTime = Date(timeIntervalSince1970: TimeInterval(bsdInfo.pbi_start_tvsec))
|
||||
|
||||
// For processes without task info, use defaults
|
||||
let memoryUsage: Int64
|
||||
let threadCount: Int32
|
||||
let cpuUsage: Double
|
||||
|
||||
if hasTaskInfo {
|
||||
let rusageData = fetchRusageData(pid: pid)
|
||||
memoryUsage = rusageData.memory
|
||||
threadCount = taskInfo.pti_threadnum
|
||||
cpuUsage = calculateCPUUsage(
|
||||
pid: pid,
|
||||
userTime: rusageData.userTime,
|
||||
systemTime: rusageData.systemTime
|
||||
)
|
||||
} else {
|
||||
memoryUsage = 0
|
||||
threadCount = 0
|
||||
cpuUsage = 0
|
||||
}
|
||||
|
||||
let state = determineProcessState(status: bsdInfo.pbi_status, cpuUsage: cpuUsage)
|
||||
|
||||
let icon = fetchIcon(pid: pid)
|
||||
|
||||
return ProcessItem(
|
||||
pid: pid,
|
||||
name: name,
|
||||
user: user,
|
||||
cpuUsage: cpuUsage,
|
||||
memoryUsage: memoryUsage,
|
||||
threadCount: threadCount,
|
||||
state: state,
|
||||
icon: icon,
|
||||
parentPid: parentPid,
|
||||
startTime: startTime
|
||||
)
|
||||
}
|
||||
|
||||
private func fetchProcessName(pid: pid_t, bsdInfo: proc_bsdinfo) -> String {
|
||||
// Check cache first
|
||||
if let cached = nameCache[pid] {
|
||||
return cached
|
||||
}
|
||||
|
||||
// PROC_PIDPATHINFO_MAXSIZE is 4 * MAXPATHLEN = 4096
|
||||
var pathBuffer = [CChar](repeating: 0, count: 4096)
|
||||
let pathLength = proc_pidpath(pid, &pathBuffer, UInt32(pathBuffer.count))
|
||||
|
||||
let name: String
|
||||
if pathLength > 0 {
|
||||
let path = String(cString: pathBuffer)
|
||||
name = (path as NSString).lastPathComponent
|
||||
} else {
|
||||
let bsdName = withUnsafePointer(to: bsdInfo.pbi_name) { ptr -> String in
|
||||
ptr.withMemoryRebound(to: CChar.self, capacity: Int(MAXCOMLEN)) {
|
||||
String(cString: $0)
|
||||
}
|
||||
}
|
||||
name = bsdName.isEmpty ? "(Unknown)" : bsdName
|
||||
}
|
||||
|
||||
nameCache[pid] = name
|
||||
return name
|
||||
}
|
||||
|
||||
private func fetchUsername(uid: uid_t) -> String {
|
||||
if let cached = userCache[uid] {
|
||||
return cached
|
||||
}
|
||||
|
||||
let name: String
|
||||
if let pw = getpwuid(uid) {
|
||||
name = String(cString: pw.pointee.pw_name)
|
||||
} else {
|
||||
name = String(uid)
|
||||
}
|
||||
|
||||
userCache[uid] = name
|
||||
return name
|
||||
}
|
||||
|
||||
private func fetchRusageData(pid: pid_t) -> (memory: Int64, userTime: UInt64, systemTime: UInt64) {
|
||||
var rusage = rusage_info_v4()
|
||||
let result = withUnsafeMutablePointer(to: &rusage) { ptr -> Int32 in
|
||||
ptr.withMemoryRebound(to: rusage_info_t?.self, capacity: 1) { rusagePtr in
|
||||
proc_pid_rusage(pid, RUSAGE_INFO_V4, rusagePtr)
|
||||
}
|
||||
}
|
||||
|
||||
if result == 0 {
|
||||
return (
|
||||
memory: Int64(rusage.ri_phys_footprint),
|
||||
userTime: rusage.ri_user_time,
|
||||
systemTime: rusage.ri_system_time
|
||||
)
|
||||
}
|
||||
|
||||
// Fallback to proc_taskinfo if rusage fails
|
||||
var taskInfo = proc_taskinfo()
|
||||
let taskInfoSize = Int32(MemoryLayout<proc_taskinfo>.size)
|
||||
if proc_pidinfo(pid, PROC_PIDTASKINFO, 0, &taskInfo, taskInfoSize) == taskInfoSize {
|
||||
return (
|
||||
memory: Int64(taskInfo.pti_resident_size),
|
||||
userTime: taskInfo.pti_total_user,
|
||||
systemTime: taskInfo.pti_total_system
|
||||
)
|
||||
}
|
||||
|
||||
return (memory: 0, userTime: 0, systemTime: 0)
|
||||
}
|
||||
|
||||
private func fetchBasicProcessInfo(pid: pid_t) -> ProcessItem? {
|
||||
var mib: [Int32] = [CTL_KERN, KERN_PROC, KERN_PROC_PID, pid]
|
||||
var info = kinfo_proc()
|
||||
var size = MemoryLayout<kinfo_proc>.size
|
||||
|
||||
let result = sysctl(&mib, UInt32(mib.count), &info, &size, nil, 0)
|
||||
guard result == 0, size > 0 else { return nil }
|
||||
|
||||
// Extract name from kp_proc.p_comm
|
||||
var name = withUnsafePointer(to: info.kp_proc.p_comm) { ptr -> String in
|
||||
ptr.withMemoryRebound(to: CChar.self, capacity: 16) {
|
||||
String(cString: $0)
|
||||
}
|
||||
}
|
||||
if name.isEmpty {
|
||||
name = "(Unknown)"
|
||||
}
|
||||
|
||||
// Check cache for full name
|
||||
if let cachedName = nameCache[pid] {
|
||||
name = cachedName
|
||||
} else {
|
||||
nameCache[pid] = name
|
||||
}
|
||||
|
||||
let user = fetchUsername(uid: info.kp_eproc.e_ucred.cr_uid)
|
||||
let parentPid = info.kp_eproc.e_ppid
|
||||
|
||||
// Determine state from p_stat
|
||||
let state: ProcessState
|
||||
switch info.kp_proc.p_stat {
|
||||
case 5: state = .zombie
|
||||
case 4: state = .stopped
|
||||
case 1: state = .unknown
|
||||
default: state = .sleeping // Can't determine CPU usage, assume sleeping
|
||||
}
|
||||
|
||||
return ProcessItem(
|
||||
pid: pid,
|
||||
name: name,
|
||||
user: user,
|
||||
cpuUsage: 0,
|
||||
memoryUsage: 0,
|
||||
threadCount: 0,
|
||||
state: state,
|
||||
icon: nil,
|
||||
parentPid: parentPid,
|
||||
startTime: nil
|
||||
)
|
||||
}
|
||||
|
||||
private func determineProcessState(status: UInt32, cpuUsage: Double) -> ProcessState {
|
||||
// Status values: SIDL=1, SRUN=2, SSLEEP=3, SSTOP=4, SZOMB=5
|
||||
switch status {
|
||||
case 5: return .zombie
|
||||
case 4: return .stopped
|
||||
case 1: return .unknown
|
||||
default:
|
||||
// For runnable processes (stat == 2 or 3), use CPU usage to determine display state
|
||||
return cpuUsage > 1.0 ? .running : .sleeping
|
||||
}
|
||||
}
|
||||
|
||||
private func calculateCPUUsage(pid: pid_t, userTime: UInt64, systemTime: UInt64) -> Double {
|
||||
let now = Date()
|
||||
let totalTime = userTime + systemTime
|
||||
|
||||
defer {
|
||||
previousCPUTimes[pid] = (userTime, systemTime, now)
|
||||
}
|
||||
|
||||
guard let previous = previousCPUTimes[pid] else {
|
||||
return 0
|
||||
}
|
||||
|
||||
let timeDelta = now.timeIntervalSince(previous.timestamp)
|
||||
guard timeDelta > 0 else { return 0 }
|
||||
|
||||
let previousTotal = previous.user + previous.system
|
||||
|
||||
// Handle case where times might wrap or process restarted
|
||||
guard totalTime >= previousTotal else { return 0 }
|
||||
|
||||
let cpuDelta = totalTime - previousTotal
|
||||
|
||||
// Convert Mach absolute time to nanoseconds
|
||||
let nanoseconds = cpuDelta * UInt64(timebaseInfo.numer) / UInt64(timebaseInfo.denom)
|
||||
|
||||
// Convert nanoseconds to seconds
|
||||
let cpuSeconds = Double(nanoseconds) / 1_000_000_000
|
||||
|
||||
// Calculate percentage: (CPU time / wall time) * 100
|
||||
let cpuUsage = (cpuSeconds / timeDelta) * 100
|
||||
|
||||
return min(cpuUsage, 100 * Double(ProcessInfo.processInfo.processorCount))
|
||||
}
|
||||
|
||||
private func fetchIcon(pid: pid_t) -> NSImage? {
|
||||
let cacheKey = NSNumber(value: pid)
|
||||
|
||||
if let cached = iconCache.object(forKey: cacheKey) {
|
||||
return cached
|
||||
}
|
||||
|
||||
if let app = NSRunningApplication(processIdentifier: pid),
|
||||
let icon = app.icon {
|
||||
iconCache.setObject(icon, forKey: cacheKey)
|
||||
return icon
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func clearCPUHistory() {
|
||||
previousCPUTimes.removeAll()
|
||||
}
|
||||
}
|
||||
183
TopManager/Services/SystemMonitor.swift
Normal file
@@ -0,0 +1,183 @@
|
||||
import Foundation
|
||||
import Combine
|
||||
|
||||
@MainActor
|
||||
final class SystemMonitor: ObservableObject {
|
||||
static let shared = SystemMonitor()
|
||||
|
||||
// Published properties
|
||||
@Published var cpuInfo: CPUInfo?
|
||||
@Published var memoryInfo: MemoryInfo?
|
||||
@Published var processes: [ProcessItem] = []
|
||||
@Published var diskInfo: DiskInfo?
|
||||
@Published var networkInfo: NetworkInfo?
|
||||
@Published var gpuInfo: GPUInfo?
|
||||
@Published var lastError: String?
|
||||
|
||||
// History for charts
|
||||
@Published var cpuHistory: [CPUHistoryPoint] = []
|
||||
@Published var coreHistories: [Int: [CoreHistoryPoint]] = [:]
|
||||
@Published var memoryHistory: [MemoryHistoryPoint] = []
|
||||
@Published var networkHistory: [NetworkHistoryPoint] = []
|
||||
|
||||
// Sub-monitors
|
||||
private let cpuMonitor = CPUMonitor()
|
||||
private let memoryMonitor = MemoryMonitor()
|
||||
private let processMonitor = ProcessMonitor()
|
||||
private let diskMonitor = DiskMonitor()
|
||||
private let networkMonitor = NetworkMonitor()
|
||||
private let gpuMonitor = GPUMonitor()
|
||||
|
||||
// Timer
|
||||
private var timer: Timer?
|
||||
private let historyLimit = 60
|
||||
private var refreshCount = 0
|
||||
|
||||
var isAppleSilicon: Bool {
|
||||
cpuMonitor.isAppleSilicon
|
||||
}
|
||||
|
||||
var thermalState: ProcessInfo.ThermalState {
|
||||
ProcessInfo.processInfo.thermalState
|
||||
}
|
||||
|
||||
private init() {}
|
||||
|
||||
func startMonitoring() {
|
||||
// Initial fetch
|
||||
Task {
|
||||
await refreshAll()
|
||||
}
|
||||
|
||||
// Start periodic refresh (2 second interval to reduce CPU usage)
|
||||
timer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: true) { [weak self] _ in
|
||||
Task { @MainActor in
|
||||
await self?.refreshAll()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func stopMonitoring() {
|
||||
timer?.invalidate()
|
||||
timer = nil
|
||||
}
|
||||
|
||||
private func refreshAll() async {
|
||||
refreshCount += 1
|
||||
|
||||
await withTaskGroup(of: Void.self) { group in
|
||||
group.addTask { await self.refreshCPU() }
|
||||
group.addTask { await self.refreshMemory() }
|
||||
group.addTask { await self.refreshNetwork() }
|
||||
|
||||
// Refresh processes every other cycle = 4 seconds (expensive operation)
|
||||
if self.refreshCount % 2 == 0 {
|
||||
group.addTask { await self.refreshProcesses() }
|
||||
}
|
||||
|
||||
// Refresh disk and GPU every 3 cycles = 6 seconds (less volatile)
|
||||
if self.refreshCount % 3 == 0 {
|
||||
group.addTask { await self.refreshDisk() }
|
||||
group.addTask { await self.refreshGPU() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func refreshCPU() async {
|
||||
if let info = cpuMonitor.fetchCPUInfo() {
|
||||
cpuInfo = info
|
||||
|
||||
// Global CPU history
|
||||
let historyPoint = CPUHistoryPoint(
|
||||
timestamp: info.timestamp,
|
||||
usage: info.globalUsage,
|
||||
userUsage: info.userUsage,
|
||||
systemUsage: info.systemUsage
|
||||
)
|
||||
cpuHistory.append(historyPoint)
|
||||
if cpuHistory.count > historyLimit {
|
||||
cpuHistory.removeFirst()
|
||||
}
|
||||
|
||||
// Per-core history
|
||||
for core in info.coreUsages {
|
||||
let corePoint = CoreHistoryPoint(
|
||||
timestamp: info.timestamp,
|
||||
usage: core.usage
|
||||
)
|
||||
if coreHistories[core.id] == nil {
|
||||
coreHistories[core.id] = []
|
||||
}
|
||||
coreHistories[core.id]?.append(corePoint)
|
||||
if coreHistories[core.id]?.count ?? 0 > historyLimit {
|
||||
coreHistories[core.id]?.removeFirst()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func refreshMemory() async {
|
||||
if let info = memoryMonitor.fetchMemoryInfo() {
|
||||
memoryInfo = info
|
||||
|
||||
let historyPoint = MemoryHistoryPoint(
|
||||
timestamp: info.timestamp,
|
||||
usedMemory: info.usedMemory,
|
||||
totalMemory: info.totalMemory
|
||||
)
|
||||
memoryHistory.append(historyPoint)
|
||||
if memoryHistory.count > historyLimit {
|
||||
memoryHistory.removeFirst()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func refreshProcesses() async {
|
||||
processes = processMonitor.fetchProcesses()
|
||||
}
|
||||
|
||||
private func refreshDisk() async {
|
||||
diskInfo = diskMonitor.fetchDiskInfo()
|
||||
}
|
||||
|
||||
private func refreshNetwork() async {
|
||||
if let info = networkMonitor.fetchNetworkInfo() as NetworkInfo? {
|
||||
networkInfo = info
|
||||
|
||||
let historyPoint = NetworkHistoryPoint(
|
||||
timestamp: info.timestamp,
|
||||
downloadRate: info.totalDownloadRate,
|
||||
uploadRate: info.totalUploadRate
|
||||
)
|
||||
networkHistory.append(historyPoint)
|
||||
if networkHistory.count > historyLimit {
|
||||
networkHistory.removeFirst()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func refreshGPU() async {
|
||||
gpuInfo = gpuMonitor.fetchGPUInfo()
|
||||
}
|
||||
|
||||
// Process control methods
|
||||
@discardableResult
|
||||
func terminateProcess(_ pid: pid_t) -> Bool {
|
||||
kill(pid, SIGTERM) == 0
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func forceKillProcess(_ pid: pid_t) -> Bool {
|
||||
kill(pid, SIGKILL) == 0
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func suspendProcess(_ pid: pid_t) -> Bool {
|
||||
kill(pid, SIGSTOP) == 0
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func resumeProcess(_ pid: pid_t) -> Bool {
|
||||
kill(pid, SIGCONT) == 0
|
||||
}
|
||||
}
|
||||
8
TopManager/TopManager.entitlements
Normal file
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.app-sandbox</key>
|
||||
<false/>
|
||||
</dict>
|
||||
</plist>
|
||||
35
TopManager/TopManagerApp.swift
Normal file
@@ -0,0 +1,35 @@
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct TopManagerApp: App {
|
||||
@StateObject private var monitor = SystemMonitor.shared
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
.environmentObject(monitor)
|
||||
.onAppear {
|
||||
monitor.startMonitoring()
|
||||
}
|
||||
}
|
||||
.windowStyle(.automatic)
|
||||
.defaultSize(width: 1000, height: 700)
|
||||
.commands {
|
||||
CommandGroup(replacing: .newItem) { }
|
||||
}
|
||||
|
||||
// Menu Bar Extra showing CPU %
|
||||
MenuBarExtra {
|
||||
MenuBarView()
|
||||
.environmentObject(monitor)
|
||||
} label: {
|
||||
if let cpu = monitor.cpuInfo {
|
||||
Text(String(format: "%.0f%%", cpu.globalUsage))
|
||||
.monospacedDigit()
|
||||
} else {
|
||||
Image(systemName: "cpu")
|
||||
}
|
||||
}
|
||||
.menuBarExtraStyle(.window)
|
||||
}
|
||||
}
|
||||
BINáris
TopManager/TopManager_icon.png
Normal file
|
After Width: | Height: | Méret: 337 KiB |
66
TopManager/Utilities/ByteFormatters.swift
Normal file
@@ -0,0 +1,66 @@
|
||||
import Foundation
|
||||
|
||||
func formatBytes(_ bytes: UInt64) -> String {
|
||||
let formatter = ByteCountFormatter()
|
||||
formatter.countStyle = .binary
|
||||
formatter.allowedUnits = [.useBytes, .useKB, .useMB, .useGB, .useTB]
|
||||
return formatter.string(fromByteCount: Int64(bytes))
|
||||
}
|
||||
|
||||
func formatBytes(_ bytes: Int64) -> String {
|
||||
let formatter = ByteCountFormatter()
|
||||
formatter.countStyle = .binary
|
||||
formatter.allowedUnits = [.useBytes, .useKB, .useMB, .useGB, .useTB]
|
||||
return formatter.string(fromByteCount: bytes)
|
||||
}
|
||||
|
||||
func formatBytes(_ bytes: Double) -> String {
|
||||
formatBytes(UInt64(max(0, bytes)))
|
||||
}
|
||||
|
||||
func formatBytesPerSecond(_ bytesPerSecond: Double) -> String {
|
||||
let absValue = abs(bytesPerSecond)
|
||||
|
||||
if absValue < 1 {
|
||||
return "0 B/s"
|
||||
}
|
||||
|
||||
let formatter = ByteCountFormatter()
|
||||
formatter.countStyle = .binary
|
||||
formatter.allowedUnits = [.useBytes, .useKB, .useMB, .useGB]
|
||||
return formatter.string(fromByteCount: Int64(absValue)) + "/s"
|
||||
}
|
||||
|
||||
func formatBytesCompact(_ bytes: Double) -> String {
|
||||
let absValue = abs(bytes)
|
||||
|
||||
if absValue < 1 {
|
||||
return "0"
|
||||
} else if absValue < 1024 {
|
||||
return String(format: "%.0f B", absValue)
|
||||
} else if absValue < 1024 * 1024 {
|
||||
return String(format: "%.1f KB", absValue / 1024)
|
||||
} else if absValue < 1024 * 1024 * 1024 {
|
||||
return String(format: "%.1f MB", absValue / (1024 * 1024))
|
||||
} else {
|
||||
return String(format: "%.2f GB", absValue / (1024 * 1024 * 1024))
|
||||
}
|
||||
}
|
||||
|
||||
func formatPercentage(_ value: Double, decimals: Int = 1) -> String {
|
||||
String(format: "%.\(decimals)f%%", value)
|
||||
}
|
||||
|
||||
func formatUptime(_ interval: TimeInterval) -> String {
|
||||
let days = Int(interval) / 86400
|
||||
let hours = (Int(interval) % 86400) / 3600
|
||||
let minutes = (Int(interval) % 3600) / 60
|
||||
|
||||
if days > 0 {
|
||||
return "\(days)d \(hours)h \(minutes)m"
|
||||
} else if hours > 0 {
|
||||
return "\(hours)h \(minutes)m"
|
||||
} else {
|
||||
return "\(minutes)m"
|
||||
}
|
||||
}
|
||||
233
TopManager/Views/Apps/AppsView.swift
Normal file
@@ -0,0 +1,233 @@
|
||||
import SwiftUI
|
||||
import AppKit
|
||||
|
||||
struct RunningApp: Identifiable {
|
||||
let id: pid_t
|
||||
let pid: pid_t
|
||||
let name: String
|
||||
let bundleIdentifier: String?
|
||||
let icon: NSImage?
|
||||
let isActive: Bool
|
||||
let isHidden: Bool
|
||||
let launchDate: Date?
|
||||
var cpuUsage: Double
|
||||
var memoryUsage: Int64
|
||||
}
|
||||
|
||||
struct AppsView: View {
|
||||
@EnvironmentObject var monitor: SystemMonitor
|
||||
@State private var runningApps: [RunningApp] = []
|
||||
@State private var selectedApp: Set<pid_t> = []
|
||||
@State private var sortOrder: [KeyPathComparator<RunningApp>] = [
|
||||
.init(\.name, order: .forward)
|
||||
]
|
||||
@State private var searchText = ""
|
||||
|
||||
var filteredApps: [RunningApp] {
|
||||
let filtered = runningApps.filter {
|
||||
searchText.isEmpty ||
|
||||
$0.name.localizedCaseInsensitiveContains(searchText) ||
|
||||
($0.bundleIdentifier?.localizedCaseInsensitiveContains(searchText) ?? false)
|
||||
}
|
||||
return filtered.sorted(using: sortOrder)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
// Summary bar
|
||||
HStack {
|
||||
Text("\(runningApps.count) apps")
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Spacer()
|
||||
|
||||
if let activeApp = runningApps.first(where: { $0.isActive }) {
|
||||
HStack(spacing: 4) {
|
||||
Text("Active:")
|
||||
.foregroundColor(.secondary)
|
||||
if let icon = activeApp.icon {
|
||||
Image(nsImage: icon)
|
||||
.resizable()
|
||||
.frame(width: 14, height: 14)
|
||||
}
|
||||
Text(activeApp.name)
|
||||
.fontWeight(.medium)
|
||||
}
|
||||
}
|
||||
|
||||
TextField("Search...", text: $searchText)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: 150)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 8)
|
||||
.background(Color(nsColor: .windowBackgroundColor))
|
||||
|
||||
Divider()
|
||||
|
||||
// Apps table
|
||||
Table(filteredApps, selection: $selectedApp, sortOrder: $sortOrder) {
|
||||
TableColumn("") { app in
|
||||
if let icon = app.icon {
|
||||
Image(nsImage: icon)
|
||||
.resizable()
|
||||
.frame(width: 20, height: 20)
|
||||
} else {
|
||||
Image(systemName: "app.dashed")
|
||||
.frame(width: 20, height: 20)
|
||||
}
|
||||
}
|
||||
.width(28)
|
||||
|
||||
TableColumn("Name", value: \.name) { app in
|
||||
HStack(spacing: 6) {
|
||||
Text(app.name)
|
||||
.lineLimit(1)
|
||||
if app.isActive {
|
||||
Circle()
|
||||
.fill(Color.green)
|
||||
.frame(width: 6, height: 6)
|
||||
}
|
||||
if app.isHidden {
|
||||
Image(systemName: "eye.slash")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.width(min: 150, ideal: 200)
|
||||
|
||||
TableColumn("PID", value: \.pid) { app in
|
||||
Text("\(app.pid)")
|
||||
.monospacedDigit()
|
||||
}
|
||||
.width(60)
|
||||
|
||||
TableColumn("CPU %", value: \.cpuUsage) { app in
|
||||
Text(String(format: "%.1f%%", app.cpuUsage))
|
||||
.monospacedDigit()
|
||||
.foregroundColor(cpuColor(app.cpuUsage))
|
||||
}
|
||||
.width(70)
|
||||
|
||||
TableColumn("Memory", value: \.memoryUsage) { app in
|
||||
Text(formatBytes(app.memoryUsage))
|
||||
.monospacedDigit()
|
||||
}
|
||||
.width(90)
|
||||
|
||||
TableColumn("Bundle ID") { app in
|
||||
Text(app.bundleIdentifier ?? "-")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
.width(min: 150, ideal: 200)
|
||||
}
|
||||
.contextMenu(forSelectionType: pid_t.self) { selection in
|
||||
if let pid = selection.first, let app = runningApps.first(where: { $0.pid == pid }) {
|
||||
Button("Activate") {
|
||||
activateApp(pid: pid)
|
||||
}
|
||||
Button("Hide") {
|
||||
hideApp(pid: pid)
|
||||
}
|
||||
Divider()
|
||||
Button("Quit") {
|
||||
quitApp(pid: pid)
|
||||
}
|
||||
Button("Force Quit") {
|
||||
forceQuitApp(pid: pid)
|
||||
}
|
||||
Divider()
|
||||
Button("Copy Bundle ID") {
|
||||
if let bundleId = app.bundleIdentifier {
|
||||
NSPasteboard.general.clearContents()
|
||||
NSPasteboard.general.setString(bundleId, forType: .string)
|
||||
}
|
||||
}
|
||||
.disabled(app.bundleIdentifier == nil)
|
||||
}
|
||||
} primaryAction: { selection in
|
||||
if let pid = selection.first {
|
||||
activateApp(pid: pid)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
refreshApps()
|
||||
}
|
||||
.onChange(of: monitor.processes) { _ in
|
||||
refreshApps()
|
||||
}
|
||||
}
|
||||
|
||||
private func refreshApps() {
|
||||
let workspace = NSWorkspace.shared
|
||||
let apps = workspace.runningApplications
|
||||
|
||||
// Build a map of PID to process info for CPU/memory data
|
||||
let processMap = Dictionary(uniqueKeysWithValues: monitor.processes.map { ($0.pid, $0) })
|
||||
|
||||
runningApps = apps.compactMap { app -> RunningApp? in
|
||||
// Only include regular apps (not background agents)
|
||||
guard app.activationPolicy == .regular else { return nil }
|
||||
|
||||
let pid = app.processIdentifier
|
||||
let processInfo = processMap[pid]
|
||||
|
||||
return RunningApp(
|
||||
id: pid,
|
||||
pid: pid,
|
||||
name: app.localizedName ?? app.bundleIdentifier ?? "Unknown",
|
||||
bundleIdentifier: app.bundleIdentifier,
|
||||
icon: app.icon,
|
||||
isActive: app.isActive,
|
||||
isHidden: app.isHidden,
|
||||
launchDate: app.launchDate,
|
||||
cpuUsage: processInfo?.cpuUsage ?? 0,
|
||||
memoryUsage: processInfo?.memoryUsage ?? 0
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func activateApp(pid: pid_t) {
|
||||
if let app = NSRunningApplication(processIdentifier: pid) {
|
||||
app.activate(options: [.activateIgnoringOtherApps])
|
||||
}
|
||||
}
|
||||
|
||||
private func hideApp(pid: pid_t) {
|
||||
if let app = NSRunningApplication(processIdentifier: pid) {
|
||||
app.hide()
|
||||
}
|
||||
}
|
||||
|
||||
private func quitApp(pid: pid_t) {
|
||||
if let app = NSRunningApplication(processIdentifier: pid) {
|
||||
app.terminate()
|
||||
}
|
||||
}
|
||||
|
||||
private func forceQuitApp(pid: pid_t) {
|
||||
if let app = NSRunningApplication(processIdentifier: pid) {
|
||||
app.forceTerminate()
|
||||
}
|
||||
}
|
||||
|
||||
private func cpuColor(_ usage: Double) -> Color {
|
||||
if usage > 80 {
|
||||
return .red
|
||||
} else if usage > 50 {
|
||||
return .orange
|
||||
} else if usage > 20 {
|
||||
return .yellow
|
||||
}
|
||||
return .primary
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
AppsView()
|
||||
.environmentObject(SystemMonitor.shared)
|
||||
}
|
||||
35
TopManager/Views/ContentView.swift
Normal file
@@ -0,0 +1,35 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ContentView: View {
|
||||
@EnvironmentObject var monitor: SystemMonitor
|
||||
|
||||
var body: some View {
|
||||
TabView {
|
||||
ProcessView()
|
||||
.tabItem {
|
||||
Label("Processes", systemImage: "list.bullet.rectangle")
|
||||
}
|
||||
|
||||
AppsView()
|
||||
.tabItem {
|
||||
Label("Apps", systemImage: "square.grid.2x2")
|
||||
}
|
||||
|
||||
PerformanceView()
|
||||
.tabItem {
|
||||
Label("Performance", systemImage: "chart.line.uptrend.xyaxis")
|
||||
}
|
||||
|
||||
PowerStorageView()
|
||||
.tabItem {
|
||||
Label("Power & Storage", systemImage: "battery.100.bolt")
|
||||
}
|
||||
}
|
||||
.frame(minWidth: 800, minHeight: 500)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
ContentView()
|
||||
.environmentObject(SystemMonitor.shared)
|
||||
}
|
||||
83
TopManager/Views/MenuBarView.swift
Normal file
@@ -0,0 +1,83 @@
|
||||
import SwiftUI
|
||||
|
||||
struct MenuBarView: View {
|
||||
@EnvironmentObject var monitor: SystemMonitor
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
if let cpu = monitor.cpuInfo {
|
||||
HStack {
|
||||
Image(systemName: "cpu")
|
||||
.frame(width: 16)
|
||||
Text("CPU:")
|
||||
Spacer()
|
||||
Text(String(format: "%.1f%%", cpu.globalUsage))
|
||||
.monospacedDigit()
|
||||
}
|
||||
}
|
||||
|
||||
if let mem = monitor.memoryInfo {
|
||||
HStack {
|
||||
Image(systemName: "memorychip")
|
||||
.frame(width: 16)
|
||||
Text("Memory:")
|
||||
Spacer()
|
||||
Text(String(format: "%.1f%%", mem.usagePercentage))
|
||||
.monospacedDigit()
|
||||
}
|
||||
}
|
||||
|
||||
if let net = monitor.networkInfo {
|
||||
HStack {
|
||||
Image(systemName: "arrow.down.circle")
|
||||
.frame(width: 16)
|
||||
Text("Download:")
|
||||
Spacer()
|
||||
Text(formatBytesPerSecond(net.totalDownloadRate))
|
||||
.monospacedDigit()
|
||||
}
|
||||
|
||||
HStack {
|
||||
Image(systemName: "arrow.up.circle")
|
||||
.frame(width: 16)
|
||||
Text("Upload:")
|
||||
Spacer()
|
||||
Text(formatBytesPerSecond(net.totalUploadRate))
|
||||
.monospacedDigit()
|
||||
}
|
||||
}
|
||||
|
||||
if let gpu = monitor.gpuInfo {
|
||||
HStack {
|
||||
Image(systemName: "gpu")
|
||||
.frame(width: 16)
|
||||
Text("GPU VRAM:")
|
||||
Spacer()
|
||||
Text(formatBytes(gpu.vramUsed))
|
||||
.monospacedDigit()
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
Button("Open TopManager") {
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
if let window = NSApp.windows.first(where: { $0.canBecomeMain }) {
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
}
|
||||
}
|
||||
|
||||
Button("Quit") {
|
||||
NSApplication.shared.terminate(nil)
|
||||
}
|
||||
.keyboardShortcut("q")
|
||||
}
|
||||
.padding(12)
|
||||
.frame(width: 220)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
MenuBarView()
|
||||
.environmentObject(SystemMonitor.shared)
|
||||
}
|
||||
87
TopManager/Views/Performance/CPUChartView.swift
Normal file
@@ -0,0 +1,87 @@
|
||||
import SwiftUI
|
||||
import Charts
|
||||
|
||||
struct CPULineChart: View {
|
||||
let history: [CPUHistoryPoint]
|
||||
|
||||
var body: some View {
|
||||
Chart {
|
||||
ForEach(history) { point in
|
||||
// User usage line
|
||||
LineMark(
|
||||
x: .value("Time", point.timestamp),
|
||||
y: .value("User", point.userUsage),
|
||||
series: .value("Type", "User")
|
||||
)
|
||||
.foregroundStyle(.blue)
|
||||
.lineStyle(StrokeStyle(lineWidth: 1.5))
|
||||
|
||||
// System usage line
|
||||
LineMark(
|
||||
x: .value("Time", point.timestamp),
|
||||
y: .value("System", point.systemUsage),
|
||||
series: .value("Type", "System")
|
||||
)
|
||||
.foregroundStyle(.red)
|
||||
.lineStyle(StrokeStyle(lineWidth: 1.5))
|
||||
|
||||
// Total usage line
|
||||
LineMark(
|
||||
x: .value("Time", point.timestamp),
|
||||
y: .value("Total", point.usage),
|
||||
series: .value("Type", "Total")
|
||||
)
|
||||
.foregroundStyle(.gray)
|
||||
.lineStyle(StrokeStyle(lineWidth: 2))
|
||||
}
|
||||
}
|
||||
.chartYScale(domain: -5...105)
|
||||
.chartYAxis {
|
||||
AxisMarks(position: .leading, values: [0, 25, 50, 75, 100]) { value in
|
||||
AxisGridLine()
|
||||
AxisValueLabel {
|
||||
if let intValue = value.as(Int.self) {
|
||||
Text("\(intValue)%")
|
||||
.font(.caption2)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
.chartXAxis {
|
||||
AxisMarks(values: .automatic(desiredCount: 5)) { _ in
|
||||
AxisGridLine()
|
||||
}
|
||||
}
|
||||
.chartLegend(position: .bottom, spacing: 10) {
|
||||
HStack(spacing: 16) {
|
||||
ChartLegendItem(color: .blue, label: "User")
|
||||
ChartLegendItem(color: .red, label: "System")
|
||||
ChartLegendItem(color: .gray, label: "Total")
|
||||
}
|
||||
}
|
||||
.drawingGroup()
|
||||
}
|
||||
}
|
||||
|
||||
struct ChartLegendItem: View {
|
||||
let color: Color
|
||||
let label: String
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 4) {
|
||||
RoundedRectangle(cornerRadius: 2)
|
||||
.fill(color)
|
||||
.frame(width: 12, height: 3)
|
||||
Text(label)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
CPULineChart(history: [])
|
||||
.frame(height: 200)
|
||||
.padding()
|
||||
}
|
||||
112
TopManager/Views/Performance/MemoryDonutView.swift
Normal file
@@ -0,0 +1,112 @@
|
||||
import SwiftUI
|
||||
|
||||
struct MemoryDonutView: View {
|
||||
@EnvironmentObject var monitor: SystemMonitor
|
||||
|
||||
var memorySegments: [MemorySegment] {
|
||||
guard let mem = monitor.memoryInfo else { return [] }
|
||||
|
||||
return [
|
||||
MemorySegment(name: "App", value: Double(mem.appMemory), color: .blue),
|
||||
MemorySegment(name: "Wired", value: Double(mem.wiredMemory), color: .orange),
|
||||
MemorySegment(name: "Compressed", value: Double(mem.compressedMemory), color: .yellow),
|
||||
MemorySegment(name: "Free", value: Double(mem.freeMemory + mem.cachedMemory), color: .green.opacity(0.5))
|
||||
]
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
// Donut chart
|
||||
DonutChart(segments: memorySegments)
|
||||
|
||||
// Center text
|
||||
if let mem = monitor.memoryInfo {
|
||||
VStack {
|
||||
Text(formatBytes(mem.usedMemory))
|
||||
.font(.title3)
|
||||
.fontWeight(.semibold)
|
||||
.monospacedDigit()
|
||||
Text("of \(formatBytes(mem.totalMemory))")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct DonutChart: View {
|
||||
let segments: [MemorySegment]
|
||||
let lineWidth: CGFloat = 30
|
||||
|
||||
var total: Double {
|
||||
segments.reduce(0) { $0 + $1.value }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geometry in
|
||||
let size = min(geometry.size.width, geometry.size.height)
|
||||
|
||||
ZStack {
|
||||
ForEach(Array(segments.enumerated()), id: \.element.id) { index, segment in
|
||||
DonutSegment(
|
||||
startAngle: startAngle(for: index),
|
||||
endAngle: endAngle(for: index),
|
||||
lineWidth: lineWidth
|
||||
)
|
||||
.fill(segment.color)
|
||||
}
|
||||
}
|
||||
.frame(width: size, height: size)
|
||||
.position(x: geometry.size.width / 2, y: geometry.size.height / 2)
|
||||
}
|
||||
}
|
||||
|
||||
private func startAngle(for index: Int) -> Angle {
|
||||
guard total > 0 else { return .degrees(-90) }
|
||||
let precedingSum = segments.prefix(index).reduce(0) { $0 + $1.value }
|
||||
return .degrees((precedingSum / total) * 360 - 90)
|
||||
}
|
||||
|
||||
private func endAngle(for index: Int) -> Angle {
|
||||
guard total > 0 else { return .degrees(-90) }
|
||||
let sum = segments.prefix(index + 1).reduce(0) { $0 + $1.value }
|
||||
return .degrees((sum / total) * 360 - 90)
|
||||
}
|
||||
}
|
||||
|
||||
struct DonutSegment: Shape {
|
||||
let startAngle: Angle
|
||||
let endAngle: Angle
|
||||
let lineWidth: CGFloat
|
||||
|
||||
func path(in rect: CGRect) -> Path {
|
||||
var path = Path()
|
||||
let center = CGPoint(x: rect.midX, y: rect.midY)
|
||||
let radius = min(rect.width, rect.height) / 2 - lineWidth / 2
|
||||
|
||||
path.addArc(
|
||||
center: center,
|
||||
radius: radius,
|
||||
startAngle: startAngle,
|
||||
endAngle: endAngle,
|
||||
clockwise: false
|
||||
)
|
||||
|
||||
return path.strokedPath(StrokeStyle(lineWidth: lineWidth, lineCap: .butt))
|
||||
}
|
||||
}
|
||||
|
||||
struct MemorySegment: Identifiable {
|
||||
let id = UUID()
|
||||
let name: String
|
||||
let value: Double
|
||||
let color: Color
|
||||
}
|
||||
|
||||
#Preview {
|
||||
MemoryDonutView()
|
||||
.environmentObject(SystemMonitor.shared)
|
||||
.frame(width: 250, height: 250)
|
||||
.padding()
|
||||
}
|
||||
44
TopManager/Views/Performance/MemoryLineChart.swift
Normal file
@@ -0,0 +1,44 @@
|
||||
import SwiftUI
|
||||
import Charts
|
||||
|
||||
struct MemoryLineChart: View {
|
||||
let history: [MemoryHistoryPoint]
|
||||
|
||||
var body: some View {
|
||||
Chart {
|
||||
ForEach(history) { point in
|
||||
LineMark(
|
||||
x: .value("Time", point.timestamp),
|
||||
y: .value("Usage", point.usagePercentage)
|
||||
)
|
||||
.foregroundStyle(.blue)
|
||||
.lineStyle(StrokeStyle(lineWidth: 2))
|
||||
}
|
||||
}
|
||||
.chartYScale(domain: -5...105)
|
||||
.chartYAxis {
|
||||
AxisMarks(position: .leading, values: [0, 25, 50, 75, 100]) { value in
|
||||
AxisGridLine()
|
||||
AxisValueLabel {
|
||||
if let intValue = value.as(Int.self) {
|
||||
Text("\(intValue)%")
|
||||
.font(.caption2)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
.chartXAxis {
|
||||
AxisMarks(values: .automatic(desiredCount: 5)) { _ in
|
||||
AxisGridLine()
|
||||
}
|
||||
}
|
||||
.drawingGroup()
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
MemoryLineChart(history: [])
|
||||
.frame(height: 200)
|
||||
.padding()
|
||||
}
|
||||
100
TopManager/Views/Performance/NetworkChartView.swift
Normal file
@@ -0,0 +1,100 @@
|
||||
import SwiftUI
|
||||
import Charts
|
||||
|
||||
struct DownloadLineChart: View {
|
||||
let history: [NetworkHistoryPoint]
|
||||
|
||||
var maxRate: Double {
|
||||
max(history.map(\.downloadRate).max() ?? 0, 1024)
|
||||
}
|
||||
|
||||
var yScaleMax: Double {
|
||||
maxRate * 1.1 // 10% margin at top
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Chart {
|
||||
ForEach(history) { point in
|
||||
LineMark(
|
||||
x: .value("Time", point.timestamp),
|
||||
y: .value("Download", point.downloadRate)
|
||||
)
|
||||
.foregroundStyle(.blue)
|
||||
.lineStyle(StrokeStyle(lineWidth: 2))
|
||||
}
|
||||
}
|
||||
.chartYScale(domain: 0...yScaleMax)
|
||||
.chartYAxis {
|
||||
AxisMarks(position: .leading) { value in
|
||||
AxisGridLine()
|
||||
AxisValueLabel {
|
||||
if let rate = value.as(Double.self) {
|
||||
Text(formatBytesPerSecond(rate))
|
||||
.font(.caption2)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.chartXAxis {
|
||||
AxisMarks(values: .automatic(desiredCount: 5)) { _ in
|
||||
AxisGridLine()
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
.drawingGroup()
|
||||
}
|
||||
}
|
||||
|
||||
struct UploadLineChart: View {
|
||||
let history: [NetworkHistoryPoint]
|
||||
|
||||
var maxRate: Double {
|
||||
max(history.map(\.uploadRate).max() ?? 0, 1024)
|
||||
}
|
||||
|
||||
var yScaleMax: Double {
|
||||
maxRate * 1.1 // 10% margin at top
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Chart {
|
||||
ForEach(history) { point in
|
||||
LineMark(
|
||||
x: .value("Time", point.timestamp),
|
||||
y: .value("Upload", point.uploadRate)
|
||||
)
|
||||
.foregroundStyle(.green)
|
||||
.lineStyle(StrokeStyle(lineWidth: 2))
|
||||
}
|
||||
}
|
||||
.chartYScale(domain: 0...yScaleMax)
|
||||
.chartYAxis {
|
||||
AxisMarks(position: .leading) { value in
|
||||
AxisGridLine()
|
||||
AxisValueLabel {
|
||||
if let rate = value.as(Double.self) {
|
||||
Text(formatBytesPerSecond(rate))
|
||||
.font(.caption2)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.chartXAxis {
|
||||
AxisMarks(values: .automatic(desiredCount: 5)) { _ in
|
||||
AxisGridLine()
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
.drawingGroup()
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
VStack {
|
||||
DownloadLineChart(history: [])
|
||||
.frame(height: 150)
|
||||
UploadLineChart(history: [])
|
||||
.frame(height: 150)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
541
TopManager/Views/Performance/PerformanceView.swift
Normal file
@@ -0,0 +1,541 @@
|
||||
import SwiftUI
|
||||
import Charts
|
||||
|
||||
struct PerformanceView: View {
|
||||
@State private var selectedTab = 0
|
||||
|
||||
var body: some View {
|
||||
TabView(selection: $selectedTab) {
|
||||
CPUTabView()
|
||||
.tabItem {
|
||||
Label("CPU", systemImage: "cpu")
|
||||
}
|
||||
.tag(0)
|
||||
|
||||
MemoryTabView()
|
||||
.tabItem {
|
||||
Label("Memory", systemImage: "memorychip")
|
||||
}
|
||||
.tag(1)
|
||||
|
||||
NetworkTabView()
|
||||
.tabItem {
|
||||
Label("Network", systemImage: "network")
|
||||
}
|
||||
.tag(2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - CPU Tab
|
||||
|
||||
struct CPUTabView: View {
|
||||
@EnvironmentObject var monitor: SystemMonitor
|
||||
|
||||
private let coreColumns = [
|
||||
GridItem(.flexible()),
|
||||
GridItem(.flexible()),
|
||||
GridItem(.flexible()),
|
||||
GridItem(.flexible())
|
||||
]
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 16) {
|
||||
// Overall CPU
|
||||
GroupBox {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack {
|
||||
Text("Total CPU Usage")
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
if let cpu = monitor.cpuInfo {
|
||||
Text(String(format: "%.1f%%", cpu.globalUsage))
|
||||
.font(.title2)
|
||||
.fontWeight(.semibold)
|
||||
.monospacedDigit()
|
||||
}
|
||||
}
|
||||
|
||||
CPULineChart(history: monitor.cpuHistory)
|
||||
.frame(height: 120)
|
||||
|
||||
// Legend
|
||||
HStack(spacing: 20) {
|
||||
ChartLegendItem(color: .blue, label: "User")
|
||||
ChartLegendItem(color: .red, label: "System")
|
||||
ChartLegendItem(color: .gray, label: "Total")
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
|
||||
// Per-core charts
|
||||
if let cpu = monitor.cpuInfo {
|
||||
// P-Cores
|
||||
if !cpu.pCoreUsages.isEmpty {
|
||||
GroupBox {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Performance Cores")
|
||||
.font(.headline)
|
||||
|
||||
LazyVGrid(columns: coreColumns, spacing: 12) {
|
||||
ForEach(cpu.pCoreUsages) { core in
|
||||
CoreLineChartView(
|
||||
coreId: core.id,
|
||||
coreType: "P",
|
||||
usage: core.usage,
|
||||
history: monitor.coreHistories[core.id] ?? [],
|
||||
color: .orange
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
}
|
||||
|
||||
// E-Cores
|
||||
if !cpu.eCoreUsages.isEmpty {
|
||||
GroupBox {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Efficiency Cores")
|
||||
.font(.headline)
|
||||
|
||||
LazyVGrid(columns: coreColumns, spacing: 12) {
|
||||
ForEach(cpu.eCoreUsages) { core in
|
||||
CoreLineChartView(
|
||||
coreId: core.id,
|
||||
coreType: "E",
|
||||
usage: core.usage,
|
||||
history: monitor.coreHistories[core.id] ?? [],
|
||||
color: .blue
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
}
|
||||
|
||||
// All cores (for non-Apple Silicon)
|
||||
if cpu.pCoreUsages.isEmpty && cpu.eCoreUsages.isEmpty {
|
||||
GroupBox {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("CPU Cores")
|
||||
.font(.headline)
|
||||
|
||||
LazyVGrid(columns: coreColumns, spacing: 12) {
|
||||
ForEach(cpu.coreUsages) { core in
|
||||
CoreLineChartView(
|
||||
coreId: core.id,
|
||||
coreType: "",
|
||||
usage: core.usage,
|
||||
history: monitor.coreHistories[core.id] ?? [],
|
||||
color: .blue
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct CoreLineChartView: View {
|
||||
let coreId: Int
|
||||
let coreType: String
|
||||
let usage: Double
|
||||
let history: [CoreHistoryPoint]
|
||||
let color: Color
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack {
|
||||
Text(coreType.isEmpty ? "Core \(coreId)" : "\(coreType)\(coreId)")
|
||||
.font(.caption)
|
||||
.fontWeight(.medium)
|
||||
Spacer()
|
||||
Text(String(format: "%.0f%%", usage))
|
||||
.font(.caption)
|
||||
.monospacedDigit()
|
||||
.foregroundColor(usageColor(usage))
|
||||
}
|
||||
|
||||
// Line chart for this core
|
||||
Chart {
|
||||
ForEach(history) { point in
|
||||
LineMark(
|
||||
x: .value("Time", point.timestamp),
|
||||
y: .value("Usage", point.usage)
|
||||
)
|
||||
.foregroundStyle(color)
|
||||
.lineStyle(StrokeStyle(lineWidth: 1.5))
|
||||
}
|
||||
}
|
||||
.chartYScale(domain: -5...105)
|
||||
.chartXAxis(.hidden)
|
||||
.chartYAxis(.hidden)
|
||||
.frame(height: 50)
|
||||
.drawingGroup()
|
||||
}
|
||||
.padding(8)
|
||||
.background(Color(nsColor: .controlBackgroundColor))
|
||||
.cornerRadius(6)
|
||||
}
|
||||
|
||||
private func usageColor(_ usage: Double) -> Color {
|
||||
if usage > 80 { return .red }
|
||||
if usage > 50 { return .orange }
|
||||
return .primary
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Memory Tab
|
||||
|
||||
struct MemoryTabView: View {
|
||||
@EnvironmentObject var monitor: SystemMonitor
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 16) {
|
||||
HStack(alignment: .top, spacing: 16) {
|
||||
// Left column: Donut chart
|
||||
GroupBox {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Memory Composition")
|
||||
.font(.headline)
|
||||
|
||||
MemoryDonutView()
|
||||
.frame(height: 200)
|
||||
|
||||
if let mem = monitor.memoryInfo {
|
||||
MemoryLegendView(memory: mem)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
|
||||
// Right column: Chart + Details
|
||||
VStack(spacing: 16) {
|
||||
// Memory history chart
|
||||
GroupBox {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack {
|
||||
Text("Memory Usage Over Time")
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
if let mem = monitor.memoryInfo {
|
||||
Text(String(format: "%.1f%%", mem.usagePercentage))
|
||||
.font(.title2)
|
||||
.fontWeight(.semibold)
|
||||
.monospacedDigit()
|
||||
}
|
||||
}
|
||||
|
||||
MemoryLineChart(history: monitor.memoryHistory)
|
||||
.frame(height: 150)
|
||||
|
||||
if let mem = monitor.memoryInfo {
|
||||
HStack {
|
||||
Text("Used: \(formatBytes(mem.usedMemory))")
|
||||
Spacer()
|
||||
Text("Free: \(formatBytes(mem.freeMemory))")
|
||||
Spacer()
|
||||
Text("Total: \(formatBytes(mem.totalMemory))")
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
|
||||
// Memory details
|
||||
if let mem = monitor.memoryInfo {
|
||||
GroupBox {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Memory Details")
|
||||
.font(.headline)
|
||||
|
||||
HStack(spacing: 20) {
|
||||
MemoryStatItem(label: "Swap Used", value: formatBytes(mem.swapUsed))
|
||||
MemoryStatItem(label: "Swap Total", value: formatBytes(mem.swapTotal))
|
||||
MemoryStatItem(label: "Pressure", value: mem.memoryPressure.rawValue, color: pressureColor(mem.memoryPressure))
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GPU Memory
|
||||
if let gpu = monitor.gpuInfo {
|
||||
GroupBox {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack {
|
||||
Label("GPU", systemImage: "gpu")
|
||||
.font(.headline)
|
||||
Text(gpu.name)
|
||||
.foregroundColor(.secondary)
|
||||
Spacer()
|
||||
}
|
||||
|
||||
HStack {
|
||||
Text(gpu.memoryLabel + ":")
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
if gpu.vramUsed > 0 {
|
||||
Text(formatBytes(gpu.vramUsed))
|
||||
.monospacedDigit()
|
||||
if let total = gpu.vramTotal {
|
||||
Text("/ \(formatBytes(total))")
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
} else if gpu.isUnifiedMemory {
|
||||
Text("Unified Memory Architecture")
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if let util = gpu.utilizationPercent {
|
||||
Text("GPU: \(String(format: "%.0f%%", util))")
|
||||
.monospacedDigit()
|
||||
}
|
||||
}
|
||||
|
||||
if gpu.vramUsed > 0, let total = gpu.vramTotal {
|
||||
ProgressView(value: Double(gpu.vramUsed), total: Double(total))
|
||||
.tint(.purple)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
||||
private func pressureColor(_ pressure: MemoryPressure) -> Color {
|
||||
switch pressure {
|
||||
case .nominal: return .green
|
||||
case .warning: return .yellow
|
||||
case .critical: return .red
|
||||
case .unknown: return .secondary
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct MemoryLegendView: View {
|
||||
let memory: MemoryInfo
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 4) {
|
||||
LegendRow(color: .blue, label: "App Memory", value: formatBytes(memory.appMemory))
|
||||
LegendRow(color: .orange, label: "Wired", value: formatBytes(memory.wiredMemory))
|
||||
LegendRow(color: .yellow, label: "Compressed", value: formatBytes(memory.compressedMemory))
|
||||
LegendRow(color: .green.opacity(0.5), label: "Cached/Free", value: formatBytes(memory.cachedMemory + memory.freeMemory))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct LegendRow: View {
|
||||
let color: Color
|
||||
let label: String
|
||||
let value: String
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Circle()
|
||||
.fill(color)
|
||||
.frame(width: 8, height: 8)
|
||||
Text(label)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
Spacer()
|
||||
Text(value)
|
||||
.font(.caption)
|
||||
.monospacedDigit()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct MemoryStatItem: View {
|
||||
let label: String
|
||||
let value: String
|
||||
var color: Color = .primary
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(label)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
Text(value)
|
||||
.font(.body)
|
||||
.foregroundColor(color)
|
||||
.monospacedDigit()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Network Tab
|
||||
|
||||
struct NetworkTabView: View {
|
||||
@EnvironmentObject var monitor: SystemMonitor
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 16) {
|
||||
// Download chart
|
||||
GroupBox {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack {
|
||||
Label("Download", systemImage: "arrow.down.circle")
|
||||
.font(.headline)
|
||||
.foregroundColor(.blue)
|
||||
Spacer()
|
||||
if let net = monitor.networkInfo {
|
||||
Text(formatBytesPerSecond(net.totalDownloadRate))
|
||||
.font(.title2)
|
||||
.fontWeight(.semibold)
|
||||
.monospacedDigit()
|
||||
}
|
||||
}
|
||||
|
||||
DownloadLineChart(history: monitor.networkHistory)
|
||||
.frame(height: 150)
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
|
||||
// Upload chart
|
||||
GroupBox {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack {
|
||||
Label("Upload", systemImage: "arrow.up.circle")
|
||||
.font(.headline)
|
||||
.foregroundColor(.green)
|
||||
Spacer()
|
||||
if let net = monitor.networkInfo {
|
||||
Text(formatBytesPerSecond(net.totalUploadRate))
|
||||
.font(.title2)
|
||||
.fontWeight(.semibold)
|
||||
.monospacedDigit()
|
||||
}
|
||||
}
|
||||
|
||||
UploadLineChart(history: monitor.networkHistory)
|
||||
.frame(height: 150)
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
|
||||
// Network interfaces
|
||||
GroupBox {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Network Interfaces")
|
||||
.font(.headline)
|
||||
|
||||
if let net = monitor.networkInfo {
|
||||
ForEach(net.interfaces.filter { $0.isActive }) { iface in
|
||||
NetworkInterfaceRowView(interface: iface)
|
||||
}
|
||||
|
||||
if net.interfaces.filter({ $0.isActive }).isEmpty {
|
||||
Text("No active interfaces")
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
|
||||
// Totals
|
||||
if let net = monitor.networkInfo {
|
||||
GroupBox {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Session Totals")
|
||||
.font(.headline)
|
||||
|
||||
HStack(spacing: 40) {
|
||||
VStack(alignment: .leading) {
|
||||
Text("Downloaded")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
Text(formatBytes(net.totalDownloadBytes))
|
||||
.monospacedDigit()
|
||||
}
|
||||
|
||||
VStack(alignment: .leading) {
|
||||
Text("Uploaded")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
Text(formatBytes(net.totalUploadBytes))
|
||||
.monospacedDigit()
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct NetworkInterfaceRowView: View {
|
||||
let interface: NetworkInterface
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack {
|
||||
Text(interface.displayName)
|
||||
.fontWeight(.medium)
|
||||
Spacer()
|
||||
Text(interface.name)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
HStack {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "arrow.down")
|
||||
.foregroundColor(.blue)
|
||||
Text(formatBytesPerSecond(interface.downloadRate))
|
||||
.monospacedDigit()
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "arrow.up")
|
||||
.foregroundColor(.green)
|
||||
Text(formatBytesPerSecond(interface.uploadRate))
|
||||
.monospacedDigit()
|
||||
}
|
||||
}
|
||||
.font(.caption)
|
||||
|
||||
Divider()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
PerformanceView()
|
||||
.environmentObject(SystemMonitor.shared)
|
||||
.frame(width: 900, height: 700)
|
||||
}
|
||||
91
TopManager/Views/PowerStorage/DiskUsageView.swift
Normal file
@@ -0,0 +1,91 @@
|
||||
import SwiftUI
|
||||
|
||||
struct DiskUsageView: View {
|
||||
let volume: VolumeInfo
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
Image(systemName: volumeIcon)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Text(volume.name)
|
||||
.fontWeight(.medium)
|
||||
|
||||
Spacer()
|
||||
|
||||
Text(volume.mountPoint)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
ProgressView(value: volume.usagePercentage, total: 100)
|
||||
.tint(usageColor)
|
||||
|
||||
HStack {
|
||||
Text("\(formatBytes(volume.usedSpace)) used")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Spacer()
|
||||
|
||||
Text("\(formatBytes(volume.freeSpace)) free")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Spacer()
|
||||
|
||||
Text("\(formatBytes(volume.totalSpace)) total")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
Divider()
|
||||
}
|
||||
}
|
||||
|
||||
private var volumeIcon: String {
|
||||
if volume.isRemovable {
|
||||
return "externaldrive"
|
||||
} else if volume.mountPoint == "/" {
|
||||
return "internaldrive.fill"
|
||||
} else {
|
||||
return "internaldrive"
|
||||
}
|
||||
}
|
||||
|
||||
private var usageColor: Color {
|
||||
if volume.usagePercentage > 90 {
|
||||
return .red
|
||||
} else if volume.usagePercentage > 75 {
|
||||
return .orange
|
||||
} else {
|
||||
return .blue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
VStack {
|
||||
DiskUsageView(volume: VolumeInfo(
|
||||
name: "Macintosh HD",
|
||||
mountPoint: "/",
|
||||
totalSpace: 500 * 1024 * 1024 * 1024,
|
||||
freeSpace: 150 * 1024 * 1024 * 1024,
|
||||
fileSystem: "apfs",
|
||||
isRemovable: false,
|
||||
isInternal: true
|
||||
))
|
||||
|
||||
DiskUsageView(volume: VolumeInfo(
|
||||
name: "External Drive",
|
||||
mountPoint: "/Volumes/External",
|
||||
totalSpace: 1024 * 1024 * 1024 * 1024,
|
||||
freeSpace: 800 * 1024 * 1024 * 1024,
|
||||
fileSystem: "apfs",
|
||||
isRemovable: true,
|
||||
isInternal: false
|
||||
))
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
272
TopManager/Views/PowerStorage/PowerStorageView.swift
Normal file
@@ -0,0 +1,272 @@
|
||||
import SwiftUI
|
||||
|
||||
struct PowerStorageView: View {
|
||||
@EnvironmentObject var monitor: SystemMonitor
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 20) {
|
||||
// System Status and GPU side by side
|
||||
HStack(alignment: .top, spacing: 20) {
|
||||
// System Status (left column)
|
||||
GroupBox {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Label("System Status", systemImage: "thermometer.medium")
|
||||
.font(.headline)
|
||||
|
||||
HStack {
|
||||
Text("macOS:")
|
||||
Spacer()
|
||||
Text(ProcessInfo.processInfo.operatingSystemVersionString)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
HStack {
|
||||
Text("Uptime:")
|
||||
Spacer()
|
||||
Text(formatUptime(ProcessInfo.processInfo.systemUptime))
|
||||
.monospacedDigit()
|
||||
}
|
||||
|
||||
HStack {
|
||||
Text("Thermal State:")
|
||||
Spacer()
|
||||
ThermalStateView(state: monitor.thermalState)
|
||||
}
|
||||
|
||||
if let cpu = monitor.cpuInfo {
|
||||
HStack {
|
||||
Text("CPU Usage:")
|
||||
Spacer()
|
||||
Text(String(format: "%.1f%%", cpu.globalUsage))
|
||||
.monospacedDigit()
|
||||
}
|
||||
|
||||
HStack {
|
||||
Text("CPU Cores:")
|
||||
Spacer()
|
||||
Text("\(cpu.coreUsages.count)")
|
||||
.monospacedDigit()
|
||||
}
|
||||
}
|
||||
|
||||
if let mem = monitor.memoryInfo {
|
||||
HStack {
|
||||
Text("Memory:")
|
||||
Spacer()
|
||||
Text("\(formatBytes(mem.usedMemory)) / \(formatBytes(mem.totalMemory))")
|
||||
.monospacedDigit()
|
||||
}
|
||||
}
|
||||
|
||||
if monitor.isAppleSilicon {
|
||||
HStack {
|
||||
Text("Architecture:")
|
||||
Spacer()
|
||||
Text("Apple Silicon")
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
|
||||
// GPU Info (right column)
|
||||
GroupBox {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
if let gpu = monitor.gpuInfo {
|
||||
HStack {
|
||||
Label("GPU", systemImage: "cpu.fill")
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
}
|
||||
|
||||
HStack {
|
||||
Text("Name:")
|
||||
Spacer()
|
||||
Text(gpu.name)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
if let cores = gpu.coreCount {
|
||||
HStack {
|
||||
Text("GPU Cores:")
|
||||
Spacer()
|
||||
Text("\(cores)")
|
||||
.monospacedDigit()
|
||||
}
|
||||
}
|
||||
|
||||
HStack {
|
||||
Text("VRAM Used:")
|
||||
Spacer()
|
||||
Text(formatBytes(gpu.vramUsed))
|
||||
.monospacedDigit()
|
||||
}
|
||||
|
||||
if let total = gpu.vramTotal {
|
||||
HStack {
|
||||
Text("VRAM Total:")
|
||||
Spacer()
|
||||
Text(formatBytes(total))
|
||||
.monospacedDigit()
|
||||
}
|
||||
|
||||
ProgressView(value: Double(gpu.vramUsed), total: Double(total))
|
||||
.tint(.purple)
|
||||
}
|
||||
|
||||
if let utilization = gpu.utilizationPercent {
|
||||
HStack {
|
||||
Text("Utilization:")
|
||||
Spacer()
|
||||
Text(String(format: "%.1f%%", utilization))
|
||||
.monospacedDigit()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Label("GPU", systemImage: "cpu.fill")
|
||||
.font(.headline)
|
||||
Text("Loading GPU info...")
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
// Storage
|
||||
GroupBox {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Label("Storage", systemImage: "internaldrive")
|
||||
.font(.headline)
|
||||
|
||||
if let diskInfo = monitor.diskInfo {
|
||||
ForEach(diskInfo.volumes) { volume in
|
||||
DiskUsageView(volume: volume)
|
||||
}
|
||||
} else {
|
||||
Text("Loading storage information...")
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
|
||||
// Network Interfaces
|
||||
GroupBox {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Label("Network Interfaces", systemImage: "network")
|
||||
.font(.headline)
|
||||
|
||||
if let networkInfo = monitor.networkInfo {
|
||||
ForEach(networkInfo.interfaces.filter { $0.isActive }) { iface in
|
||||
NetworkInterfaceRow(interface: iface)
|
||||
}
|
||||
|
||||
if networkInfo.interfaces.filter({ $0.isActive }).isEmpty {
|
||||
Text("No active interfaces")
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ThermalStateView: View {
|
||||
let state: ProcessInfo.ThermalState
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: stateIcon)
|
||||
.foregroundColor(stateColor)
|
||||
Text(stateText)
|
||||
.foregroundColor(stateColor)
|
||||
}
|
||||
}
|
||||
|
||||
private var stateIcon: String {
|
||||
switch state {
|
||||
case .nominal: return "checkmark.circle.fill"
|
||||
case .fair: return "exclamationmark.circle.fill"
|
||||
case .serious: return "exclamationmark.triangle.fill"
|
||||
case .critical: return "xmark.octagon.fill"
|
||||
@unknown default: return "questionmark.circle"
|
||||
}
|
||||
}
|
||||
|
||||
private var stateColor: Color {
|
||||
switch state {
|
||||
case .nominal: return .green
|
||||
case .fair: return .yellow
|
||||
case .serious: return .orange
|
||||
case .critical: return .red
|
||||
@unknown default: return .secondary
|
||||
}
|
||||
}
|
||||
|
||||
private var stateText: String {
|
||||
switch state {
|
||||
case .nominal: return "Normal"
|
||||
case .fair: return "Fair"
|
||||
case .serious: return "Serious"
|
||||
case .critical: return "Critical"
|
||||
@unknown default: return "Unknown"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct NetworkInterfaceRow: View {
|
||||
let interface: NetworkInterface
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack {
|
||||
Text(interface.displayName)
|
||||
.fontWeight(.medium)
|
||||
Spacer()
|
||||
Text(interface.name)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
HStack {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "arrow.down")
|
||||
.foregroundColor(.blue)
|
||||
Text(formatBytesPerSecond(interface.downloadRate))
|
||||
.monospacedDigit()
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "arrow.up")
|
||||
.foregroundColor(.green)
|
||||
Text(formatBytesPerSecond(interface.uploadRate))
|
||||
.monospacedDigit()
|
||||
}
|
||||
}
|
||||
.font(.caption)
|
||||
|
||||
Divider()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
PowerStorageView()
|
||||
.environmentObject(SystemMonitor.shared)
|
||||
.frame(width: 600, height: 700)
|
||||
}
|
||||
110
TopManager/Views/Processes/ProcessDetailView.swift
Normal file
@@ -0,0 +1,110 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ProcessDetailView: View {
|
||||
let process: ProcessItem
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
// Header
|
||||
HStack(spacing: 12) {
|
||||
if let icon = process.icon {
|
||||
Image(nsImage: icon)
|
||||
.resizable()
|
||||
.frame(width: 48, height: 48)
|
||||
} else {
|
||||
Image(systemName: "app.dashed")
|
||||
.font(.system(size: 48))
|
||||
}
|
||||
|
||||
VStack(alignment: .leading) {
|
||||
Text(process.name)
|
||||
.font(.title2)
|
||||
.fontWeight(.semibold)
|
||||
|
||||
Text("PID: \(process.pid)")
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
VStack(alignment: .trailing) {
|
||||
HStack {
|
||||
Image(systemName: process.state.symbol)
|
||||
Text(process.state.rawValue)
|
||||
}
|
||||
.foregroundColor(stateColor(process.state))
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
// Details grid
|
||||
LazyVGrid(columns: [
|
||||
GridItem(.flexible()),
|
||||
GridItem(.flexible())
|
||||
], alignment: .leading, spacing: 12) {
|
||||
DetailRow(label: "CPU Usage", value: String(format: "%.1f%%", process.cpuUsage))
|
||||
DetailRow(label: "Memory", value: formatBytes(process.memoryUsage))
|
||||
DetailRow(label: "Threads", value: "\(process.threadCount)")
|
||||
DetailRow(label: "User", value: process.user)
|
||||
DetailRow(label: "Parent PID", value: "\(process.parentPid)")
|
||||
|
||||
if let startTime = process.startTime {
|
||||
DetailRow(label: "Started", value: formatDate(startTime))
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding()
|
||||
.frame(minWidth: 300)
|
||||
}
|
||||
|
||||
private func stateColor(_ state: ProcessState) -> Color {
|
||||
switch state {
|
||||
case .running: return .green
|
||||
case .sleeping: return .secondary
|
||||
case .stopped: return .orange
|
||||
case .zombie: return .red
|
||||
case .unknown: return .secondary
|
||||
}
|
||||
}
|
||||
|
||||
private func formatDate(_ date: Date) -> String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateStyle = .short
|
||||
formatter.timeStyle = .medium
|
||||
return formatter.string(from: date)
|
||||
}
|
||||
}
|
||||
|
||||
struct DetailRow: View {
|
||||
let label: String
|
||||
let value: String
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(label)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
Text(value)
|
||||
.font(.body)
|
||||
.monospacedDigit()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
ProcessDetailView(process: ProcessItem(
|
||||
pid: 1234,
|
||||
name: "Safari",
|
||||
user: "ariel",
|
||||
cpuUsage: 12.5,
|
||||
memoryUsage: 512 * 1024 * 1024,
|
||||
threadCount: 42,
|
||||
state: .running,
|
||||
icon: nil,
|
||||
parentPid: 1,
|
||||
startTime: Date()
|
||||
))
|
||||
}
|
||||
426
TopManager/Views/Processes/ProcessView.swift
Normal file
@@ -0,0 +1,426 @@
|
||||
import SwiftUI
|
||||
|
||||
enum ProcessSortColumn: String {
|
||||
case name, pid, cpu, memory, threads, user, state
|
||||
}
|
||||
|
||||
struct ProcessView: View {
|
||||
@EnvironmentObject var monitor: SystemMonitor
|
||||
@State private var searchText = ""
|
||||
@State private var selectedProcess: Set<ProcessItem.ID> = []
|
||||
@State private var sortColumn: ProcessSortColumn = .cpu
|
||||
@State private var sortAscending: Bool = false
|
||||
@State private var displayedProcesses: [ProcessItem] = []
|
||||
|
||||
// Keep sortOrder for Table binding compatibility
|
||||
@State private var sortOrder: [KeyPathComparator<ProcessItem>] = [
|
||||
.init(\.cpuUsage, order: .reverse)
|
||||
]
|
||||
|
||||
// Confirmation dialogs
|
||||
@State private var showTerminateConfirm = false
|
||||
@State private var showForceKillConfirm = false
|
||||
@State private var processToKill: ProcessItem?
|
||||
|
||||
// "Don't ask again" preferences
|
||||
@AppStorage("skipTerminateConfirm") private var skipTerminateConfirm = false
|
||||
@AppStorage("skipForceKillConfirm") private var skipForceKillConfirm = false
|
||||
|
||||
private func updateDisplayedProcesses() {
|
||||
let filtered = monitor.processes.filter {
|
||||
searchText.isEmpty ||
|
||||
$0.name.localizedCaseInsensitiveContains(searchText) ||
|
||||
String($0.pid).contains(searchText)
|
||||
}
|
||||
|
||||
displayedProcesses = filtered.sorted { lhs, rhs in
|
||||
let comparison: ComparisonResult
|
||||
switch sortColumn {
|
||||
case .name:
|
||||
comparison = lhs.name.localizedCaseInsensitiveCompare(rhs.name)
|
||||
case .pid:
|
||||
comparison = lhs.pid < rhs.pid ? .orderedAscending : (lhs.pid > rhs.pid ? .orderedDescending : .orderedSame)
|
||||
case .cpu:
|
||||
comparison = lhs.cpuUsage < rhs.cpuUsage ? .orderedAscending : (lhs.cpuUsage > rhs.cpuUsage ? .orderedDescending : .orderedSame)
|
||||
case .memory:
|
||||
comparison = lhs.memoryUsage < rhs.memoryUsage ? .orderedAscending : (lhs.memoryUsage > rhs.memoryUsage ? .orderedDescending : .orderedSame)
|
||||
case .threads:
|
||||
comparison = lhs.threadCount < rhs.threadCount ? .orderedAscending : (lhs.threadCount > rhs.threadCount ? .orderedDescending : .orderedSame)
|
||||
case .user:
|
||||
comparison = lhs.user.localizedCaseInsensitiveCompare(rhs.user)
|
||||
case .state:
|
||||
comparison = lhs.state.rawValue.compare(rhs.state.rawValue)
|
||||
}
|
||||
|
||||
// Primary sort
|
||||
if comparison != .orderedSame {
|
||||
return sortAscending ? comparison == .orderedAscending : comparison == .orderedDescending
|
||||
}
|
||||
|
||||
// Tiebreaker: sort by PID for stability
|
||||
return lhs.pid < rhs.pid
|
||||
}
|
||||
}
|
||||
|
||||
var selectedProcessItem: ProcessItem? {
|
||||
guard let pid = selectedProcess.first else { return nil }
|
||||
return monitor.processes.first { $0.pid == pid }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
// Summary bar - separate view to avoid re-rendering table
|
||||
ProcessSummaryBar(processCount: displayedProcesses.count, searchText: $searchText)
|
||||
|
||||
Divider()
|
||||
|
||||
// Process table
|
||||
Table(displayedProcesses, selection: $selectedProcess, sortOrder: $sortOrder) {
|
||||
TableColumn("") { process in
|
||||
if let icon = process.icon {
|
||||
Image(nsImage: icon)
|
||||
.resizable()
|
||||
.frame(width: 16, height: 16)
|
||||
} else {
|
||||
Image(systemName: "app.dashed")
|
||||
.frame(width: 16, height: 16)
|
||||
}
|
||||
}
|
||||
.width(24)
|
||||
|
||||
TableColumn("Name", value: \.name) { process in
|
||||
Text(process.name)
|
||||
.lineLimit(1)
|
||||
}
|
||||
.width(min: 150, ideal: 200)
|
||||
|
||||
TableColumn("PID", value: \.pid) { process in
|
||||
Text("\(process.pid)")
|
||||
.monospacedDigit()
|
||||
}
|
||||
.width(60)
|
||||
|
||||
TableColumn("CPU %", value: \.cpuUsage) { process in
|
||||
Text(String(format: "%.1f%%", process.cpuUsage))
|
||||
.monospacedDigit()
|
||||
.foregroundColor(cpuColor(process.cpuUsage))
|
||||
}
|
||||
.width(70)
|
||||
|
||||
TableColumn("Memory", value: \.memoryUsage) { process in
|
||||
Text(formatBytes(process.memoryUsage))
|
||||
.monospacedDigit()
|
||||
}
|
||||
.width(80)
|
||||
|
||||
TableColumn("Threads", value: \.threadCount) { process in
|
||||
Text("\(process.threadCount)")
|
||||
.monospacedDigit()
|
||||
}
|
||||
.width(60)
|
||||
|
||||
TableColumn("User", value: \.user) { process in
|
||||
Text(process.user)
|
||||
.lineLimit(1)
|
||||
}
|
||||
.width(80)
|
||||
|
||||
TableColumn("State", value: \.state.rawValue) { process in
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: process.state.symbol)
|
||||
.foregroundColor(stateColor(process.state))
|
||||
Text(process.state.rawValue)
|
||||
}
|
||||
}
|
||||
.width(90)
|
||||
}
|
||||
.contextMenu(forSelectionType: ProcessItem.ID.self) { selection in
|
||||
if let pid = selection.first {
|
||||
Button("Terminate (⌫)") {
|
||||
initiateTerminate()
|
||||
}
|
||||
Button("Force Kill (⌘⌫)") {
|
||||
initiateForceKill()
|
||||
}
|
||||
Divider()
|
||||
Button("Suspend") {
|
||||
monitor.suspendProcess(pid)
|
||||
}
|
||||
Button("Resume") {
|
||||
monitor.resumeProcess(pid)
|
||||
}
|
||||
Divider()
|
||||
Button("Copy PID") {
|
||||
NSPasteboard.general.clearContents()
|
||||
NSPasteboard.general.setString("\(pid)", forType: .string)
|
||||
}
|
||||
}
|
||||
} primaryAction: { selection in
|
||||
// Double-click action
|
||||
}
|
||||
.onDeleteCommand {
|
||||
initiateTerminate()
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
updateDisplayedProcesses()
|
||||
}
|
||||
.onChange(of: monitor.processes) { _ in
|
||||
updateDisplayedProcesses()
|
||||
}
|
||||
.onChange(of: searchText) { _ in
|
||||
updateDisplayedProcesses()
|
||||
}
|
||||
.onChange(of: sortColumn) { _ in
|
||||
updateDisplayedProcesses()
|
||||
}
|
||||
.onChange(of: sortAscending) { _ in
|
||||
updateDisplayedProcesses()
|
||||
}
|
||||
.onChange(of: sortOrder) { newOrder in
|
||||
guard let comparator = newOrder.first else { return }
|
||||
|
||||
// Detect which column and direction from the KeyPathComparator
|
||||
let ascending = comparator.order == .forward
|
||||
|
||||
// Use string representation of keypath to determine column
|
||||
let keyPathString = String(describing: comparator)
|
||||
|
||||
if keyPathString.contains("cpuUsage") {
|
||||
sortColumn = .cpu
|
||||
} else if keyPathString.contains("memoryUsage") {
|
||||
sortColumn = .memory
|
||||
} else if keyPathString.contains("name") {
|
||||
sortColumn = .name
|
||||
} else if keyPathString.contains("pid") {
|
||||
sortColumn = .pid
|
||||
} else if keyPathString.contains("threadCount") {
|
||||
sortColumn = .threads
|
||||
} else if keyPathString.contains("user") {
|
||||
sortColumn = .user
|
||||
} else if keyPathString.contains("state") {
|
||||
sortColumn = .state
|
||||
}
|
||||
|
||||
sortAscending = ascending
|
||||
}
|
||||
.background(
|
||||
KeyboardShortcutHandler(
|
||||
onCommandBackspace: { initiateForceKill() }
|
||||
)
|
||||
)
|
||||
.sheet(isPresented: $showTerminateConfirm) {
|
||||
if let process = processToKill {
|
||||
ConfirmationSheet(
|
||||
title: "Terminate Process",
|
||||
message: "Are you sure you want to terminate \"\(process.name)\" (PID: \(process.pid))?",
|
||||
actionTitle: "Terminate",
|
||||
isDestructive: true,
|
||||
skipPreferenceKey: "skipTerminateConfirm",
|
||||
onConfirm: {
|
||||
monitor.terminateProcess(process.pid)
|
||||
},
|
||||
onCancel: {}
|
||||
)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showForceKillConfirm) {
|
||||
if let process = processToKill {
|
||||
ConfirmationSheet(
|
||||
title: "Force Kill Process",
|
||||
message: "Are you sure you want to force kill \"\(process.name)\" (PID: \(process.pid))?\n\nThis will immediately terminate the process without allowing it to save data.",
|
||||
actionTitle: "Force Kill",
|
||||
isDestructive: true,
|
||||
skipPreferenceKey: "skipForceKillConfirm",
|
||||
onConfirm: {
|
||||
monitor.forceKillProcess(process.pid)
|
||||
},
|
||||
onCancel: {}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func initiateTerminate() {
|
||||
guard let process = selectedProcessItem else { return }
|
||||
processToKill = process
|
||||
|
||||
if skipTerminateConfirm {
|
||||
monitor.terminateProcess(process.pid)
|
||||
} else {
|
||||
showTerminateConfirm = true
|
||||
}
|
||||
}
|
||||
|
||||
private func initiateForceKill() {
|
||||
guard let process = selectedProcessItem else { return }
|
||||
processToKill = process
|
||||
|
||||
if skipForceKillConfirm {
|
||||
monitor.forceKillProcess(process.pid)
|
||||
} else {
|
||||
showForceKillConfirm = true
|
||||
}
|
||||
}
|
||||
|
||||
private func cpuColor(_ usage: Double) -> Color {
|
||||
if usage > 80 {
|
||||
return .red
|
||||
} else if usage > 50 {
|
||||
return .orange
|
||||
} else if usage > 20 {
|
||||
return .yellow
|
||||
}
|
||||
return .primary
|
||||
}
|
||||
|
||||
private func stateColor(_ state: ProcessState) -> Color {
|
||||
switch state {
|
||||
case .running: return .green
|
||||
case .sleeping: return .secondary
|
||||
case .stopped: return .orange
|
||||
case .zombie: return .red
|
||||
case .unknown: return .secondary
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ConfirmationSheet: View {
|
||||
let title: String
|
||||
let message: String
|
||||
let actionTitle: String
|
||||
let isDestructive: Bool
|
||||
let skipPreferenceKey: String
|
||||
let onConfirm: () -> Void
|
||||
let onCancel: () -> Void
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var dontAskAgain = false
|
||||
@AppStorage private var skipConfirm: Bool
|
||||
|
||||
init(
|
||||
title: String,
|
||||
message: String,
|
||||
actionTitle: String,
|
||||
isDestructive: Bool,
|
||||
skipPreferenceKey: String,
|
||||
onConfirm: @escaping () -> Void,
|
||||
onCancel: @escaping () -> Void
|
||||
) {
|
||||
self.title = title
|
||||
self.message = message
|
||||
self.actionTitle = actionTitle
|
||||
self.isDestructive = isDestructive
|
||||
self.skipPreferenceKey = skipPreferenceKey
|
||||
self.onConfirm = onConfirm
|
||||
self.onCancel = onCancel
|
||||
self._skipConfirm = AppStorage(wrappedValue: false, skipPreferenceKey)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 20) {
|
||||
Image(systemName: isDestructive ? "exclamationmark.triangle.fill" : "questionmark.circle.fill")
|
||||
.font(.system(size: 48))
|
||||
.foregroundColor(isDestructive ? .orange : .blue)
|
||||
|
||||
Text(title)
|
||||
.font(.headline)
|
||||
|
||||
Text(message)
|
||||
.font(.body)
|
||||
.foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
Toggle("Don't ask again", isOn: $dontAskAgain)
|
||||
.toggleStyle(.checkbox)
|
||||
|
||||
HStack(spacing: 12) {
|
||||
Button("Cancel") {
|
||||
onCancel()
|
||||
dismiss()
|
||||
}
|
||||
.keyboardShortcut(.cancelAction)
|
||||
|
||||
Button(actionTitle) {
|
||||
if dontAskAgain {
|
||||
skipConfirm = true
|
||||
}
|
||||
onConfirm()
|
||||
dismiss()
|
||||
}
|
||||
.keyboardShortcut(.defaultAction)
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(isDestructive ? .red : .accentColor)
|
||||
}
|
||||
}
|
||||
.padding(24)
|
||||
.frame(width: 350)
|
||||
}
|
||||
}
|
||||
|
||||
struct KeyboardShortcutHandler: NSViewRepresentable {
|
||||
let onCommandBackspace: () -> Void
|
||||
|
||||
func makeNSView(context: Context) -> NSView {
|
||||
let view = KeyCaptureView()
|
||||
view.onCommandBackspace = onCommandBackspace
|
||||
return view
|
||||
}
|
||||
|
||||
func updateNSView(_ nsView: NSView, context: Context) {
|
||||
if let view = nsView as? KeyCaptureView {
|
||||
view.onCommandBackspace = onCommandBackspace
|
||||
}
|
||||
}
|
||||
|
||||
class KeyCaptureView: NSView {
|
||||
var onCommandBackspace: (() -> Void)?
|
||||
|
||||
override func performKeyEquivalent(with event: NSEvent) -> Bool {
|
||||
// Backspace = keyCode 51
|
||||
if event.keyCode == 51 && event.modifierFlags.contains(.command) {
|
||||
onCommandBackspace?()
|
||||
return true
|
||||
}
|
||||
return super.performKeyEquivalent(with: event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ProcessSummaryBar: View {
|
||||
@EnvironmentObject var monitor: SystemMonitor
|
||||
let processCount: Int
|
||||
@Binding var searchText: String
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Text("\(processCount) processes")
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Spacer()
|
||||
|
||||
if let cpu = monitor.cpuInfo {
|
||||
Text("CPU: \(String(format: "%.1f%%", cpu.globalUsage))")
|
||||
.monospacedDigit()
|
||||
}
|
||||
|
||||
if let mem = monitor.memoryInfo {
|
||||
Text("Memory: \(formatBytes(mem.usedMemory)) / \(formatBytes(mem.totalMemory))")
|
||||
.monospacedDigit()
|
||||
}
|
||||
|
||||
TextField("Search...", text: $searchText)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: 150)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 8)
|
||||
.background(Color(nsColor: .windowBackgroundColor))
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
ProcessView()
|
||||
.environmentObject(SystemMonitor.shared)
|
||||
}
|
||||