Files
macos-stats/SMC/Helper/main.swift
Alex Goodkind 20030a2a1c feat: added better fan control for M3/M4 Apple Silicon (#2924)
* Fix Ftst key handling for Apple Silicon fan control

* Update CFBundleVersion to 747 in Info.plist

Signed-off-by: Alex Goodkind <alex@goodkind.io>

* Update TeamId and SMC.Helper certificate identifier in Info.plist

Signed-off-by: Alex Goodkind <alex@goodkind.io>

* Add debug logging to SMC fan control functions

* Use writeWithRetry for Apple Silicon fan control writes and bump helper version to 1.0.3

* SMC fan control: serialize ops, Ftst timing, verification, logging

- Helper: serial queue for setFanMode/setFanSpeed/resetFanControl
- smc.swift: 3s wait after Ftst=1, longer mode retry (100ms), SMC result logging
- helpers: per-fan verification with cancel-on-supersede, clearer logs
- smc.swift: neutral write logs (no 'succeeded'), FAILED on error

* - Updated error handling in SMCHelper to suppress expected XPC errors (codes 4097 and 4099) during helper updates/restarts.
- Removed unnecessary debug print statement in ModeButtons for improved log clarity.

* Update version numbers in Info.plist files to 752 and change TeamId for SMC.Helper

* Add FanMode.auto3 and isAutomatic, re-add F%dMd write in automatic path, use isAutomatic in countManualFans; bump SMC Helper to 1.0.24

* Apple Silicon fan control: direct-first writes, strip diagnostic bloat

Try direct F%dMd=1 write before Ftst unlock (instant on M1, fallback
on M3/M4). Remove verification system, diagnostic prints, dead code.

* Apple Silicon fan control: direct-first writes

Try direct F%dMd=1 write before Ftst unlock (instant on M1,
fallback on M3/M4).

Bump helper to 1.0.2/3, Stats/Widgets to 751.

---------

Signed-off-by: Alex Goodkind <alex@goodkind.io>
2026-02-22 15:17:23 +01:00

298 lines
11 KiB
Swift

//
// main.swift
// Helper
//
// Created by Serhiy Mytrovtsiy on 17/11/2022
// Using Swift 5.0
// Running on macOS 13.0
//
// Copyright © 2022 Serhiy Mytrovtsiy. All rights reserved.
//
import Foundation
let helper = Helper()
helper.run()
class Helper: NSObject, NSXPCListenerDelegate, HelperProtocol {
private let listener: NSXPCListener
private let smcQueue = DispatchQueue(label: "eu.exelban.Stats.SMC.Helper.smcQueue")
private var connections = [NSXPCConnection]()
private var shouldQuit = false
private var shouldQuitCheckInterval = 1.0
private var smc: String? = nil
override init() {
self.listener = NSXPCListener(machServiceName: "eu.exelban.Stats.SMC.Helper")
super.init()
self.listener.delegate = self
}
public func run() {
let args = CommandLine.arguments.dropFirst()
if !args.isEmpty && args.first == "uninstall" {
NSLog("detected uninstall command")
if let val = args.last, let pid: pid_t = Int32(val) {
while kill(pid, 0) == 0 {
usleep(50000)
}
}
self.uninstallHelper()
exit(0)
}
self.listener.resume()
while !self.shouldQuit {
RunLoop.current.run(until: Date(timeIntervalSinceNow: self.shouldQuitCheckInterval))
}
}
func listener(_ listener: NSXPCListener, shouldAcceptNewConnection newConnection: NSXPCConnection) -> Bool {
do {
let isValid = try CodesignCheck.codeSigningMatches(pid: newConnection.processIdentifier)
if !isValid {
NSLog("invalid connection, dropping")
return false
}
} catch {
NSLog("error checking code signing: \(error)")
return false
}
newConnection.exportedInterface = NSXPCInterface(with: HelperProtocol.self)
newConnection.exportedObject = self
newConnection.invalidationHandler = {
if let connectionIndex = self.connections.firstIndex(of: newConnection) {
self.connections.remove(at: connectionIndex)
}
if self.connections.isEmpty {
self.shouldQuit = true
}
}
self.connections.append(newConnection)
newConnection.resume()
return true
}
private func uninstallHelper() {
let process = Process()
process.launchPath = "/bin/launchctl"
process.qualityOfService = QualityOfService.userInitiated
process.arguments = ["unload", "/Library/LaunchDaemons/eu.exelban.Stats.SMC.Helper.plist"]
process.launch()
process.waitUntilExit()
if process.terminationStatus != .zero {
NSLog("termination code: \(process.terminationStatus)")
}
NSLog("unloaded from launchctl")
do {
try FileManager.default.removeItem(at: URL(fileURLWithPath: "/Library/LaunchDaemons/eu.exelban.Stats.SMC.Helper.plist"))
} catch let err {
NSLog("plist deletion: \(err)")
}
NSLog("property list deleted")
do {
try FileManager.default.removeItem(at: URL(fileURLWithPath: "/Library/PrivilegedHelperTools/eu.exelban.Stats.SMC.Helper"))
} catch let err {
NSLog("helper deletion: \(err)")
}
NSLog("smc helper deleted")
}
}
extension Helper {
func version(completion: (String) -> Void) {
completion(Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0")
}
func setSMCPath(_ path: String) {
self.smc = path
}
func setFanMode(id: Int, mode: Int, completion: (String?) -> Void) {
smcQueue.sync {
guard let smc = self.smc else {
completion("missing smc tool")
return
}
let result = syncShell("\(smc) fan \(id) -m \(mode)")
if let error = result.error, !error.isEmpty {
NSLog("error set fan mode: \(error)")
completion(nil)
return
}
completion(result.output)
}
}
func setFanSpeed(id: Int, value: Int, completion: (String?) -> Void) {
smcQueue.sync {
guard let smc = self.smc else {
completion("missing smc tool")
return
}
let result = syncShell("\(smc) fan \(id) -v \(value)")
if let error = result.error, !error.isEmpty {
NSLog("error set fan speed: \(error)")
completion(nil)
return
}
completion(result.output)
}
}
func resetFanControl(completion: (String?) -> Void) {
smcQueue.sync {
guard let smc = self.smc else {
completion("missing smc tool")
return
}
let result = syncShell("\(smc) reset")
if let error = result.error, !error.isEmpty {
NSLog("error reset fan control: \(error)")
completion(nil)
return
}
completion(result.output)
}
}
func powermetrics(_ samplers: [String], completion: @escaping (String?) -> Void) {
let result = syncShell("powermetrics -n 1 -s \(samplers.joined(separator: ",")) --sample-rate 1000")
if let error = result.error, !error.isEmpty {
NSLog("error call powermetrics: \(error)")
completion(nil)
return
}
completion(result.output)
}
public func syncShell(_ args: String) -> (output: String?, error: String?) {
let task = Process()
task.launchPath = "/bin/sh"
task.arguments = ["-c", args]
let outputPipe = Pipe()
let errorPipe = Pipe()
defer {
outputPipe.fileHandleForReading.closeFile()
errorPipe.fileHandleForReading.closeFile()
}
task.standardOutput = outputPipe
task.standardError = errorPipe
do {
try task.run()
} catch let err {
return (nil, "syncShell: \(err.localizedDescription)")
}
let outputData = outputPipe.fileHandleForReading.readDataToEndOfFile()
let errorData = errorPipe.fileHandleForReading.readDataToEndOfFile()
let output = String(data: outputData, encoding: .utf8)
let error = String(data: errorData, encoding: .utf8)
return (output, error)
}
func uninstall() {
let process = Process()
process.launchPath = "/Library/PrivilegedHelperTools/eu.exelban.Stats.SMC.Helper"
process.qualityOfService = QualityOfService.userInitiated
process.arguments = ["uninstall", String(getpid())]
process.launch()
exit(0)
}
}
// https://github.com/duanefields/VirtualKVM/blob/master/VirtualKVM/CodesignCheck.swift
let kSecCSDefaultFlags = 0
enum CodesignCheckError: Error {
case message(String)
}
struct CodesignCheck {
public static func codeSigningMatches(pid: pid_t) throws -> Bool {
return try self.codeSigningCertificatesForSelf() == self.codeSigningCertificates(forPID: pid)
}
private static func codeSigningCertificatesForSelf() throws -> [SecCertificate] {
guard let secStaticCode = try secStaticCodeSelf() else { return [] }
return try codeSigningCertificates(forStaticCode: secStaticCode)
}
private static func codeSigningCertificates(forPID pid: pid_t) throws -> [SecCertificate] {
guard let secStaticCode = try secStaticCode(forPID: pid) else { return [] }
return try codeSigningCertificates(forStaticCode: secStaticCode)
}
private static func executeSecFunction(_ secFunction: () -> (OSStatus) ) throws {
let osStatus = secFunction()
guard osStatus == errSecSuccess else {
throw CodesignCheckError.message(String(describing: SecCopyErrorMessageString(osStatus, nil)))
}
}
private static func secStaticCodeSelf() throws -> SecStaticCode? {
var secCodeSelf: SecCode?
try executeSecFunction { SecCodeCopySelf(SecCSFlags(rawValue: 0), &secCodeSelf) }
guard let secCode = secCodeSelf else {
throw CodesignCheckError.message("SecCode returned empty from SecCodeCopySelf")
}
return try secStaticCode(forSecCode: secCode)
}
private static func secStaticCode(forPID pid: pid_t) throws -> SecStaticCode? {
var secCodePID: SecCode?
try executeSecFunction { SecCodeCopyGuestWithAttributes(nil, [kSecGuestAttributePid: pid] as CFDictionary, [], &secCodePID) }
guard let secCode = secCodePID else {
throw CodesignCheckError.message("SecCode returned empty from SecCodeCopyGuestWithAttributes")
}
return try secStaticCode(forSecCode: secCode)
}
private static func secStaticCode(forSecCode secCode: SecCode) throws -> SecStaticCode? {
var secStaticCodeCopy: SecStaticCode?
try executeSecFunction { SecCodeCopyStaticCode(secCode, [], &secStaticCodeCopy) }
guard let secStaticCode = secStaticCodeCopy else {
throw CodesignCheckError.message("SecStaticCode returned empty from SecCodeCopyStaticCode")
}
return secStaticCode
}
private static func isValid(secStaticCode: SecStaticCode) throws {
try executeSecFunction { SecStaticCodeCheckValidity(secStaticCode, SecCSFlags(rawValue: kSecCSDoNotValidateResources | kSecCSCheckNestedCode), nil) }
}
private static func secCodeInfo(forStaticCode secStaticCode: SecStaticCode) throws -> [String: Any]? {
try isValid(secStaticCode: secStaticCode)
var secCodeInfoCFDict: CFDictionary?
try executeSecFunction { SecCodeCopySigningInformation(secStaticCode, SecCSFlags(rawValue: kSecCSSigningInformation), &secCodeInfoCFDict) }
guard let secCodeInfo = secCodeInfoCFDict as? [String: Any] else {
throw CodesignCheckError.message("CFDictionary returned empty from SecCodeCopySigningInformation")
}
return secCodeInfo
}
private static func codeSigningCertificates(forStaticCode secStaticCode: SecStaticCode) throws -> [SecCertificate] {
guard
let secCodeInfo = try secCodeInfo(forStaticCode: secStaticCode),
let secCertificates = secCodeInfo[kSecCodeInfoCertificates as String] as? [SecCertificate] else { return [] }
return secCertificates
}
}