Initial MacTaskManager release
This commit is contained in:
@@ -0,0 +1,943 @@
|
||||
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])
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user