944 lines
41 KiB
Swift
944 lines
41 KiB
Swift
import Foundation
|
|
import Observation
|
|
|
|
@MainActor
|
|
@Observable
|
|
final class SystemMonitor {
|
|
var processes: [ProcessRecord] = []
|
|
var services: [ServiceRecord] = []
|
|
var appHistory: [AppUsageRecord] = []
|
|
var appHistoryStartDate = Date()
|
|
var processActivity: [Int32: ProcessActivityRate] = [:]
|
|
var snapshot = SystemSnapshot()
|
|
var cpuHistory: [MetricSample] = []
|
|
var coreHistories: [[MetricSample]] = []
|
|
var memoryHistory: [MetricSample] = []
|
|
var diskReadHistory: [MetricSample] = []
|
|
var diskWriteHistory: [MetricSample] = []
|
|
var diskLatencyHistory: [MetricSample] = []
|
|
var diskActiveHistory: [MetricSample] = []
|
|
var gpuHistory: [MetricSample] = []
|
|
var gpuRendererHistory: [MetricSample] = []
|
|
var gpuTilerHistory: [MetricSample] = []
|
|
var networkReceiveHistory: [MetricSample] = []
|
|
var networkSendHistory: [MetricSample] = []
|
|
var updateSpeed: UpdateSpeed {
|
|
didSet { UserDefaults.standard.set(updateSpeed.rawValue, forKey: "updateSpeed") }
|
|
}
|
|
var lastUpdated: Date?
|
|
var errorMessage: String?
|
|
|
|
private var updateTask: Task<Void, Never>?
|
|
private var previousCoreTicks: [CoreTicks] = []
|
|
private var previousNetworkCounters: NetworkCounters?
|
|
private var previousNetworkDate: Date?
|
|
private var previousDiskCounters: DiskCounters?
|
|
private var previousDiskDate: Date?
|
|
private var previousProcessCPU: [Int32: TimeInterval] = [:]
|
|
private var previousProcessNetwork: [Int32: ProcessNetworkTotals] = [:]
|
|
private var previousProcessNetworkDate: Date?
|
|
private var previousProcessIO: [Int32: ProcessIOTotals] = [:]
|
|
private var previousProcessIODate: Date?
|
|
private var appNetworkTask: Task<Void, Never>?
|
|
private var lastAppNetworkSample: Date?
|
|
|
|
init() {
|
|
updateSpeed = UpdateSpeed(rawValue: UserDefaults.standard.string(forKey: "updateSpeed") ?? "") ?? .normal
|
|
loadAppHistory()
|
|
}
|
|
|
|
var isPaused: Bool {
|
|
get { updateSpeed == .paused }
|
|
set { updateSpeed = newValue ? .paused : .normal }
|
|
}
|
|
|
|
func start() {
|
|
guard updateTask == nil else { return }
|
|
refresh()
|
|
updateTask = Task { [weak self] in
|
|
while !Task.isCancelled {
|
|
let interval = self?.updateSpeed.interval ?? .seconds(2)
|
|
try? await Task.sleep(for: interval)
|
|
guard let self, !self.isPaused else { continue }
|
|
self.refresh()
|
|
}
|
|
}
|
|
}
|
|
|
|
func refresh() {
|
|
Task {
|
|
let result = await Task.detached(priority: .userInitiated) {
|
|
ProcessScanner.capture()
|
|
}.value
|
|
processes = result.processes
|
|
updateCPUHistory(with: result.processes)
|
|
updateProcessDiskActivity(result.processIO, current: result.processes)
|
|
services = result.services
|
|
var refreshedSnapshot = result.snapshot
|
|
refreshedSnapshot.corePercents = CoreCPUReader.capture(previous: &previousCoreTicks)
|
|
let diskDate = Date()
|
|
if let previousDiskCounters, let previousDiskDate {
|
|
let elapsed = max(0.001, diskDate.timeIntervalSince(previousDiskDate))
|
|
let readBytes = result.disk.readBytes >= previousDiskCounters.readBytes
|
|
? result.disk.readBytes - previousDiskCounters.readBytes : 0
|
|
let writeBytes = result.disk.writeBytes >= previousDiskCounters.writeBytes
|
|
? result.disk.writeBytes - previousDiskCounters.writeBytes : 0
|
|
let readOperations = result.disk.readOperations >= previousDiskCounters.readOperations
|
|
? result.disk.readOperations - previousDiskCounters.readOperations : 0
|
|
let writeOperations = result.disk.writeOperations >= previousDiskCounters.writeOperations
|
|
? result.disk.writeOperations - previousDiskCounters.writeOperations : 0
|
|
let readTime = result.disk.readTime >= previousDiskCounters.readTime
|
|
? result.disk.readTime - previousDiskCounters.readTime : 0
|
|
let writeTime = result.disk.writeTime >= previousDiskCounters.writeTime
|
|
? result.disk.writeTime - previousDiskCounters.writeTime : 0
|
|
let operations = readOperations + writeOperations
|
|
let serviceTime = readTime + writeTime
|
|
refreshedSnapshot.diskReadRate = Double(readBytes) / elapsed
|
|
refreshedSnapshot.diskWriteRate = Double(writeBytes) / elapsed
|
|
refreshedSnapshot.diskOperationsPerSecond = Double(operations) / elapsed
|
|
refreshedSnapshot.diskLatencyMilliseconds = operations > 0
|
|
? Double(serviceTime) / Double(operations) / 1_000_000 : 0
|
|
refreshedSnapshot.diskActivePercent = min(100, Double(serviceTime) / (elapsed * 1_000_000_000) * 100)
|
|
}
|
|
previousDiskCounters = result.disk
|
|
previousDiskDate = diskDate
|
|
let networkDate = Date()
|
|
if let previousNetworkCounters, let previousNetworkDate {
|
|
let elapsed = max(0.001, networkDate.timeIntervalSince(previousNetworkDate))
|
|
if result.network.received >= previousNetworkCounters.received {
|
|
refreshedSnapshot.networkReceiveRate = Double(result.network.received - previousNetworkCounters.received) / elapsed
|
|
}
|
|
if result.network.sent >= previousNetworkCounters.sent {
|
|
refreshedSnapshot.networkSendRate = Double(result.network.sent - previousNetworkCounters.sent) / elapsed
|
|
}
|
|
}
|
|
refreshedSnapshot.networkInterface = result.network.interface
|
|
previousNetworkCounters = result.network
|
|
previousNetworkDate = networkDate
|
|
snapshot = refreshedSnapshot
|
|
lastUpdated = Date()
|
|
errorMessage = result.error
|
|
appendHistory(cpu: snapshot.cpuPercent, memory: snapshot.memoryPercent)
|
|
sampleAppNetworkIfNeeded()
|
|
}
|
|
}
|
|
|
|
func resetAppHistory() {
|
|
appHistory = []
|
|
appHistoryStartDate = Date()
|
|
previousProcessCPU = Dictionary(uniqueKeysWithValues: processes.map { ($0.pid, $0.cpuTime) })
|
|
previousProcessNetwork = [:]
|
|
saveAppHistory()
|
|
}
|
|
|
|
func activeProcess(for usage: AppUsageRecord) -> ProcessRecord? {
|
|
processes.first { AppIdentity.forProcess($0).id == usage.id }
|
|
}
|
|
|
|
func terminate(_ process: ProcessRecord, force: Bool = false) {
|
|
guard process.pid != getpid() else {
|
|
errorMessage = "Task Manager cannot end itself."
|
|
return
|
|
}
|
|
let signal = force ? SIGKILL : SIGTERM
|
|
if kill(process.pid, signal) == 0 {
|
|
processes.removeAll { $0.pid == process.pid }
|
|
Task {
|
|
try? await Task.sleep(for: .milliseconds(400))
|
|
refresh()
|
|
}
|
|
} else {
|
|
errorMessage = "Could not end \(process.displayName). You may not have permission."
|
|
}
|
|
}
|
|
|
|
func terminateTree(_ process: ProcessRecord) {
|
|
let descendants = descendantPIDs(of: process.pid)
|
|
for pid in descendants.reversed() { _ = kill(pid, SIGTERM) }
|
|
terminate(process)
|
|
}
|
|
|
|
func terminateGroup(_ group: [ProcessRecord]) {
|
|
guard !group.contains(where: { $0.pid == getpid() }) else {
|
|
errorMessage = "Task Manager cannot end an application group that contains itself."
|
|
return
|
|
}
|
|
let pids = Set(group.map(\.pid))
|
|
var failed = false
|
|
for process in group {
|
|
if kill(process.pid, SIGTERM) != 0 { failed = true }
|
|
}
|
|
processes.removeAll { pids.contains($0.pid) }
|
|
if failed { errorMessage = "Some processes could not be ended. You may not have permission." }
|
|
Task {
|
|
try? await Task.sleep(for: .milliseconds(500))
|
|
refresh()
|
|
}
|
|
}
|
|
|
|
func sendSignal(_ signal: Int32, to process: ProcessRecord, successMessage: String? = nil) {
|
|
guard process.pid != getpid() else {
|
|
errorMessage = "Task Manager cannot control itself."
|
|
return
|
|
}
|
|
if kill(process.pid, signal) != 0 {
|
|
errorMessage = "Could not control \(process.displayName). You may not have permission."
|
|
} else if let successMessage {
|
|
errorMessage = successMessage
|
|
}
|
|
Task {
|
|
try? await Task.sleep(for: .milliseconds(300))
|
|
refresh()
|
|
}
|
|
}
|
|
|
|
func setPriority(_ priority: Int, for process: ProcessRecord) {
|
|
let command = Process()
|
|
command.executableURL = URL(fileURLWithPath: "/usr/bin/renice")
|
|
command.arguments = ["-n", "\(priority)", "-p", "\(process.pid)"]
|
|
command.standardOutput = FileHandle.nullDevice
|
|
command.standardError = FileHandle.nullDevice
|
|
do {
|
|
try command.run()
|
|
command.waitUntilExit()
|
|
if command.terminationStatus != 0 {
|
|
errorMessage = "Could not change priority for \(process.displayName). Higher priorities may require administrator access."
|
|
}
|
|
} catch {
|
|
errorMessage = error.localizedDescription
|
|
}
|
|
}
|
|
|
|
func startService(_ service: ServiceRecord) {
|
|
runServiceCommand(["kickstart", serviceTarget(service)], action: "start", service: service)
|
|
}
|
|
|
|
func stopService(_ service: ServiceRecord) {
|
|
runServiceCommand(["kill", "SIGTERM", serviceTarget(service)], action: "stop", service: service)
|
|
}
|
|
|
|
func restartService(_ service: ServiceRecord) {
|
|
runServiceCommand(["kickstart", "-k", serviceTarget(service)], action: "restart", service: service)
|
|
}
|
|
|
|
private func serviceTarget(_ service: ServiceRecord) -> String {
|
|
"\(service.domain.launchctlPrefix)/\(service.label)"
|
|
}
|
|
|
|
private func runServiceCommand(_ arguments: [String], action: String, service: ServiceRecord) {
|
|
let command = Process()
|
|
let errorPipe = Pipe()
|
|
command.executableURL = URL(fileURLWithPath: "/bin/launchctl")
|
|
command.arguments = arguments
|
|
command.standardOutput = FileHandle.nullDevice
|
|
command.standardError = errorPipe
|
|
do {
|
|
try command.run()
|
|
command.waitUntilExit()
|
|
if command.terminationStatus != 0 {
|
|
let message = String(decoding: errorPipe.fileHandleForReading.readDataToEndOfFile(), as: UTF8.self)
|
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
errorMessage = message.isEmpty ? "Could not \(action) \(service.displayName)." : message
|
|
}
|
|
Task {
|
|
try? await Task.sleep(for: .milliseconds(500))
|
|
refresh()
|
|
}
|
|
} catch {
|
|
errorMessage = error.localizedDescription
|
|
}
|
|
}
|
|
|
|
private func descendantPIDs(of parentPID: Int32) -> [Int32] {
|
|
let children = processes.filter { $0.parentPID == parentPID }.map(\.pid)
|
|
return children + children.flatMap { descendantPIDs(of: $0) }
|
|
}
|
|
|
|
private func appendHistory(cpu: Double, memory: Double) {
|
|
let now = Date()
|
|
cpuHistory.append(MetricSample(date: now, value: cpu))
|
|
memoryHistory.append(MetricSample(date: now, value: memory))
|
|
networkReceiveHistory.append(MetricSample(date: now, value: snapshot.networkReceiveRate))
|
|
networkSendHistory.append(MetricSample(date: now, value: snapshot.networkSendRate))
|
|
diskReadHistory.append(MetricSample(date: now, value: snapshot.diskReadRate))
|
|
diskWriteHistory.append(MetricSample(date: now, value: snapshot.diskWriteRate))
|
|
diskLatencyHistory.append(MetricSample(date: now, value: snapshot.diskLatencyMilliseconds))
|
|
diskActiveHistory.append(MetricSample(date: now, value: snapshot.diskActivePercent))
|
|
gpuHistory.append(MetricSample(date: now, value: snapshot.gpuPercent))
|
|
gpuRendererHistory.append(MetricSample(date: now, value: snapshot.gpuRendererPercent))
|
|
gpuTilerHistory.append(MetricSample(date: now, value: snapshot.gpuTilerPercent))
|
|
if coreHistories.count != snapshot.corePercents.count {
|
|
coreHistories = Array(repeating: [], count: snapshot.corePercents.count)
|
|
}
|
|
for index in snapshot.corePercents.indices {
|
|
coreHistories[index].append(MetricSample(date: now, value: snapshot.corePercents[index]))
|
|
}
|
|
let cutoff = now.addingTimeInterval(-60)
|
|
cpuHistory.removeAll { $0.date < cutoff }
|
|
memoryHistory.removeAll { $0.date < cutoff }
|
|
networkReceiveHistory.removeAll { $0.date < cutoff }
|
|
networkSendHistory.removeAll { $0.date < cutoff }
|
|
diskReadHistory.removeAll { $0.date < cutoff }
|
|
diskWriteHistory.removeAll { $0.date < cutoff }
|
|
diskLatencyHistory.removeAll { $0.date < cutoff }
|
|
diskActiveHistory.removeAll { $0.date < cutoff }
|
|
gpuHistory.removeAll { $0.date < cutoff }
|
|
gpuRendererHistory.removeAll { $0.date < cutoff }
|
|
gpuTilerHistory.removeAll { $0.date < cutoff }
|
|
for index in coreHistories.indices {
|
|
coreHistories[index].removeAll { $0.date < cutoff }
|
|
}
|
|
}
|
|
|
|
|
|
private func updateCPUHistory(with current: [ProcessRecord]) {
|
|
var records = Dictionary(uniqueKeysWithValues: appHistory.map { ($0.id, $0) })
|
|
let now = Date()
|
|
for process in current where process.user == NSUserName() {
|
|
let identity = AppIdentity.forProcess(process)
|
|
var usage = records[identity.id] ?? AppUsageRecord(
|
|
id: identity.id,
|
|
name: identity.name,
|
|
executablePath: identity.path,
|
|
user: process.user,
|
|
cpuSeconds: 0,
|
|
networkReceivedBytes: 0,
|
|
networkSentBytes: 0,
|
|
lastSeen: now
|
|
)
|
|
if let old = previousProcessCPU[process.pid], process.cpuTime >= old {
|
|
usage.cpuSeconds += process.cpuTime - old
|
|
}
|
|
usage.lastSeen = now
|
|
records[identity.id] = usage
|
|
}
|
|
previousProcessCPU = Dictionary(uniqueKeysWithValues: current.map { ($0.pid, $0.cpuTime) })
|
|
appHistory = Array(records.values)
|
|
saveAppHistory()
|
|
}
|
|
|
|
private func sampleAppNetworkIfNeeded() {
|
|
guard appNetworkTask == nil,
|
|
lastAppNetworkSample.map({ Date().timeIntervalSince($0) >= 10 }) ?? true else { return }
|
|
lastAppNetworkSample = Date()
|
|
appNetworkTask = Task { [weak self] in
|
|
let totals = await Task.detached(priority: .utility) { AppNetworkScanner.capture() }.value
|
|
guard let self else { return }
|
|
self.mergeNetworkHistory(totals)
|
|
self.appNetworkTask = nil
|
|
}
|
|
}
|
|
|
|
private func mergeNetworkHistory(_ totals: [Int32: ProcessNetworkTotals]) {
|
|
var records = Dictionary(uniqueKeysWithValues: appHistory.map { ($0.id, $0) })
|
|
let processByPID = Dictionary(uniqueKeysWithValues: processes.map { ($0.pid, $0) })
|
|
let now = Date()
|
|
let elapsed = previousProcessNetworkDate.map { max(0.001, now.timeIntervalSince($0)) }
|
|
for (pid, total) in totals {
|
|
guard let process = processByPID[pid], process.user == NSUserName() else { continue }
|
|
let identity = AppIdentity.forProcess(process)
|
|
guard var usage = records[identity.id] else { continue }
|
|
if let old = previousProcessNetwork[pid] {
|
|
if total.received >= old.received { usage.networkReceivedBytes += total.received - old.received }
|
|
if total.sent >= old.sent { usage.networkSentBytes += total.sent - old.sent }
|
|
}
|
|
records[identity.id] = usage
|
|
}
|
|
if let elapsed {
|
|
for (pid, total) in totals {
|
|
guard let old = previousProcessNetwork[pid] else { continue }
|
|
var activity = processActivity[pid] ?? ProcessActivityRate()
|
|
activity.networkReceive = total.received >= old.received ? Double(total.received - old.received) / elapsed : 0
|
|
activity.networkSend = total.sent >= old.sent ? Double(total.sent - old.sent) / elapsed : 0
|
|
processActivity[pid] = activity
|
|
}
|
|
}
|
|
previousProcessNetwork = totals
|
|
previousProcessNetworkDate = now
|
|
appHistory = Array(records.values)
|
|
saveAppHistory()
|
|
}
|
|
|
|
private func updateProcessDiskActivity(_ totals: [Int32: ProcessIOTotals], current: [ProcessRecord]) {
|
|
let now = Date()
|
|
let activePIDs = Set(current.map(\.pid))
|
|
processActivity = processActivity.filter { activePIDs.contains($0.key) }
|
|
if let previousProcessIODate {
|
|
let elapsed = max(0.001, now.timeIntervalSince(previousProcessIODate))
|
|
for (pid, total) in totals {
|
|
guard let old = previousProcessIO[pid] else { continue }
|
|
var activity = processActivity[pid] ?? ProcessActivityRate()
|
|
activity.diskRead = total.read >= old.read ? Double(total.read - old.read) / elapsed : 0
|
|
activity.diskWrite = total.written >= old.written ? Double(total.written - old.written) / elapsed : 0
|
|
processActivity[pid] = activity
|
|
}
|
|
}
|
|
previousProcessIO = totals
|
|
previousProcessIODate = now
|
|
}
|
|
|
|
private func loadAppHistory() {
|
|
guard let data = try? Data(contentsOf: AppHistoryPersistence.url),
|
|
let state = try? JSONDecoder().decode(AppHistoryPersistence.State.self, from: data) else { return }
|
|
appHistoryStartDate = state.startDate
|
|
appHistory = state.records
|
|
}
|
|
|
|
private func saveAppHistory() {
|
|
let state = AppHistoryPersistence.State(startDate: appHistoryStartDate, records: appHistory)
|
|
guard let data = try? JSONEncoder().encode(state) else { return }
|
|
do {
|
|
try FileManager.default.createDirectory(at: AppHistoryPersistence.directory, withIntermediateDirectories: true)
|
|
try data.write(to: AppHistoryPersistence.url, options: .atomic)
|
|
} catch {
|
|
errorMessage = "App history could not be saved: \(error.localizedDescription)"
|
|
}
|
|
}
|
|
}
|
|
|
|
private enum AppHistoryPersistence {
|
|
struct State: Codable {
|
|
let startDate: Date
|
|
let records: [AppUsageRecord]
|
|
}
|
|
|
|
static let directory = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
|
|
.appendingPathComponent("MacTaskManager", isDirectory: true)
|
|
static let url = directory.appendingPathComponent("app-history.json")
|
|
}
|
|
|
|
private struct AppIdentity {
|
|
let id: String
|
|
let name: String
|
|
let path: String
|
|
|
|
static func forProcess(_ process: ProcessRecord) -> AppIdentity {
|
|
let path = process.executablePath
|
|
if let range = path.range(of: ".app/Contents/", options: .caseInsensitive) {
|
|
let appPath = String(path[..<range.lowerBound]) + ".app"
|
|
let filename = URL(fileURLWithPath: appPath).deletingPathExtension().lastPathComponent
|
|
return AppIdentity(id: appPath, name: filename, path: appPath)
|
|
}
|
|
return AppIdentity(id: "\(process.user)|\(path)", name: process.displayName, path: path)
|
|
}
|
|
}
|
|
|
|
private struct ProcessNetworkTotals: Sendable {
|
|
let received: UInt64
|
|
let sent: UInt64
|
|
}
|
|
|
|
private struct ProcessIOTotals: Sendable {
|
|
let read: UInt64
|
|
let written: UInt64
|
|
}
|
|
|
|
private enum AppNetworkScanner {
|
|
static func capture() -> [Int32: ProcessNetworkTotals] {
|
|
let process = Process()
|
|
let pipe = Pipe()
|
|
process.executableURL = URL(fileURLWithPath: "/usr/bin/nettop")
|
|
process.arguments = ["-P", "-x", "-l", "1", "-J", "bytes_in,bytes_out"]
|
|
process.standardOutput = pipe
|
|
process.standardError = FileHandle.nullDevice
|
|
do {
|
|
try process.run()
|
|
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
|
process.waitUntilExit()
|
|
guard process.terminationStatus == 0 else { return [:] }
|
|
return parse(String(decoding: data, as: UTF8.self))
|
|
} catch {
|
|
return [:]
|
|
}
|
|
}
|
|
|
|
private static func parse(_ output: String) -> [Int32: ProcessNetworkTotals] {
|
|
var result: [Int32: ProcessNetworkTotals] = [:]
|
|
for line in output.split(separator: "\n").dropFirst() {
|
|
let fields = line.split(whereSeparator: { $0 == " " || $0 == "\t" })
|
|
guard fields.count >= 3,
|
|
let received = UInt64(fields[fields.count - 2]),
|
|
let sent = UInt64(fields[fields.count - 1]) else { continue }
|
|
let identity = fields.dropLast(2).joined(separator: " ")
|
|
guard let dot = identity.lastIndex(of: "."), let pid = Int32(identity[identity.index(after: dot)...]) else { continue }
|
|
result[pid] = ProcessNetworkTotals(received: received, sent: sent)
|
|
}
|
|
return result
|
|
}
|
|
}
|
|
|
|
private struct NetworkCounters: Sendable {
|
|
let received: UInt64
|
|
let sent: UInt64
|
|
let interface: String
|
|
}
|
|
|
|
private struct DiskCounters: Sendable {
|
|
var readBytes: UInt64 = 0
|
|
var writeBytes: UInt64 = 0
|
|
var readOperations: UInt64 = 0
|
|
var writeOperations: UInt64 = 0
|
|
var readTime: UInt64 = 0
|
|
var writeTime: UInt64 = 0
|
|
}
|
|
|
|
private struct GPUCounters: Sendable {
|
|
var devicePercent = 0.0
|
|
var rendererPercent = 0.0
|
|
var tilerPercent = 0.0
|
|
var memoryBytes: UInt64 = 0
|
|
var allocatedBytes: UInt64 = 0
|
|
var coreCount = 0
|
|
}
|
|
|
|
private struct MemoryCounters: Sendable {
|
|
var used: UInt64 = 0
|
|
var active: UInt64 = 0
|
|
var wired: UInt64 = 0
|
|
var compressed: UInt64 = 0
|
|
var cached: UInt64 = 0
|
|
var swapUsed: UInt64 = 0
|
|
var swapTotal: UInt64 = 0
|
|
}
|
|
|
|
private struct MemoryHardwareInfo: Sendable {
|
|
var type = "Unified"
|
|
var speed = "Not reported by macOS"
|
|
var manufacturer = "Apple unified memory"
|
|
}
|
|
|
|
private struct CoreTicks {
|
|
let active: UInt64
|
|
let idle: UInt64
|
|
}
|
|
|
|
private enum CoreCPUReader {
|
|
static func capture(previous: inout [CoreTicks]) -> [Double] {
|
|
var processorCount: natural_t = 0
|
|
var info: processor_info_array_t?
|
|
var infoCount: mach_msg_type_number_t = 0
|
|
let result = host_processor_info(
|
|
mach_host_self(),
|
|
PROCESSOR_CPU_LOAD_INFO,
|
|
&processorCount,
|
|
&info,
|
|
&infoCount
|
|
)
|
|
guard result == KERN_SUCCESS, let info else { return [] }
|
|
defer {
|
|
vm_deallocate(
|
|
mach_task_self_,
|
|
vm_address_t(UInt(bitPattern: info)),
|
|
vm_size_t(infoCount) * vm_size_t(MemoryLayout<integer_t>.stride)
|
|
)
|
|
}
|
|
|
|
let current = (0..<Int(processorCount)).map { core -> CoreTicks in
|
|
let offset = core * Int(CPU_STATE_MAX)
|
|
let user = UInt64(info[offset + Int(CPU_STATE_USER)])
|
|
let system = UInt64(info[offset + Int(CPU_STATE_SYSTEM)])
|
|
let nice = UInt64(info[offset + Int(CPU_STATE_NICE)])
|
|
let idle = UInt64(info[offset + Int(CPU_STATE_IDLE)])
|
|
return CoreTicks(active: user + system + nice, idle: idle)
|
|
}
|
|
|
|
defer { previous = current }
|
|
guard previous.count == current.count else { return Array(repeating: 0, count: current.count) }
|
|
return zip(current, previous).map { current, old in
|
|
let activeDelta = current.active >= old.active ? current.active - old.active : 0
|
|
let idleDelta = current.idle >= old.idle ? current.idle - old.idle : 0
|
|
let total = activeDelta + idleDelta
|
|
return total > 0 ? min(100, Double(activeDelta) / Double(total) * 100) : 0
|
|
}
|
|
}
|
|
}
|
|
|
|
private enum ProcessScanner {
|
|
private static let memoryHardware = memoryHardwareInfo()
|
|
|
|
struct Result: Sendable {
|
|
let processes: [ProcessRecord]
|
|
let services: [ServiceRecord]
|
|
let snapshot: SystemSnapshot
|
|
let network: NetworkCounters
|
|
let disk: DiskCounters
|
|
let processIO: [Int32: ProcessIOTotals]
|
|
let error: String?
|
|
}
|
|
|
|
static func capture() -> Result {
|
|
let ps = run("/bin/ps", arguments: ["-axo", "pid=,ppid=,user=,%cpu=,%mem=,rss=,state=,etime=,time=,pri=,nice=,comm="])
|
|
let threads = threadCounts()
|
|
let records = parseProcesses(ps.output, threadCounts: threads)
|
|
let userServices = parseServices(run("/bin/launchctl", arguments: ["list"]).output, domain: .user)
|
|
let systemServices = parseSystemServices(run("/bin/launchctl", arguments: ["print", "system"]).output)
|
|
let services = userServices + systemServices
|
|
let cpu = min(100, records.reduce(0) { $0 + $1.cpu })
|
|
let totalMemory = ProcessInfo.processInfo.physicalMemory
|
|
let memory = memoryCounters(total: totalMemory)
|
|
let disk = diskUsage()
|
|
var snapshot = SystemSnapshot()
|
|
snapshot.cpuPercent = cpu
|
|
snapshot.memoryUsed = memory.used
|
|
snapshot.memoryTotal = totalMemory
|
|
snapshot.memoryActive = memory.active
|
|
snapshot.memoryWired = memory.wired
|
|
snapshot.memoryCompressed = memory.compressed
|
|
snapshot.memoryCached = memory.cached
|
|
snapshot.swapUsed = memory.swapUsed
|
|
snapshot.swapTotal = memory.swapTotal
|
|
snapshot.memoryType = memoryHardware.type
|
|
snapshot.memorySpeed = memoryHardware.speed
|
|
snapshot.memoryManufacturer = memoryHardware.manufacturer
|
|
snapshot.diskFree = disk.free
|
|
snapshot.diskTotal = disk.total
|
|
snapshot.removableVolumes = removableVolumes()
|
|
snapshot.processCount = records.count
|
|
snapshot.threadCount = threads.values.reduce(0, +)
|
|
snapshot.uptime = ProcessInfo.processInfo.systemUptime
|
|
let gpu = gpuCounters()
|
|
snapshot.gpuPercent = gpu.devicePercent
|
|
snapshot.gpuRendererPercent = gpu.rendererPercent
|
|
snapshot.gpuTilerPercent = gpu.tilerPercent
|
|
snapshot.gpuMemoryBytes = gpu.memoryBytes
|
|
snapshot.gpuAllocatedBytes = gpu.allocatedBytes
|
|
snapshot.gpuCoreCount = gpu.coreCount
|
|
return Result(
|
|
processes: records,
|
|
services: services,
|
|
snapshot: snapshot,
|
|
network: networkCounters(),
|
|
disk: diskCounters(),
|
|
processIO: processIOTotals(records.map(\.pid)),
|
|
error: ps.error
|
|
)
|
|
}
|
|
|
|
private static func processIOTotals(_ pids: [Int32]) -> [Int32: ProcessIOTotals] {
|
|
var totals: [Int32: ProcessIOTotals] = [:]
|
|
for pid in pids {
|
|
var usage = rusage_info_v2()
|
|
let result = withUnsafeMutablePointer(to: &usage) { pointer in
|
|
pointer.withMemoryRebound(to: rusage_info_t?.self, capacity: 1) {
|
|
proc_pid_rusage(pid, RUSAGE_INFO_V2, $0)
|
|
}
|
|
}
|
|
if result == 0 {
|
|
totals[pid] = ProcessIOTotals(read: usage.ri_diskio_bytesread, written: usage.ri_diskio_byteswritten)
|
|
}
|
|
}
|
|
return totals
|
|
}
|
|
|
|
private static func diskCounters() -> DiskCounters {
|
|
let output = run(
|
|
"/usr/sbin/ioreg",
|
|
arguments: ["-r", "-c", "IOBlockStorageDriver", "-k", "Statistics", "-a"]
|
|
).output
|
|
guard let data = output.data(using: .utf8),
|
|
let drivers = try? PropertyListSerialization.propertyList(from: data, format: nil) as? [[String: Any]] else {
|
|
return DiskCounters()
|
|
}
|
|
var result = DiskCounters()
|
|
for driver in drivers {
|
|
guard let statistics = driver["Statistics"] as? [String: Any] else { continue }
|
|
func value(_ key: String) -> UInt64 {
|
|
(statistics[key] as? NSNumber)?.uint64Value ?? 0
|
|
}
|
|
result.readBytes += value("Bytes (Read)")
|
|
result.writeBytes += value("Bytes (Write)")
|
|
result.readOperations += value("Operations (Read)")
|
|
result.writeOperations += value("Operations (Write)")
|
|
result.readTime += value("Total Time (Read)")
|
|
result.writeTime += value("Total Time (Write)")
|
|
}
|
|
return result
|
|
}
|
|
|
|
private static func gpuCounters() -> GPUCounters {
|
|
let output = run(
|
|
"/usr/sbin/ioreg",
|
|
arguments: ["-r", "-c", "AGXAccelerator", "-l"]
|
|
).output
|
|
func number(_ key: String) -> UInt64 {
|
|
UInt64(output.firstMatch("\"\(NSRegularExpression.escapedPattern(for: key))\" *= *(\\d+)") ?? "0") ?? 0
|
|
}
|
|
return GPUCounters(
|
|
devicePercent: Double(number("Device Utilization %")),
|
|
rendererPercent: Double(number("Renderer Utilization %")),
|
|
tilerPercent: Double(number("Tiler Utilization %")),
|
|
memoryBytes: number("In use system memory"),
|
|
allocatedBytes: number("Alloc system memory"),
|
|
coreCount: Int(number("gpu-core-count"))
|
|
)
|
|
}
|
|
|
|
private static func parseProcesses(_ output: String, threadCounts: [Int32: Int]) -> [ProcessRecord] {
|
|
output.split(separator: "\n").compactMap { line in
|
|
let fields = line.split(maxSplits: 11, whereSeparator: { $0 == " " || $0 == "\t" })
|
|
guard fields.count == 12,
|
|
let pid = Int32(fields[0]),
|
|
let ppid = Int32(fields[1]),
|
|
let cpu = Double(fields[3]),
|
|
let memory = Double(fields[4]),
|
|
let rssKB = UInt64(fields[5]),
|
|
let priority = Int(fields[9]),
|
|
let nice = Int(fields[10]) else { return nil }
|
|
let path = String(fields[11])
|
|
let name = URL(fileURLWithPath: path).lastPathComponent
|
|
let normalizedCPU = cpu / Double(max(1, ProcessInfo.processInfo.activeProcessorCount))
|
|
return ProcessRecord(
|
|
pid: pid,
|
|
parentPID: ppid,
|
|
name: name,
|
|
executablePath: path,
|
|
user: String(fields[2]),
|
|
cpu: normalizedCPU,
|
|
memoryPercent: memory,
|
|
residentBytes: rssKB * 1024,
|
|
state: String(fields[6]),
|
|
elapsed: String(fields[7]),
|
|
cpuTime: parseCPUTime(String(fields[8])),
|
|
threadCount: threadCounts[pid] ?? 0,
|
|
openFileCount: openFileCount(pid),
|
|
architecture: BinaryArchitectureReader.architecture(at: path),
|
|
priority: priority,
|
|
nice: nice
|
|
)
|
|
}
|
|
}
|
|
|
|
private static func parseCPUTime(_ value: String) -> TimeInterval {
|
|
let dayParts = value.split(separator: "-", maxSplits: 1).map(String.init)
|
|
let days = dayParts.count == 2 ? Double(dayParts[0]) ?? 0 : 0
|
|
let clock = (dayParts.count == 2 ? dayParts[1] : dayParts[0]).split(separator: ":").compactMap { Double($0) }
|
|
guard !clock.isEmpty else { return days * 86_400 }
|
|
var multiplier = 1.0
|
|
var seconds = 0.0
|
|
for component in clock.reversed() {
|
|
seconds += component * multiplier
|
|
multiplier *= 60
|
|
}
|
|
return days * 86_400 + seconds
|
|
}
|
|
|
|
private static func parseServices(_ output: String, domain: ServiceDomain) -> [ServiceRecord] {
|
|
output.split(separator: "\n").dropFirst().compactMap { line in
|
|
let fields = line.split(maxSplits: 2, whereSeparator: { $0 == " " || $0 == "\t" })
|
|
guard fields.count == 3, let status = Int32(fields[1]) else { return nil }
|
|
let pid = fields[0] == "-" ? nil : Int32(fields[0])
|
|
let label = String(fields[2]).trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !label.isEmpty else { return nil }
|
|
return ServiceRecord(label: label, pid: pid, lastExitStatus: status, domain: domain)
|
|
}
|
|
}
|
|
|
|
private static func parseSystemServices(_ output: String) -> [ServiceRecord] {
|
|
guard let start = output.range(of: "\n\tservices = {\n") else { return [] }
|
|
let body = output[start.upperBound...]
|
|
guard let end = body.range(of: "\n\t}") else { return [] }
|
|
return body[..<end.lowerBound].split(separator: "\n").compactMap { line in
|
|
let fields = line.split(maxSplits: 2, whereSeparator: { $0 == " " || $0 == "\t" })
|
|
guard fields.count == 3, let rawPID = Int32(fields[0]) else { return nil }
|
|
let status = Int32(fields[1]) ?? 0
|
|
let label = String(fields[2]).trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !label.isEmpty else { return nil }
|
|
return ServiceRecord(
|
|
label: label,
|
|
pid: rawPID > 0 ? rawPID : nil,
|
|
lastExitStatus: status,
|
|
domain: .system
|
|
)
|
|
}
|
|
}
|
|
|
|
private static func memoryCounters(total: UInt64) -> MemoryCounters {
|
|
let vm = run("/usr/bin/vm_stat", arguments: []).output
|
|
let pageSize = UInt64(vm.firstMatch(#"page size of (\d+) bytes"#) ?? "4096") ?? 4096
|
|
func pages(_ label: String) -> UInt64 {
|
|
UInt64(vm.firstMatch("\(NSRegularExpression.escapedPattern(for: label)): +([0-9]+)") ?? "0") ?? 0
|
|
}
|
|
let free = (pages("Pages free") + pages("Pages speculative")) * pageSize
|
|
var swap = xsw_usage()
|
|
var swapSize = MemoryLayout<xsw_usage>.size
|
|
_ = sysctlbyname("vm.swapusage", &swap, &swapSize, nil, 0)
|
|
return MemoryCounters(
|
|
used: total > free ? total - free : 0,
|
|
active: pages("Pages active") * pageSize,
|
|
wired: pages("Pages wired down") * pageSize,
|
|
compressed: pages("Pages occupied by compressor") * pageSize,
|
|
cached: (pages("Pages inactive") + pages("Pages purgeable")) * pageSize,
|
|
swapUsed: swap.xsu_used,
|
|
swapTotal: swap.xsu_total
|
|
)
|
|
}
|
|
|
|
private static func memoryHardwareInfo() -> MemoryHardwareInfo {
|
|
let output = run("/usr/sbin/system_profiler", arguments: ["SPMemoryDataType", "-detailLevel", "mini"]).output
|
|
return MemoryHardwareInfo(
|
|
type: output.firstMatch(#"Type: +([^\n]+)"#) ?? "Unified",
|
|
speed: output.firstMatch(#"Speed: +([^\n]+)"#) ?? "Not reported by macOS",
|
|
manufacturer: output.firstMatch(#"Manufacturer: +([^\n]+)"#) ?? "Apple unified memory"
|
|
)
|
|
}
|
|
|
|
private static func removableVolumes() -> [VolumeSnapshot] {
|
|
let keys: [URLResourceKey] = [
|
|
.volumeNameKey,
|
|
.volumeLocalizedFormatDescriptionKey,
|
|
.volumeIsRemovableKey,
|
|
.volumeIsEjectableKey,
|
|
.volumeTotalCapacityKey,
|
|
.volumeAvailableCapacityForImportantUsageKey
|
|
]
|
|
let urls = FileManager.default.mountedVolumeURLs(
|
|
includingResourceValuesForKeys: keys,
|
|
options: [.skipHiddenVolumes]
|
|
) ?? []
|
|
return urls.compactMap { url in
|
|
guard let values = try? url.resourceValues(forKeys: Set(keys)),
|
|
values.volumeIsRemovable == true || values.volumeIsEjectable == true else { return nil }
|
|
let capacity = UInt64(max(0, values.volumeTotalCapacity ?? 0))
|
|
let available = UInt64(max(0, values.volumeAvailableCapacityForImportantUsage ?? 0))
|
|
return VolumeSnapshot(
|
|
id: url.path,
|
|
name: values.volumeName ?? url.lastPathComponent,
|
|
mountPath: url.path,
|
|
fileSystem: values.volumeLocalizedFormatDescription ?? "Removable storage",
|
|
capacity: capacity,
|
|
available: available,
|
|
isEjectable: values.volumeIsEjectable ?? false
|
|
)
|
|
}
|
|
.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
|
|
}
|
|
|
|
private static func diskUsage() -> (free: UInt64, total: UInt64) {
|
|
guard let values = try? URL(fileURLWithPath: "/").resourceValues(forKeys: [
|
|
.volumeAvailableCapacityForImportantUsageKey,
|
|
.volumeTotalCapacityKey
|
|
]) else { return (0, 0) }
|
|
return (UInt64(max(0, values.volumeAvailableCapacityForImportantUsage ?? 0)), UInt64(max(0, values.volumeTotalCapacity ?? 0)))
|
|
}
|
|
|
|
private static func threadCounts() -> [Int32: Int] {
|
|
let result = run("/bin/ps", arguments: ["-M", "-axo", "pid="]).output
|
|
let rawCounts = result.split(separator: "\n").reduce(into: [Int32: Int]()) { counts, line in
|
|
guard let last = line.split(whereSeparator: { $0 == " " || $0 == "\t" }).last,
|
|
let pid = Int32(last) else { return }
|
|
counts[pid, default: 0] += 1
|
|
}
|
|
return rawCounts.mapValues { max(1, $0 - 1) }
|
|
}
|
|
|
|
private static func openFileCount(_ pid: Int32) -> Int {
|
|
let bytes = Int(proc_pidinfo(pid, PROC_PIDLISTFDS, 0, nil, 0))
|
|
return bytes > 0 ? bytes / MemoryLayout<proc_fdinfo>.stride : 0
|
|
}
|
|
|
|
private static func networkCounters() -> NetworkCounters {
|
|
let route = run("/sbin/route", arguments: ["-n", "get", "default"]).output
|
|
let primary = route.firstMatch(#"interface: +([^\s]+)"#) ?? "Network"
|
|
let netstat = run("/usr/sbin/netstat", arguments: ["-ibn"]).output
|
|
var received: UInt64 = 0
|
|
var sent: UInt64 = 0
|
|
var matchedPrimary = false
|
|
|
|
for line in netstat.split(separator: "\n") {
|
|
let fields = line.split(whereSeparator: { $0 == " " || $0 == "\t" }).map(String.init)
|
|
guard fields.count >= 10,
|
|
fields[2].hasPrefix("<Link#"),
|
|
fields[0] != "lo0",
|
|
!fields[0].hasSuffix("*") else { continue }
|
|
if primary != "Network", fields[0] != primary { continue }
|
|
let counters = Array(fields.suffix(7))
|
|
guard let inputBytes = UInt64(counters[2]), let outputBytes = UInt64(counters[5]) else { continue }
|
|
received += inputBytes
|
|
sent += outputBytes
|
|
matchedPrimary = true
|
|
}
|
|
|
|
if !matchedPrimary, primary != "Network" {
|
|
return NetworkCounters(received: 0, sent: 0, interface: primary)
|
|
}
|
|
return NetworkCounters(received: received, sent: sent, interface: primary)
|
|
}
|
|
|
|
private static func run(_ path: String, arguments: [String]) -> (output: String, error: String?) {
|
|
let process = Process()
|
|
let pipe = Pipe()
|
|
process.executableURL = URL(fileURLWithPath: path)
|
|
process.arguments = arguments
|
|
process.standardOutput = pipe
|
|
process.standardError = FileHandle.nullDevice
|
|
do {
|
|
try process.run()
|
|
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
|
process.waitUntilExit()
|
|
return (String(decoding: data, as: UTF8.self), process.terminationStatus == 0 ? nil : "System data is temporarily unavailable.")
|
|
} catch {
|
|
return ("", error.localizedDescription)
|
|
}
|
|
}
|
|
}
|
|
|
|
private enum BinaryArchitectureReader {
|
|
private static let machO64: UInt32 = 0xfeedfacf
|
|
private static let machO64Swapped: UInt32 = 0xcffaedfe
|
|
private static let fat32: UInt32 = 0xcafebabe
|
|
private static let fat32Swapped: UInt32 = 0xbebafeca
|
|
private static let fat64: UInt32 = 0xcafebabf
|
|
private static let fat64Swapped: UInt32 = 0xbfbafeca
|
|
private static let arm64: UInt32 = 0x0100000c
|
|
private static let x86_64: UInt32 = 0x01000007
|
|
|
|
static func architecture(at path: String) -> String {
|
|
guard path.hasPrefix("/"),
|
|
let handle = try? FileHandle(forReadingFrom: URL(fileURLWithPath: path)) else { return "Unknown" }
|
|
defer { try? handle.close() }
|
|
guard let data = try? handle.read(upToCount: 512), data.count >= 8 else { return "Unknown" }
|
|
let magicBE = read(data, offset: 0, bigEndian: true)
|
|
let magicLE = read(data, offset: 0, bigEndian: false)
|
|
if magicBE == fat32 || magicBE == fat64 || magicBE == fat32Swapped || magicBE == fat64Swapped {
|
|
let bigEndian = magicBE == fat32 || magicBE == fat64
|
|
let is64 = magicBE == fat64 || magicBE == fat64Swapped
|
|
let count = min(Int(read(data, offset: 4, bigEndian: bigEndian)), 16)
|
|
let stride = is64 ? 32 : 20
|
|
var architectures: Set<UInt32> = []
|
|
for index in 0..<count {
|
|
let offset = 8 + index * stride
|
|
guard offset + 4 <= data.count else { break }
|
|
architectures.insert(read(data, offset: offset, bigEndian: bigEndian))
|
|
}
|
|
if architectures.contains(arm64) { return "ARM64" }
|
|
if architectures.contains(x86_64) { return "x86_64" }
|
|
return "Universal"
|
|
}
|
|
if magicLE == machO64 || magicBE == machO64Swapped {
|
|
let type = read(data, offset: 4, bigEndian: false)
|
|
if type == arm64 { return "ARM64" }
|
|
if type == x86_64 { return "x86_64" }
|
|
}
|
|
return "Unknown"
|
|
}
|
|
|
|
private static func read(_ data: Data, offset: Int, bigEndian: Bool) -> UInt32 {
|
|
guard offset + 4 <= data.count else { return 0 }
|
|
let bytes = data[offset..<(offset + 4)]
|
|
if bigEndian {
|
|
return bytes.reduce(0) { ($0 << 8) | UInt32($1) }
|
|
}
|
|
return bytes.reversed().reduce(0) { ($0 << 8) | UInt32($1) }
|
|
}
|
|
}
|
|
|
|
private extension String {
|
|
func firstMatch(_ pattern: String) -> String? {
|
|
guard let regex = try? NSRegularExpression(pattern: pattern),
|
|
let match = regex.firstMatch(in: self, range: NSRange(startIndex..., in: self)),
|
|
match.numberOfRanges > 1,
|
|
let range = Range(match.range(at: 1), in: self) else { return nil }
|
|
return String(self[range])
|
|
}
|
|
}
|