300 lines
11 KiB
Swift
300 lines
11 KiB
Swift
import Foundation
|
|
import MacToolsPluginKit
|
|
|
|
// MARK: - FanControlSMCWriter
|
|
|
|
/// Writes SMC fan control values through MacTools' bundled SMC helper.
|
|
/// The helper is copied to `/Library/PrivilegedHelperTools` with root ownership
|
|
/// and setuid permissions on first use, so users do not need to install SoloFan's
|
|
/// external helper manually.
|
|
@MainActor
|
|
final class FanControlSMCWriter: FanControlSMCWriting {
|
|
private enum Helper {
|
|
static let bundledName = "mactools-fan-smc-helper"
|
|
static let bundledSubdirectory = "SMCHelper"
|
|
static let installPath = "/Library/PrivilegedHelperTools/cc.ggbond.mactools.fan-control.smc-helper"
|
|
static let installDirectory = "/Library/PrivilegedHelperTools"
|
|
}
|
|
|
|
private let fileManager: FileManager
|
|
private let resourceBundle: Bundle
|
|
private let localization: PluginLocalization
|
|
|
|
private var resolvedHelperPath: String?
|
|
|
|
init(
|
|
resourceBundle: Bundle = .main,
|
|
fileManager: FileManager = .default,
|
|
localization: PluginLocalization? = nil
|
|
) {
|
|
self.resourceBundle = resourceBundle
|
|
self.fileManager = fileManager
|
|
self.localization = localization ?? PluginLocalization(bundle: resourceBundle)
|
|
}
|
|
|
|
// MARK: - Public API
|
|
|
|
typealias WriteError = FanWriteError
|
|
|
|
var isHelperAvailable: Bool {
|
|
bundledHelperURL != nil
|
|
}
|
|
|
|
var isInstalledHelperAvailable: Bool {
|
|
guard let bundledHelperURL else {
|
|
return false
|
|
}
|
|
|
|
return installedHelperPath(matching: bundledHelperURL) != nil
|
|
}
|
|
|
|
/// Apply `strategy` to all fans described by `snapshot`.
|
|
/// - Returns: `nil` on success, or an error description on failure.
|
|
@discardableResult
|
|
func apply(strategy: FanControlStrategy, snapshot: FanSnapshot) -> FanWriteError? {
|
|
let resolvedPath: String
|
|
switch helperPath() {
|
|
case .success(let path):
|
|
resolvedPath = path
|
|
case .failure(let error):
|
|
FanControlLog.writer.error("smc-helper not found")
|
|
return error
|
|
}
|
|
|
|
let fanCount = max(1, snapshot.fanCount)
|
|
|
|
switch strategy {
|
|
case .auto:
|
|
var allOK = true
|
|
for i in 0..<fanCount {
|
|
if !runHelper(path: resolvedPath, args: ["auto", "\(i)"]) { allOK = false }
|
|
}
|
|
if !allOK {
|
|
return .writeFailed(localization.string(
|
|
"writer.error.restoreAutoPartial",
|
|
defaultValue: "部分风扇恢复自动控制失败"
|
|
))
|
|
}
|
|
|
|
case .fullSpeed:
|
|
var allOK = true
|
|
for i in 0..<fanCount {
|
|
let maxRPM = i < snapshot.fanMaxSpeeds.count
|
|
? snapshot.fanMaxSpeeds[i]
|
|
: FanRPMLimits.fallbackMax
|
|
let safe = max(FanRPMLimits.absoluteMin, min(FanRPMLimits.absoluteMax, maxRPM))
|
|
if !runHelper(path: resolvedPath, args: ["set", "\(i)", "\(safe)"]) { allOK = false }
|
|
}
|
|
if !allOK {
|
|
return .writeFailed(localization.string(
|
|
"writer.error.fullSpeedPartial",
|
|
defaultValue: "部分风扇设置全速失败"
|
|
))
|
|
}
|
|
|
|
case .fixed(let rpm):
|
|
let safe = max(FanRPMLimits.absoluteMin, min(FanRPMLimits.absoluteMax, rpm))
|
|
var allOK = true
|
|
for i in 0..<fanCount {
|
|
let perFanMax = i < snapshot.fanMaxSpeeds.count
|
|
? snapshot.fanMaxSpeeds[i]
|
|
: FanRPMLimits.fallbackMax
|
|
let perFanMin = i < snapshot.fanMinSpeeds.count
|
|
? snapshot.fanMinSpeeds[i]
|
|
: FanRPMLimits.fallbackMin
|
|
let clamped = max(perFanMin, min(perFanMax, safe))
|
|
if !runHelper(path: resolvedPath, args: ["set", "\(i)", "\(clamped)"]) { allOK = false }
|
|
}
|
|
if !allOK {
|
|
return .writeFailed(localization.string(
|
|
"writer.error.fixedRPMPartial",
|
|
defaultValue: "部分风扇设置目标转速失败"
|
|
))
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// MARK: - Private
|
|
|
|
private func installedHelperPath(matching bundledHelperURL: URL) -> String? {
|
|
if let cached = resolvedHelperPath,
|
|
isExecutable(at: cached),
|
|
installedHelperMatchesBundled(installedPath: cached, bundledURL: bundledHelperURL) {
|
|
return cached
|
|
}
|
|
|
|
guard isExecutable(at: Helper.installPath),
|
|
installedHelperMatchesBundled(installedPath: Helper.installPath, bundledURL: bundledHelperURL)
|
|
else {
|
|
return nil
|
|
}
|
|
|
|
resolvedHelperPath = Helper.installPath
|
|
return Helper.installPath
|
|
}
|
|
|
|
private var bundledHelperURL: URL? {
|
|
resourceBundle.url(
|
|
forResource: Helper.bundledName,
|
|
withExtension: nil,
|
|
subdirectory: Helper.bundledSubdirectory
|
|
)
|
|
}
|
|
|
|
private func helperPath() -> Result<String, FanWriteError> {
|
|
guard let bundledHelperURL else {
|
|
return .failure(.helperNotFound)
|
|
}
|
|
|
|
guard verifyBundleContainingBundledHelper(bundledHelperURL) else {
|
|
return .failure(.helperVerificationFailed)
|
|
}
|
|
|
|
if let installedHelperPath = installedHelperPath(matching: bundledHelperURL) {
|
|
return .success(installedHelperPath)
|
|
}
|
|
|
|
if let error = installBundledHelper(from: bundledHelperURL) {
|
|
return .failure(error)
|
|
}
|
|
|
|
guard let installedHelperPath = installedHelperPath(matching: bundledHelperURL) else {
|
|
return .failure(.helperInstallFailed(localization.string(
|
|
"writer.error.helperMissingAfterInstall",
|
|
defaultValue: "安装后仍无法找到组件"
|
|
)))
|
|
}
|
|
|
|
return .success(installedHelperPath)
|
|
}
|
|
|
|
private func isExecutable(at path: String) -> Bool {
|
|
fileManager.isExecutableFile(atPath: path)
|
|
}
|
|
|
|
private func installedHelperMatchesBundled(installedPath: String, bundledURL: URL) -> Bool {
|
|
guard let installedAttributes = try? fileManager.attributesOfItem(atPath: installedPath),
|
|
let installedSize = installedAttributes[.size] as? NSNumber,
|
|
let installedModifiedAt = installedAttributes[.modificationDate] as? Date,
|
|
let bundledAttributes = try? fileManager.attributesOfItem(atPath: bundledURL.path),
|
|
let bundledSize = bundledAttributes[.size] as? NSNumber,
|
|
let bundledModifiedAt = bundledAttributes[.modificationDate] as? Date,
|
|
let installedData = try? Data(contentsOf: URL(fileURLWithPath: installedPath)),
|
|
let bundledData = try? Data(contentsOf: bundledURL)
|
|
else {
|
|
return false
|
|
}
|
|
|
|
guard installedSize == bundledSize else {
|
|
return false
|
|
}
|
|
|
|
return installedModifiedAt.timeIntervalSince(bundledModifiedAt) >= -1
|
|
&& installedData == bundledData
|
|
}
|
|
|
|
private func verifyBundleContainingBundledHelper(_ helperURL: URL) -> Bool {
|
|
var currentURL = helperURL.deletingLastPathComponent()
|
|
|
|
while currentURL.path != "/" {
|
|
if currentURL.pathExtension == "bundle" {
|
|
return verifyCodeSignature(at: currentURL)
|
|
}
|
|
currentURL.deleteLastPathComponent()
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
private func verifyCodeSignature(at url: URL) -> Bool {
|
|
let task = Process()
|
|
task.executableURL = URL(fileURLWithPath: "/usr/bin/codesign")
|
|
task.arguments = ["--verify", "--strict", "--deep", url.path]
|
|
task.standardOutput = Pipe()
|
|
task.standardError = Pipe()
|
|
|
|
do {
|
|
try task.run()
|
|
task.waitUntilExit()
|
|
return task.terminationStatus == 0
|
|
} catch {
|
|
FanControlLog.writer.error("codesign verification failed: \(error.localizedDescription, privacy: .public)")
|
|
return false
|
|
}
|
|
}
|
|
|
|
private func installBundledHelper(from sourceURL: URL) -> FanWriteError? {
|
|
guard isExecutable(at: sourceURL.path) else {
|
|
return .helperNotFound
|
|
}
|
|
|
|
let command = [
|
|
"/bin/mkdir -p \(shellQuoted(Helper.installDirectory))",
|
|
"/usr/bin/install -o root -g wheel -m 4755 \(shellQuoted(sourceURL.path)) \(shellQuoted(Helper.installPath))",
|
|
"/usr/bin/touch -r \(shellQuoted(sourceURL.path)) \(shellQuoted(Helper.installPath))",
|
|
"/bin/chmod 4755 \(shellQuoted(Helper.installPath))"
|
|
].joined(separator: " && ")
|
|
|
|
let script = "do shell script \"\(appleScriptEscaped(command))\" with administrator privileges"
|
|
var appleScriptError: NSDictionary?
|
|
guard let scriptObject = NSAppleScript(source: script) else {
|
|
return .helperInstallFailed(localization.string(
|
|
"writer.error.authorizationScriptFailed",
|
|
defaultValue: "无法创建授权脚本"
|
|
))
|
|
}
|
|
|
|
_ = scriptObject.executeAndReturnError(&appleScriptError)
|
|
if let appleScriptError {
|
|
let message = appleScriptError["NSAppleScriptErrorMessage"] as? String
|
|
?? appleScriptError.description
|
|
FanControlLog.writer.error("SMC helper install failed: \(message, privacy: .public)")
|
|
return .helperInstallFailed(message)
|
|
}
|
|
|
|
resolvedHelperPath = nil
|
|
return nil
|
|
}
|
|
|
|
@discardableResult
|
|
private func runHelper(path: String, args: [String]) -> Bool {
|
|
let task = Process()
|
|
task.executableURL = URL(fileURLWithPath: path)
|
|
task.arguments = args
|
|
task.environment = ["LANG": "C"]
|
|
let errorPipe = Pipe()
|
|
task.standardError = errorPipe
|
|
|
|
do {
|
|
try task.run()
|
|
task.waitUntilExit()
|
|
if task.terminationStatus == 0 {
|
|
return true
|
|
}
|
|
|
|
let errorData = errorPipe.fileHandleForReading.readDataToEndOfFile()
|
|
let errorMessage = String(data: errorData, encoding: .utf8)?
|
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
FanControlLog.writer.error(
|
|
"smc-helper failed with status \(task.terminationStatus): \(errorMessage ?? "unknown", privacy: .public)"
|
|
)
|
|
} catch {
|
|
FanControlLog.writer.error("smc-helper launch failed: \(error.localizedDescription, privacy: .public)")
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
private func shellQuoted(_ value: String) -> String {
|
|
"'\(value.replacingOccurrences(of: "'", with: "'\\''"))'"
|
|
}
|
|
|
|
private func appleScriptEscaped(_ value: String) -> String {
|
|
value
|
|
.replacingOccurrences(of: "\\", with: "\\\\")
|
|
.replacingOccurrences(of: "\"", with: "\\\"")
|
|
}
|
|
}
|