From a1758c323376b825596d3badd5700767e683774d Mon Sep 17 00:00:00 2001 From: Serhiy Mytrovtsiy Date: Thu, 6 Apr 2023 18:17:25 +0200 Subject: [PATCH] feat: added top processes to the Disk module (#1370) --- Modules/Disk/main.swift | 72 ++++++++-- Modules/Disk/popup.swift | 255 ++++++++++++++++++++++++++++++++---- Modules/Disk/portal.swift | 6 +- Modules/Disk/readers.swift | 101 ++++++++++++++ Modules/Disk/settings.swift | 40 ++++-- 5 files changed, 425 insertions(+), 49 deletions(-) diff --git a/Modules/Disk/main.swift b/Modules/Disk/main.swift index 370fe377..42417dde 100644 --- a/Modules/Disk/main.swift +++ b/Modules/Disk/main.swift @@ -128,28 +128,62 @@ public class Disks { } } +public struct Disk_process: IOProcess_p { + private var base: DataSizeBase { + DataSizeBase(rawValue: Store.shared.string(key: "\(Disk.name)_base", defaultValue: "byte")) ?? .byte + } + + public var pid: Int32 + public var name: String + public var icon: NSImage = Constants.defaultProcessIcon + + var read: Int + var write: Int + + public var input: String { + Units(bytes: Int64(self.read)).getReadableSpeed(base: self.base) + } + public var output: String { + Units(bytes: Int64(self.write)).getReadableSpeed(base: self.base) + } + + init(pid: Int32, name: String, read: Int, write: Int) { + self.pid = pid + self.name = name + self.read = read + self.write = write + + if let app = NSRunningApplication(processIdentifier: pid) { + if let name = app.localizedName { + self.name = name + } + if let icon = app.icon { + self.icon = icon + } + } + } +} + public class Disk: Module { - private let popupView: Popup - private let settingsView: Settings - private let portalView: Portal + public static let name: String = "Disk" + + private let popupView: Popup = Popup() + private let settingsView: Settings = Settings() + private let portalView: Portal = Portal() private var capacityReader: CapacityReader? = nil private var activityReader: ActivityReader? = nil + private var processReader: ProcessReader? = nil + private var selectedDisk: String = "" private var notificationLevelState: Bool = false private var notificationID: String? = nil private var notificationLevel: String { - get { - return Store.shared.string(key: "\(self.config.name)_notificationLevel", defaultValue: "Disabled") - } + Store.shared.string(key: "\(Disk.name)_notificationLevel", defaultValue: "Disabled") } public init() { - self.popupView = Popup() - self.settingsView = Settings("Disk") - self.portalView = Portal("Disk") - super.init( popup: self.popupView, settings: self.settingsView, @@ -159,7 +193,9 @@ public class Disk: Module { self.capacityReader = CapacityReader() self.activityReader = ActivityReader() - self.selectedDisk = Store.shared.string(key: "\(self.config.name)_disk", defaultValue: self.selectedDisk) + self.processReader = ProcessReader() + + self.selectedDisk = Store.shared.string(key: "\(Disk.name)_disk", defaultValue: self.selectedDisk) self.capacityReader?.callbackHandler = { [unowned self] value in if let value = value { @@ -175,6 +211,11 @@ public class Disk: Module { self.activityCallback(value) } } + self.processReader?.callbackHandler = { [unowned self] value in + if let list = value { + self.popupView.processCallback(list) + } + } self.settingsView.selectedDiskHandler = { [unowned self] value in self.selectedDisk = value @@ -186,6 +227,12 @@ public class Disk: Module { self.settingsView.setInterval = { [unowned self] value in self.capacityReader?.setInterval(value) } + self.settingsView.callbackWhenUpdateNumberOfProcesses = { + self.popupView.numberOfProcessesUpdated() + DispatchQueue.global(qos: .background).async { + self.processReader?.read() + } + } if let reader = self.capacityReader { self.addReader(reader) @@ -193,6 +240,9 @@ public class Disk: Module { if let reader = self.activityReader { self.addReader(reader) } + if let reader = self.processReader { + self.addReader(reader) + } } public override func widgetDidSet(_ type: widget_t) { diff --git a/Modules/Disk/popup.swift b/Modules/Disk/popup.swift index 5a45cdb1..82c6dcbe 100644 --- a/Modules/Disk/popup.swift +++ b/Modules/Disk/popup.swift @@ -13,8 +13,6 @@ import Cocoa import Kit internal class Popup: PopupWrapper { - private let emptyView: EmptyView = EmptyView(height: 30, isHidden: false, msg: localizedString("No disks are available")) - private var readColorState: Color = .secondBlue private var readColor: NSColor { var value = NSColor.systemRed @@ -32,46 +30,67 @@ internal class Popup: PopupWrapper { return value } + private var disks: NSStackView = { + let view = NSStackView() + view.spacing = Constants.Popup.margins + view.orientation = .vertical + return view + }() + private var processes: IOProcessView = IOProcessView( + countKey: "\(Disk.name)_processes", + inputColorKey: "\(Disk.name)_readColor", + outputColorKey: "\(Disk.name)_writeColor" + ) + public init() { - super.init(frame: NSRect(x: 0, y: 0, width: Constants.Popup.width, height: 30)) + super.init(frame: NSRect(x: 0, y: 0, width: Constants.Popup.width, height: 0)) self.readColorState = Color.fromString(Store.shared.string(key: "\(Disk.name)_readColor", defaultValue: self.readColorState.key)) self.writeColorState = Color.fromString(Store.shared.string(key: "\(Disk.name)_writeColor", defaultValue: self.writeColorState.key)) self.orientation = .vertical - self.spacing = Constants.Popup.margins + self.distribution = .fill + self.spacing = 0 + + self.addArrangedSubview(self.disks) + self.addArrangedSubview(separatorView(localizedString("Top processes"), width: self.frame.width)) + self.addArrangedSubview(self.processes) + + self.recalculateHeight() } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + private func recalculateHeight() { + let h = self.subviews.map({ $0.bounds.height }).reduce(0, +) + if self.frame.size.height != h { + self.setFrameSize(NSSize(width: self.frame.width, height: h)) + self.sizeCallback?(self.frame.size) + } + } + internal func capacityCallback(_ value: Disks) { defer { - if value.isEmpty && self.emptyView.superview == nil { - self.addArrangedSubview(self.emptyView) - } else if !value.isEmpty && self.emptyView.superview != nil { - self.emptyView.removeFromSuperview() - } - - let h = self.arrangedSubviews.map({ $0.bounds.height + self.spacing }).reduce(0, +) - self.spacing - if h > 0 && self.frame.size.height != h { - self.setFrameSize(NSSize(width: self.frame.width, height: h)) - self.sizeCallback?(self.frame.size) + let h = self.disks.subviews.map({ $0.bounds.height + self.disks.spacing }).reduce(0, +) - self.disks.spacing + if h > 0 && self.disks.frame.size.height != h { + self.disks.setFrameSize(NSSize(width: self.frame.width, height: h)) + self.recalculateHeight() } } - self.subviews.filter{ $0 is DiskView }.map{ $0 as! DiskView }.forEach { (v: DiskView) in + self.disks.subviews.filter{ $0 is DiskView }.map{ $0 as! DiskView }.forEach { (v: DiskView) in if !value.map({$0.BSDName}).contains(v.BSDName) { v.removeFromSuperview() } } value.forEach { (drive: drive) in - if let view = self.subviews.filter({ $0 is DiskView }).map({ $0 as! DiskView }).first(where: { $0.BSDName == drive.BSDName }) { + if let view = self.disks.subviews.filter({ $0 is DiskView }).map({ $0 as! DiskView }).first(where: { $0.BSDName == drive.BSDName }) { view.updateFree(free: drive.free) } else { - self.addArrangedSubview(DiskView( + self.disks.addArrangedSubview(DiskView( width: self.frame.width, BSDName: drive.BSDName, name: drive.mediaName, @@ -84,7 +103,7 @@ internal class Popup: PopupWrapper { } internal func activityCallback(_ value: Disks) { - let views = self.subviews.filter{ $0 is DiskView }.map{ $0 as! DiskView } + let views = self.disks.subviews.filter{ $0 is DiskView }.map{ $0 as! DiskView } value.reversed().forEach { (drive: drive) in if let view = views.first(where: { $0.name == drive.mediaName }) { view.updateReadWrite(read: drive.activity.read, write: drive.activity.write) @@ -92,6 +111,15 @@ internal class Popup: PopupWrapper { } } + internal func processCallback(_ list: [Disk_process]) { + self.processes.update(list) + } + + internal func numberOfProcessesUpdated() { + self.processes.reinit() + self.recalculateHeight() + } + // MARK: - Settings public override func settings() -> NSView? { @@ -122,10 +150,11 @@ internal class Popup: PopupWrapper { self.writeColorState = newValue Store.shared.set(key: "\(Disk.name)_writeColor", value: key) if let color = newValue.additional as? NSColor { - for view in self.subviews.filter({ $0 is DiskView }).map({ $0 as! DiskView }) { + for view in self.disks.subviews.filter({ $0 is DiskView }).map({ $0 as! DiskView }) { view.setChartColor(write: color) } } + self.processes.updateColors() } @objc private func toggleReadColor(_ sender: NSMenuItem) { guard let key = sender.representedObject as? String, @@ -135,10 +164,11 @@ internal class Popup: PopupWrapper { self.readColorState = newValue Store.shared.set(key: "\(Disk.name)_readColor", value: key) if let color = newValue.additional as? NSColor { - for view in self.subviews.filter({ $0 is DiskView }).map({ $0 as! DiskView }) { + for view in self.disks.subviews.filter({ $0 is DiskView }).map({ $0 as! DiskView }) { view.setChartColor(read: color) } } + self.processes.updateColors() } } @@ -246,7 +276,7 @@ internal class NameView: NSStackView { let readView: NSView = NSView(frame: NSRect(x: 0, y: 0, width: 32, height: activity.frame.height)) let readField: NSTextField = TextView(frame: NSRect(x: 0, y: 0, width: nameField.frame.width, height: readView.frame.height)) readField.stringValue = "R" - let readState: NSView = NSView(frame: NSRect(x: 13, y: (readView.frame.height-10)/2, width: 9, height: 9)) + let readState: NSView = NSView(frame: NSRect(x: 13, y: (readView.frame.height-9)/2, width: 10, height: 10)) readState.wantsLayer = true readState.layer?.backgroundColor = NSColor.lightGray.withAlphaComponent(0.75).cgColor readState.layer?.cornerRadius = 2 @@ -254,7 +284,7 @@ internal class NameView: NSStackView { let writeView: NSView = NSView(frame: NSRect(x: 0, y: 0, width: 32, height: activity.frame.height)) let writeField: NSTextField = TextView(frame: NSRect(x: 0, y: 0, width: nameField.frame.width, height: readView.frame.height)) writeField.stringValue = "W" - let writeState: NSView = NSView(frame: NSRect(x: 17, y: (writeView.frame.height-10)/2, width: 9, height: 9)) + let writeState: NSView = NSView(frame: NSRect(x: 17, y: (writeView.frame.height-10)/2, width: 10, height: 10)) writeState.wantsLayer = true writeState.layer?.backgroundColor = NSColor.lightGray.withAlphaComponent(0.75).cgColor writeState.layer?.cornerRadius = 2 @@ -551,3 +581,184 @@ internal class LegendView: NSView { } } } + +public protocol IOProcess_p { + var pid: Int32 { get } + var name: String { get } + var icon: NSImage { get } + + var input: String { get } + var output: String { get } +} + +public class IOProcessView: NSStackView { + private let countKey: String + private let inputColorKey: String + private let outputColorKey: String + + private var initialized: Bool = false + + private var count: Int { + Store.shared.int(key: countKey, defaultValue: 5) + } + private var readColor: NSColor { + Color.fromString(Store.shared.string(key: inputColorKey, defaultValue: Color.secondBlue.key)).additional as! NSColor + } + private var writeColor: NSColor { + Color.fromString(Store.shared.string(key: outputColorKey, defaultValue: Color.secondRed.key)).additional as! NSColor + } + + private var inputBoxView: NSView? + private var outputBoxView: NSView? + + public var height: CGFloat { + CGFloat((self.count+1) * 22) + } + + init(countKey: String, inputColorKey: String, outputColorKey: String) { + self.countKey = countKey + self.inputColorKey = inputColorKey + self.outputColorKey = outputColorKey + + super.init(frame: NSRect.zero) + + self.orientation = .vertical + self.spacing = 1 + + self.reinit() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + public func reinit() { + self.subviews.forEach({ $0.removeFromSuperview() }) + + self.addArrangedSubview(self.legendRow()) + for _ in 0.. NSView { + let view: NSStackView = NSStackView() + view.spacing = 50 + view.orientation = .horizontal + view.heightAnchor.constraint(equalToConstant: 21).isActive = true + + let inputView: NSView = NSView() + inputView.widthAnchor.constraint(equalToConstant: 10).isActive = true + inputView.heightAnchor.constraint(equalToConstant: 10).isActive = true + inputView.wantsLayer = true + inputView.layer?.backgroundColor = self.readColor.cgColor + inputView.layer?.cornerRadius = 2 + self.inputBoxView = inputView + + let outputView: NSView = NSView() + outputView.widthAnchor.constraint(equalToConstant: 10).isActive = true + outputView.heightAnchor.constraint(equalToConstant: 10).isActive = true + outputView.wantsLayer = true + outputView.layer?.backgroundColor = self.writeColor.cgColor + outputView.layer?.cornerRadius = 2 + self.outputBoxView = outputView + + view.addArrangedSubview(NSView()) + view.addArrangedSubview(inputView) + view.addArrangedSubview(outputView) + + return view + } + + public func update(_ list: [IOProcess_p]) { + DispatchQueue.main.async(execute: { + if !(self.window?.isVisible ?? false) && self.initialized { + return + } + + for (i, p) in self.subviews.compactMap({ $0 as? TopProcess }).enumerated() { + if list.count != self.count && self.initialized { + p.clear() + } + if list.indices.contains(i) { + p.set(list[i]) + } + } + + self.initialized = true + }) + } + + public func updateColors() { + self.inputBoxView?.layer?.backgroundColor = self.readColor.cgColor + self.outputBoxView?.layer?.backgroundColor = self.writeColor.cgColor + } +} + +public class TopProcess: NSStackView { + private var imageView: NSImageView = NSImageView() + private var labelView: NSTextField = LabelField() + private var inputView: NSTextField = ValueField() + private var outputView: NSTextField = ValueField() + + init() { + super.init(frame: NSRect.zero) + + self.orientation = .horizontal + self.spacing = 0 + self.alignment = .centerY + self.layer?.cornerRadius = 3 + + self.labelView.cell?.truncatesLastVisibleLine = true + self.inputView.font = NSFont.systemFont(ofSize: 10, weight: .regular) + self.outputView.font = NSFont.systemFont(ofSize: 10, weight: .regular) + + self.addArrangedSubview(self.imageView) + self.addArrangedSubview(self.labelView) + self.addArrangedSubview(NSView()) + self.addArrangedSubview(self.inputView) + self.addArrangedSubview(self.outputView) + + self.addTrackingArea(NSTrackingArea( + rect: NSRect(x: 0, y: 0, width: 264, height: 21), + options: [NSTrackingArea.Options.activeAlways, NSTrackingArea.Options.mouseEnteredAndExited, NSTrackingArea.Options.activeInActiveApp], + owner: self, + userInfo: nil + )) + + NSLayoutConstraint.activate([ + self.imageView.widthAnchor.constraint(equalToConstant: 12), + self.labelView.heightAnchor.constraint(equalToConstant: 16), + self.inputView.widthAnchor.constraint(equalToConstant: 60), + self.outputView.widthAnchor.constraint(equalToConstant: 60), + self.heightAnchor.constraint(equalToConstant: 21) + ]) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + public override func mouseEntered(with: NSEvent) { + self.layer?.backgroundColor = .init(gray: 0.01, alpha: 0.05) + } + public override func mouseExited(with: NSEvent) { + self.layer?.backgroundColor = .none + } + + public func set(_ process: IOProcess_p) { + self.imageView.image = process.icon + self.labelView.stringValue = process.name + self.inputView.stringValue = process.input + self.outputView.stringValue = process.output + self.toolTip = "pid: \(process.pid)" + } + + public func clear() { + self.inputView.stringValue = "-" + self.outputView.stringValue = "-" + self.toolTip = "" + } +} diff --git a/Modules/Disk/portal.swift b/Modules/Disk/portal.swift index 0dabbc26..f6bc3dee 100644 --- a/Modules/Disk/portal.swift +++ b/Modules/Disk/portal.swift @@ -13,15 +13,13 @@ import Cocoa import Kit internal class Portal: NSStackView, Portal_p { - internal var name: String + internal var name: String { Disk.name } private var circle: PieChartView? = nil private var initialized: Bool = false - init(_ title: String) { - self.name = title - + init() { super.init(frame: NSRect.zero) self.wantsLayer = true diff --git a/Modules/Disk/readers.swift b/Modules/Disk/readers.swift index 7c540593..d0d77dd4 100644 --- a/Modules/Disk/readers.swift +++ b/Modules/Disk/readers.swift @@ -285,3 +285,104 @@ public func getDeviceIOParent(_ obj: io_registry_entry_t, level: Int) -> io_regi return parent } + +struct io { + var read: Int + var write: Int +} + +public class ProcessReader: Reader<[Disk_process]> { + private let queue = DispatchQueue(label: "eu.exelban.Disk.processReader") + + private var _list: [Int32: io] = [:] + private var list: [Int32: io] { + get { + self.queue.sync { self._list } + } + set { + self.queue.sync { self._list = newValue } + } + } + + private var numberOfProcesses: Int { + Store.shared.int(key: "\(Disk.name)_processes", defaultValue: 5) + } + + public override func read() { + guard self.numberOfProcesses != 0 else { return } + + guard let output = runProcess(path: "/bin/ps", args: ["-Aceo pid,args", "-r"]) else { return } + + var processes: [Disk_process] = [] + output.enumerateLines { (line, _) -> Void in + var str = line.trimmingCharacters(in: .whitespaces) + let pidString = str.findAndCrop(pattern: "^\\d+") + if let range = str.range(of: pidString) { + str = str.replacingCharacters(in: range, with: "") + } + let name = str.findAndCrop(pattern: "^[^ ]+") + guard let pid = Int32(pidString) else { return } + + var usage = rusage_info_current() + let result = withUnsafeMutablePointer(to: &usage) { + $0.withMemoryRebound(to: (rusage_info_t?.self), capacity: 1) { + proc_pid_rusage(pid, RUSAGE_INFO_CURRENT, $0) + } + } + guard result != -1 else { return } + + let bytesRead = Int(usage.ri_diskio_bytesread) + let bytesWritten = Int(usage.ri_diskio_byteswritten) + + if self.list[pid] == nil { + self.list[pid] = io(read: bytesRead, write: bytesWritten) + } + + if let v = self.list[pid] { + let read = bytesRead - v.read + let write = bytesWritten - v.write + if read != 0 || write != 0 { + processes.append(Disk_process(pid: pid, name: name, read: read, write: write)) + } + } + + self.list[pid]?.read = bytesRead + self.list[pid]?.write = bytesWritten + } + + processes.sort { + let firstMax = max($0.read, $0.write) + let secondMax = max($1.read, $1.write) + let firstMin = min($0.read, $0.write) + let secondMin = min($1.read, $1.write) + + if firstMax == secondMax && firstMin != secondMin { // max values are the same, min not. Sort by min values + return firstMin < secondMin + } + return firstMax < secondMax // max values are not the same, sort by max value + } + + self.callback(processes.suffix(self.numberOfProcesses).reversed()) + } +} + +private func runProcess(path: String, args: [String] = []) -> String? { + let task = Process() + task.launchPath = path + task.arguments = args + + let outputPipe = Pipe() + defer { + outputPipe.fileHandleForReading.closeFile() + } + task.standardOutput = outputPipe + + do { + try task.run() + } catch { + return nil + } + + let outputData = outputPipe.fileHandleForReading.readDataToEndOfFile() + return String(decoding: outputData, as: UTF8.self) +} diff --git a/Modules/Disk/settings.swift b/Modules/Disk/settings.swift index f472df06..b56f9bb4 100644 --- a/Modules/Disk/settings.swift +++ b/Modules/Disk/settings.swift @@ -16,24 +16,25 @@ internal class Settings: NSStackView, Settings_v { private var removableState: Bool = false private var updateIntervalValue: Int = 10 private var notificationLevel: String = "Disabled" + private var numberOfProcesses: Int = 5 public var selectedDiskHandler: (String) -> Void = {_ in } public var callback: (() -> Void) = {} public var setInterval: ((_ value: Int) -> Void) = {_ in } + public var callbackWhenUpdateNumberOfProcesses: (() -> Void) = {} - private let title: String private var selectedDisk: String private var button: NSPopUpButton? private var intervalSelectView: NSView? = nil private var list: [String] = [] - public init(_ title: String) { - self.title = title - self.selectedDisk = Store.shared.string(key: "\(self.title)_disk", defaultValue: "") - self.removableState = Store.shared.bool(key: "\(self.title)_removable", defaultValue: self.removableState) - self.updateIntervalValue = Store.shared.int(key: "\(self.title)_updateInterval", defaultValue: self.updateIntervalValue) - self.notificationLevel = Store.shared.string(key: "\(self.title)_notificationLevel", defaultValue: self.notificationLevel) + public init() { + self.selectedDisk = Store.shared.string(key: "\(Disk.name)_disk", defaultValue: "") + self.removableState = Store.shared.bool(key: "\(Disk.name)_removable", defaultValue: self.removableState) + self.updateIntervalValue = Store.shared.int(key: "\(Disk.name)_updateInterval", defaultValue: self.updateIntervalValue) + self.notificationLevel = Store.shared.string(key: "\(Disk.name)_notificationLevel", defaultValue: self.notificationLevel) + self.numberOfProcesses = Store.shared.int(key: "\(Disk.name)_processes", defaultValue: self.numberOfProcesses) super.init(frame: NSRect(x: 0, y: 0, width: 0, height: 0)) @@ -56,6 +57,13 @@ internal class Settings: NSStackView, Settings_v { public func load(widgets: [widget_t]) { self.subviews.forEach{ $0.removeFromSuperview() } + self.addArrangedSubview(selectSettingsRowV1( + title: localizedString("Number of top processes"), + action: #selector(changeNumberOfProcesses), + items: NumbersOfProcesses.map{ "\($0)" }, + selected: "\(self.numberOfProcesses)" + )) + self.intervalSelectView = selectSettingsRowV1( title: localizedString("Update interval"), action: #selector(changeUpdateInterval), @@ -122,10 +130,18 @@ internal class Settings: NSStackView, Settings_v { }) } + @objc private func changeNumberOfProcesses(_ sender: NSMenuItem) { + if let value = Int(sender.title) { + self.numberOfProcesses = value + Store.shared.set(key: "\(Disk.name)_processes", value: value) + self.callbackWhenUpdateNumberOfProcesses() + } + } + @objc private func handleSelection(_ sender: NSPopUpButton) { guard let item = sender.selectedItem else { return } self.selectedDisk = item.title - Store.shared.set(key: "\(self.title)_disk", value: item.title) + Store.shared.set(key: "\(Disk.name)_disk", value: item.title) self.selectedDiskHandler(item.title) } @@ -138,7 +154,7 @@ internal class Settings: NSStackView, Settings_v { } self.removableState = state! == .on ? true : false - Store.shared.set(key: "\(self.title)_removable", value: self.removableState) + Store.shared.set(key: "\(Disk.name)_removable", value: self.removableState) self.callback() } @@ -152,15 +168,15 @@ internal class Settings: NSStackView, Settings_v { guard let key = sender.representedObject as? String else { return } if key == "Disabled" { - Store.shared.set(key: "\(self.title)_notificationLevel", value: key) + Store.shared.set(key: "\(Disk.name)_notificationLevel", value: key) } else if let value = Double(key.replacingOccurrences(of: "%", with: "")) { - Store.shared.set(key: "\(self.title)_notificationLevel", value: "\(value/100)") + Store.shared.set(key: "\(Disk.name)_notificationLevel", value: "\(value/100)") } } public func setUpdateInterval(value: Int) { self.updateIntervalValue = value - Store.shared.set(key: "\(self.title)_updateInterval", value: value) + Store.shared.set(key: "\(Disk.name)_updateInterval", value: value) self.setInterval(value) } }