feat: add fan speed control panel to the popup (#152)

This commit is contained in:
Serhiy Mytrovtsiy
2021-03-23 20:51:46 +01:00
parent 647799b232
commit 1af5ec1763
5 changed files with 303 additions and 92 deletions

View File

@@ -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<SMCService>) {
self.smc = smc
self.fansReader = FansReader(smc)
self.settingsView = Settings("Fans", list: &self.fansReader.list)
self.popupView = Popup(smc)
super.init(
popup: self.popupView,

View File

@@ -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<SMCService>
private var list: [Int: FanView] = [:]
public init(_ smc: UnsafePointer<SMCService>) {
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<SMCService>
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<SMCService>, 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)
}
}

View File

@@ -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";

View File

@@ -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),

View File

@@ -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
}