From 1af5ec17631f5ff1236bb7672f08005df7b16ac7 Mon Sep 17 00:00:00 2001 From: Serhiy Mytrovtsiy Date: Tue, 23 Mar 2021 20:51:46 +0100 Subject: [PATCH] feat: add fan speed control panel to the popup (#152) --- Modules/Fans/main.swift | 3 +- Modules/Fans/popup.swift | 344 ++++++++++++++---- .../en.lproj/Localizable.strings | 3 + StatsKit/SMC.swift | 35 +- StatsKit/helpers.swift | 10 +- 5 files changed, 303 insertions(+), 92 deletions(-) diff --git a/Modules/Fans/main.swift b/Modules/Fans/main.swift index 277cc471..ca0a984a 100644 --- a/Modules/Fans/main.swift +++ b/Modules/Fans/main.swift @@ -39,12 +39,13 @@ public class Fans: Module { private var fansReader: FansReader private var settingsView: Settings - private let popupView: Popup = Popup() + private let popupView: Popup public init(_ smc: UnsafePointer) { self.smc = smc self.fansReader = FansReader(smc) self.settingsView = Settings("Fans", list: &self.fansReader.list) + self.popupView = Popup(smc) super.init( popup: self.popupView, diff --git a/Modules/Fans/popup.swift b/Modules/Fans/popup.swift index effadc0b..d3073541 100644 --- a/Modules/Fans/popup.swift +++ b/Modules/Fans/popup.swift @@ -13,55 +13,33 @@ import Cocoa import ModuleKit import StatsKit -internal class Popup: NSView, Popup_p { - private var list: [Int: FanView] = [:] - +internal class Popup: NSStackView, Popup_p { public var sizeCallback: ((NSSize) -> Void)? = nil - public init() { - super.init(frame: NSRect( x: 0, y: 0, width: Constants.Popup.width, height: 0)) + private var smc: UnsafePointer + private var list: [Int: FanView] = [:] + + public init(_ smc: UnsafePointer) { + self.smc = smc + + super.init(frame: NSRect(x: 0, y: 0, width: Constants.Popup.width, height: 0)) + + self.orientation = .vertical + self.spacing = Constants.Popup.margins } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - internal func setup(_ values: [Fan]?) { - guard values != nil else { - return + internal func setup(_ values: [Fan]) { + values.forEach { (f: Fan) in + let view = FanView(f, smc: self.smc, width: self.frame.width, callback: self.recalculateHeight) + self.list[f.id] = view + self.addArrangedSubview(view) } - self.subviews.forEach { (v: NSView) in - v.removeFromSuperview() - } - - let fanViewHeight: CGFloat = 40 - let view: NSView = NSView(frame: NSRect( - x: 0, - y: 0, - width: self.frame.width, - height: ((fanViewHeight+Constants.Popup.margins)*CGFloat(values!.count))-Constants.Popup.margins - )) - var i: CGFloat = 0 - - values!.reversed().forEach { (f: Fan) in - let fanView = FanView( - NSRect( - x: 0, - y: (fanViewHeight + Constants.Popup.margins) * i, - width: self.frame.width, - height: fanViewHeight - ), - fan: f - ) - self.list[f.id] = fanView - view.addSubview(fanView) - i += 1 - } - self.addSubview(view) - - self.setFrameSize(NSSize(width: self.frame.width, height: view.frame.height)) - self.sizeCallback?(self.frame.size) + self.recalculateHeight() } internal func usageCallback(_ values: [Fan]) { @@ -75,29 +53,66 @@ internal class Popup: NSView, Popup_p { } }) } + + private func recalculateHeight() { + let h = self.arrangedSubviews.map({ $0.bounds.height + self.spacing }).reduce(0, +) - self.spacing + if self.frame.size.height != h { + self.setFrameSize(NSSize(width: self.frame.width, height: h)) + self.sizeCallback?(self.frame.size) + } + } } -internal class FanView: NSView { - private let fan: Fan - private var mainView: NSView +internal class FanView: NSStackView { + public var sizeCallback: (() -> Void) + + private var smc: UnsafePointer + private var fan: Fan + private var ready: Bool = false private var valueField: NSTextField? = nil private var percentageField: NSTextField? = nil + private var sliderValueField: NSTextField? = nil - private var ready: Bool = false + private var slider: NSSlider? = nil + private var controlView: NSView? = nil + private var debouncer: DispatchWorkItem? = nil - public init(_ frame: NSRect, fan: Fan) { + public init(_ fan: Fan, smc: UnsafePointer, width: CGFloat, callback: @escaping (() -> Void)) { self.fan = fan - self.mainView = NSView(frame: NSRect(x: 5, y: 5, width: frame.width - 10, height: frame.height - 10)) - super.init(frame: frame) + self.smc = smc + self.sizeCallback = callback + let inset: CGFloat = 5 + super.init(frame: NSRect(x: 0, y: 0, width: width - (inset*2), height: 0)) + + self.controlView = self.control() + + self.orientation = .vertical + self.alignment = .centerX + self.distribution = .fillProportionally + self.spacing = 0 + self.edgeInsets = NSEdgeInsets( + top: inset, + left: inset, + bottom: inset, + right: inset + ) self.wantsLayer = true self.layer?.cornerRadius = 2 + self.layer?.backgroundColor = NSColor.red.cgColor - self.addFirstRow() - self.addSecondRow() + self.addArrangedSubview(self.nameAndSpeed()) + self.addArrangedSubview(self.keyAndPercentage()) + self.addArrangedSubview(self.mode()) - self.addSubview(self.mainView) + if let view = self.controlView, fan.mode == .forced { + self.addArrangedSubview(view) + } + + let h = self.arrangedSubviews.map({ $0.bounds.height }).reduce(0, +) + (inset*2) + self.setFrameSize(NSSize(width: self.frame.width, height: h)) + self.sizeCallback() } required init?(coder: NSCoder) { @@ -108,47 +123,58 @@ internal class FanView: NSView { self.layer?.backgroundColor = isDarkMode ? NSColor(hexString: "#111111", alpha: 0.25).cgColor : NSColor(hexString: "#f5f5f5", alpha: 1).cgColor } - private func addFirstRow() { - let row: NSView = NSView(frame: NSRect(x: 0, y: 14, width: self.mainView.frame.width, height: 16)) + private func nameAndSpeed() -> NSView { + let row: NSView = NSView(frame: NSRect(x: 0, y: 0, width: self.frame.width, height: 16)) + row.heightAnchor.constraint(equalToConstant: row.bounds.height).isActive = true - let value = self.fan.formattedValue let valueWidth: CGFloat = 80 - let nameField: NSTextField = TextView(frame: NSRect( x: 0, y: 0, - width: self.mainView.frame.width - valueWidth, + width: row.frame.width - valueWidth, height: row.frame.height )) nameField.stringValue = self.fan.name nameField.cell?.truncatesLastVisibleLine = true let valueField: NSTextField = TextView(frame: NSRect( - x: self.mainView.frame.width - valueWidth, + x: row.frame.width - valueWidth, y: 0, width: valueWidth, height: row.frame.height )) valueField.font = NSFont.systemFont(ofSize: 13, weight: .regular) - valueField.stringValue = value + valueField.stringValue = self.fan.formattedValue valueField.alignment = .right row.addSubview(nameField) row.addSubview(valueField) - - self.mainView.addSubview(row) self.valueField = valueField + + return row } - private func addSecondRow() { - let row: NSView = NSView(frame: NSRect(x: 0, y: 0, width: self.mainView.frame.width, height: 14)) + private func keyAndPercentage() -> NSView { + let row: NSView = NSView(frame: NSRect(x: 0, y: 0, width: self.frame.width, height: 14)) + row.heightAnchor.constraint(equalToConstant: row.bounds.height).isActive = true let value = self.fan.value let percentage = "\((100*Int(value)) / Int(self.fan.maxSpeed))%" let percentageWidth: CGFloat = 40 + let keyField: NSTextField = TextView(frame: NSRect( + x: 0, + y: 0, + width: row.frame.width - percentageWidth, + height: row.frame.height + )) + keyField.font = NSFont.systemFont(ofSize: 11, weight: .light) + keyField.textColor = .secondaryLabelColor + keyField.stringValue = "Fan #\(self.fan.id)" + keyField.alignment = .left + let percentageField: NSTextField = TextView(frame: NSRect( - x: self.mainView.frame.width - percentageWidth, + x: row.frame.width - percentageWidth, y: 0, width: percentageWidth, height: row.frame.height @@ -158,15 +184,150 @@ internal class FanView: NSView { percentageField.stringValue = percentage percentageField.alignment = .right + row.addSubview(keyField) row.addSubview(percentageField) - self.mainView.addSubview(row) self.percentageField = percentageField + + return row + } + + private func mode() -> NSView { + let view: NSView = NSView(frame: NSRect(x: 0, y: 0, width: self.frame.width, height: 30)) + view.heightAnchor.constraint(equalToConstant: view.bounds.height).isActive = true + + let buttons = ModeButtons(frame: NSRect( + x: 0, + y: 4, + width: view.frame.width, + height: view.frame.height - 8 + ), mode: self.fan.mode) + buttons.callback = { [weak self] (mode: FanMode) in + self?.fan.mode = mode + if let fan = self?.fan { + self?.smc.pointee.setFanMode(fan.id, mode: mode) + } + self?.toggleMode() + } + + let rootBtn: NSButton = NSButton(frame: NSRect(x: 0, y: 4, width: view.frame.width, height: view.frame.height - 8)) + rootBtn.title = "Control fan (root required)" + rootBtn.setButtonType(.momentaryLight) + rootBtn.isBordered = false + rootBtn.target = self + rootBtn.action = #selector(self.askForRoot) + rootBtn.wantsLayer = true + rootBtn.layer?.cornerRadius = 3 + rootBtn.layer?.borderWidth = 1 + rootBtn.layer?.borderColor = NSColor.lightGray.cgColor + + view.addSubview(isRoot() ? buttons : rootBtn) + + return view + } + + private func control() -> NSView { + let view: NSView = NSView(frame: NSRect(x: 0, y: 0, width: self.frame.width, height: 44)) + view.identifier = NSUserInterfaceItemIdentifier(rawValue: "control") + view.heightAnchor.constraint(equalToConstant: view.bounds.height).isActive = true + + let controls: NSStackView = NSStackView(frame: NSRect(x: 0, y: 14, width: view.frame.width, height: 30)) + controls.orientation = .horizontal + controls.spacing = 0 + + let slider: NSSlider = NSSlider(frame: NSRect(x: 0, y: 0, width: view.frame.width, height: 26)) + slider.minValue = self.fan.minSpeed + slider.doubleValue = self.fan.value + slider.maxValue = self.fan.maxSpeed + slider.isContinuous = true + slider.action = #selector(self.speedChange) + slider.target = self + + let levels: NSView = NSView(frame: NSRect(x: 0, y: 0, width: view.frame.width, height: 14)) + + let minField: NSTextField = TextView(frame: NSRect(x: 0, y: 0, width: 80, height: levels.frame.height)) + minField.font = NSFont.systemFont(ofSize: 11, weight: .light) + minField.textColor = .secondaryLabelColor + minField.stringValue = "\(LocalizedString("Min")): \(Int(self.fan.minSpeed))" + minField.alignment = .left + + let valueField: NSTextField = TextView(frame: NSRect(x: 80, y: 0, width: levels.frame.width - 160, height: levels.frame.height)) + valueField.font = NSFont.systemFont(ofSize: 11, weight: .light) + valueField.textColor = .secondaryLabelColor + valueField.alignment = .center + + let maxField: NSTextField = TextView(frame: NSRect(x: levels.frame.width - 80, y: 0, width: 80, height: levels.frame.height)) + maxField.font = NSFont.systemFont(ofSize: 11, weight: .light) + maxField.textColor = .secondaryLabelColor + maxField.stringValue = "\(LocalizedString("Max")): \(Int(self.fan.maxSpeed))" + maxField.alignment = .right + + controls.addArrangedSubview(slider) + + levels.addSubview(minField) + levels.addSubview(valueField) + levels.addSubview(maxField) + + view.addSubview(controls) + view.addSubview(levels) + + self.slider = slider + self.sliderValueField = valueField + return view + } + + @objc private func askForRoot(_ sender: NSButton) { + DispatchQueue.main.async { + ensureRoot() + } + } + + @objc private func speedChange(_ sender: NSSlider) { + guard let field = self.sliderValueField else { + return + } + + let value = sender.doubleValue + field.stringValue = "\(Int(value)) RPM" + field.textColor = .secondaryLabelColor + + self.debouncer?.cancel() + + let task = DispatchWorkItem { [weak self] in + DispatchQueue.global(qos: .userInteractive).async { [weak self] in + if let id = self?.fan.id { + self?.smc.pointee.setFanSpeed(id, speed: Int(value)) + } + DispatchQueue.main.async { + field.textColor = .systemBlue + } + } + } + + self.debouncer = task + DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 0.3, execute: task) + } + + private func toggleMode() { + guard let view = self.controlView else { + return + } + + if self.fan.mode == .automatic { + view.removeFromSuperview() + self.sliderValueField?.stringValue = "" + self.slider?.doubleValue = self.fan.minSpeed + } else if self.fan.mode == .forced { + self.addArrangedSubview(view) + } + + let h = self.arrangedSubviews.map({ $0.bounds.height }).reduce(0, +) + 10 + self.setFrameSize(NSSize(width: self.frame.width, height: h)) + self.sizeCallback() } public func update(_ value: Fan) { DispatchQueue.main.async(execute: { if (self.window?.isVisible ?? false) || !self.ready { - if let view = self.valueField { view.stringValue = value.formattedValue } @@ -180,3 +341,60 @@ internal class FanView: NSView { }) } } + +private class ModeButtons: NSStackView { + public var callback: (FanMode) -> Void = {_ in } + + private var autoBtn: NSButton = NSButton(title: "Automatic", target: nil, action: #selector(autoMode)) + private var manualBtn: NSButton = NSButton(title: "Manual", target: nil, action: #selector(manualMode)) + + public init(frame: NSRect, mode: FanMode) { + super.init(frame: frame) + + self.orientation = .horizontal + self.alignment = .centerY + self.distribution = .fillEqually + + self.wantsLayer = true + self.layer?.cornerRadius = 3 + self.layer?.borderWidth = 1 + self.layer?.borderColor = NSColor.lightGray.cgColor + + self.autoBtn.setButtonType(.toggle) + self.autoBtn.isBordered = false + self.autoBtn.target = self + self.autoBtn.state = mode == .automatic ? .on : .off + + self.manualBtn.setButtonType(.toggle) + self.manualBtn.isBordered = false + self.manualBtn.target = self + self.manualBtn.state = mode == .forced ? .on : .off + + self.addArrangedSubview(self.autoBtn) + self.addArrangedSubview(self.manualBtn) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + @objc func autoMode(_ sender: NSButton) { + if sender.state.rawValue == 0 { + self.autoBtn.state = .on + return + } + + self.manualBtn.state = .off + self.callback(.automatic) + } + + @objc func manualMode(_ sender: NSButton) { + if sender.state.rawValue == 0 { + self.manualBtn.state = .on + return + } + + self.autoBtn.state = .off + self.callback(.forced) + } +} diff --git a/Stats/Supporting Files/en.lproj/Localizable.strings b/Stats/Supporting Files/en.lproj/Localizable.strings index a99f5451..5a371a81 100644 --- a/Stats/Supporting Files/en.lproj/Localizable.strings +++ b/Stats/Supporting Files/en.lproj/Localizable.strings @@ -24,6 +24,7 @@ "Yes" = "Yes"; "No" = "No"; "Automatic" = "Automatic"; +"Manual" = "Manual"; "None" = "None"; "Dots" = "Dots"; "Arrows" = "Arrows"; @@ -31,6 +32,8 @@ "Short" = "Short"; "Long" = "Long"; "Statistics" = "Statistics"; +"Max" = "Max"; +"Min" = "Min"; // Alerts "New version available" = "New version available"; diff --git a/StatsKit/SMC.swift b/StatsKit/SMC.swift index c4a398f6..d00d9938 100644 --- a/StatsKit/SMC.swift +++ b/StatsKit/SMC.swift @@ -105,8 +105,6 @@ internal struct SMCVal_t { } public class SMCService { - public static let shared = SMCService() - private var conn: io_connect_t = 0 public init() { @@ -136,13 +134,6 @@ public class SMCService { } } - deinit { - let result = self.close() - if (result != kIOReturnSuccess) { - print("error close smc connection: " + (String(cString: mach_error_string(result), encoding: String.Encoding.ascii) ?? "unknown error")) - } - } - public func close() -> kern_return_t{ return IOServiceClose(conn) } @@ -355,28 +346,28 @@ public class SMCService { } public func setFanMode(_ id: Int, mode: FanMode) { - let currentMode = Int(self.getValue("FS! ") ?? 0) + let fansMode = Int(self.getValue("FS! ") ?? 0) var newMode: UInt8 = 0 - if (currentMode == 0 || currentMode == 1) && id == 0 && mode == .automatic { + if fansMode == 0 && id == 0 && mode == .forced { newMode = 1 - } else if (currentMode == 0 || currentMode == 2) && id == 1 && mode == .automatic { + } else if fansMode == 0 && id == 1 && mode == .forced { newMode = 2 - } else if currentMode == 1 && id == 0 && mode == .forced { + } else if fansMode == 1 && id == 0 && mode == .automatic { newMode = 0 - } else if currentMode == 2 && id == 1 && mode == .forced { + } else if fansMode == 1 && id == 1 && mode == .forced { + newMode = 3 + } else if fansMode == 2 && id == 1 && mode == .automatic { newMode = 0 - } else if currentMode == 1 && id == 1 && mode == .automatic { + } else if fansMode == 2 && id == 0 && mode == .forced { newMode = 3 - } else if currentMode == 2 && id == 0 && mode == .automatic { - newMode = 3 - } else if currentMode == 3 && id == 0 && mode == .forced { + } else if fansMode == 3 && id == 0 && mode == .automatic { + newMode = 2 + } else if fansMode == 3 && id == 1 && mode == .automatic { newMode = 1 - } else if currentMode == 3 && id == 1 && mode == .forced { - newMode = 2 } - if currentMode == newMode { + if fansMode == newMode { return } @@ -425,8 +416,6 @@ public class SMCService { return } - self.setFanMode(id, mode: .automatic) - var value = SMCVal_t("F\(id)Tg") value.dataSize = 2 value.bytes = [UInt8(speed >> 6), UInt8((speed << 2) ^ ((speed >> 6) << 8)), UInt8(0), UInt8(0), UInt8(0), UInt8(0), diff --git a/StatsKit/helpers.swift b/StatsKit/helpers.swift index 025ff565..e797de57 100644 --- a/StatsKit/helpers.swift +++ b/StatsKit/helpers.swift @@ -924,14 +924,14 @@ public func isRoot() -> Bool { return getuid() == 0 } -public func ensureRoot() -> Bool { +public func ensureRoot() { if isRoot() { - return true + return } let pwd = Bundle.main.bundleURL.absoluteString.replacingOccurrences(of: "file://", with: "") guard let script = NSAppleScript(source: "do shell script \"\(pwd)/Contents/MacOS/Stats > /dev/null 2>&1 &\" with administrator privileges") else { - return false + return } var err: NSDictionary? = nil @@ -939,9 +939,9 @@ public func ensureRoot() -> Bool { if err != nil { print("cannot run script as root: \(String(describing: err))") - return false + return } NSApp.terminate(nil) - return true + return }