diff --git a/README.md b/README.md index fbfa6ad..8a876b2 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,10 @@ Each [release](https://github.com/leaperone/smctl/releases) ships a zip with `sm Charge limiting and fan control are applied by `smctld`, a root LaunchDaemon. The CLI talks to it over XPC; reading sensors needs no daemon and no root. +`daemon install` copies the helper to `/Library/PrivilegedHelperTools/one.leaper.smctl.daemon`, owned by root, and restarts the service. After `brew upgrade smctl` or rebuilding from source, run `sudo smctl daemon install` again to update this protected copy. Restarting alone does not install the new binary. + +Foreground discharge is supervised by the daemon. Reaching the target, losing the CLI connection, or missing renewals restores adapter power. A failed restore is retried; it does not depend on the CLI running an exit handler. + The Homebrew install sets up shell completions (bash/zsh/fish) and a `man smctl` page automatically. ## Safety by design diff --git a/Sources/PolicyEngine/PolicyEngine.swift b/Sources/PolicyEngine/PolicyEngine.swift index d63af91..3602337 100644 --- a/Sources/PolicyEngine/PolicyEngine.swift +++ b/Sources/PolicyEngine/PolicyEngine.swift @@ -572,11 +572,7 @@ public struct FanSafetyGuard: Codable, Equatable, Sendable { private var consecutiveOverCeiling: Int public init(configuredCeilingCelsius: Double = Self.defaultCeilingCelsius, isLatched: Bool = false) { - if configuredCeilingCelsius <= 0 { - self.configuredCeilingCelsius = Self.defaultCeilingCelsius - } else { - self.configuredCeilingCelsius = min(configuredCeilingCelsius, Self.hardMaximumCeilingCelsius) - } + self.configuredCeilingCelsius = Self.normalizedCeiling(configuredCeilingCelsius) self.isLatched = isLatched self.consecutiveOverCeiling = 0 } @@ -585,6 +581,8 @@ public struct FanSafetyGuard: Codable, Equatable, Sendable { /// guard is the only thermal floor (thermalmonitord is hands-off), so *no readable /// temperature* counts as unsafe — blind manual control is never allowed. public mutating func evaluate(samples: [FanTemperatureSample], manualPolicyActive: Bool) -> FanSafetyDecision { + configuredCeilingCelsius = Self.normalizedCeiling(configuredCeilingCelsius) + let samples = samples.filter { $0.celsius.isFinite } // Latch release requires credible readings comfortably below each sensor's // own effective ceiling. Hotspot-class Tp* readings use a higher ceiling // than board/skin/proximity sensors. @@ -647,6 +645,10 @@ public struct FanSafetyGuard: Codable, Equatable, Sendable { Self.hotSpotHardMaximumCeilingCelsius ) } + + private static func normalizedCeiling(_ value: Double) -> Double { + value.isFinite && value > 0 ? min(value, hardMaximumCeilingCelsius) : defaultCeilingCelsius + } } public struct FanStartupReconciler: Sendable { diff --git a/Sources/SMCCore/FanControl.swift b/Sources/SMCCore/FanControl.swift index a682891..8a8b4cf 100644 --- a/Sources/SMCCore/FanControl.swift +++ b/Sources/SMCCore/FanControl.swift @@ -15,6 +15,7 @@ public enum FanControlError: Error, Equatable, CustomStringConvertible { case unsupported(String) case missingFan(Int) case missingModeKey(Int) + case restorationFailed(operation: String, restoration: String) public var description: String { switch self { @@ -24,6 +25,8 @@ public enum FanControlError: Error, Equatable, CustomStringConvertible { return "Fan \(index) is not available." case .missingModeKey(let index): return "Fan \(index) has no readable mode key; manual fan control is unsupported." + case .restorationFailed(let operation, let restoration): + return "Manual fan operation failed: \(operation). Restoring auto also failed: \(restoration)" } } } @@ -144,24 +147,46 @@ public struct FanController { public func setManual( index: Int, rpm: Double, - timing: FanUnlockTiming = .default + timing: FanUnlockTiming = .default, + otherManualFansRemaining: Bool = false ) throws -> FanManualResult { let fan = try fan(index) - let unlockPath = try unlockManualMode(fan: fan, timing: timing) - try backend.writeKey( - fan.targetKey, - bytes: Self.float32LittleEndianBytes(Float(rpm)), - retryPolicy: Self.singleAttemptRetryPolicy - ) - return FanManualResult(index: index, requestedRPM: rpm, unlockPath: unlockPath) + guard fan.modeKey != nil else { throw FanControlError.missingModeKey(index) } + do { + let unlockPath = try unlockManualMode(fan: fan, timing: timing) + try backend.writeKey( + fan.targetKey, + bytes: Self.float32LittleEndianBytes(Float(rpm)), + retryPolicy: Self.singleAttemptRetryPolicy + ) + return FanManualResult(index: index, requestedRPM: rpm, unlockPath: unlockPath) + } catch { + try restoreAfterManualFailure(index: index, otherManualFansRemaining: otherManualFansRemaining, operationError: error) + } } @discardableResult public func unlockManualMode( index: Int, - timing: FanUnlockTiming = .default + timing: FanUnlockTiming = .default, + otherManualFansRemaining: Bool = false ) throws -> FanUnlockPath { - try unlockManualMode(fan: fan(index), timing: timing) + let fan = try fan(index) + guard fan.modeKey != nil else { throw FanControlError.missingModeKey(index) } + do { + return try unlockManualMode(fan: fan, timing: timing) + } catch { + try restoreAfterManualFailure(index: index, otherManualFansRemaining: otherManualFansRemaining, operationError: error) + } + } + + private func restoreAfterManualFailure(index: Int, otherManualFansRemaining: Bool, operationError: Error) throws -> Never { + do { + try setAuto(index: index, otherManualFansRemaining: otherManualFansRemaining) + } catch { + throw FanControlError.restorationFailed(operation: String(describing: operationError), restoration: String(describing: error)) + } + throw operationError } public func setAuto(index: Int, otherManualFansRemaining: Bool) throws { diff --git a/Sources/SMCtlClient/DaemonClient.swift b/Sources/SMCtlClient/DaemonClient.swift index a9ed13c..c955b32 100644 --- a/Sources/SMCtlClient/DaemonClient.swift +++ b/Sources/SMCtlClient/DaemonClient.swift @@ -72,8 +72,8 @@ public final class DaemonClient { } } - public func setAdapterEnabled(_ enabled: Bool) throws { - let data = try SMCtlProtocolCoding.encode(SetEnabledRequestDTO(enabled: enabled)) + public func setAdapterEnabled(_ enabled: Bool, dischargeTarget: Int? = nil) throws { + let data = try SMCtlProtocolCoding.encode(SetEnabledRequestDTO(enabled: enabled, dischargeTarget: dischargeTarget)) let _: EmptyResponseDTO = try call { proxy, reply in proxy.setAdapterEnabled(data, withReply: reply) } @@ -174,14 +174,14 @@ public final class DaemonClient { if ping.version != SMCtlProtocolInfo.version { emit(""" warning: smctl is \(SMCtlProtocolInfo.version) but the running smctld is \(ping.version). - Fixes in this version are NOT active until the daemon restarts: - sudo smctl daemon restart + Install this version of the protected helper and restart the daemon: + sudo smctl daemon install """) } else if let latest = ping.latestVersion, SMCtlProtocolInfo.isVersion(latest, newerThan: SMCtlProtocolInfo.version) { emit(""" note: smctl \(latest) is available (you have \(SMCtlProtocolInfo.version)). Upgrade: - brew upgrade smctl && sudo smctl daemon restart + brew upgrade smctl && sudo smctl daemon install """) } } diff --git a/Sources/SMCtlDaemonCore/Daemon.swift b/Sources/SMCtlDaemonCore/Daemon.swift index 657757b..0b23f73 100644 --- a/Sources/SMCtlDaemonCore/Daemon.swift +++ b/Sources/SMCtlDaemonCore/Daemon.swift @@ -297,9 +297,15 @@ enum DaemonError: Error, CustomStringConvertible { } } +private enum ForegroundDischarge { + case active(owner: UUID, target: Int, expiresAt: TimeInterval) + case restoring +} + public final class SmctlDaemon: @unchecked Sendable { let configPath: String static let period: TimeInterval = 10 + static let foregroundDischargeLease: TimeInterval = 30 private let queue = DispatchQueue(label: "one.leaper.smctl.daemon.state") var config = DaemonConfig() @@ -308,6 +314,8 @@ public final class SmctlDaemon: @unchecked Sendable { private var capabilities = Capabilities() var ftstGate = FtstGateManager() var manualFanTargets: [Int: Double] = [:] + private var pendingFanRestores: Set = [] + private var foregroundDischarge: ForegroundDischarge? private var fanCurveEngines: [Int: FanCurveEngine] = [:] private var runtimeFanProfile: String? // Stateful: carries the safety latch across ticks; ceiling refreshed from config. @@ -388,6 +396,7 @@ public final class SmctlDaemon: @unchecked Sendable { queue.sync { timer = source fanTimer = fanSource + reconcileAdapterStartupLocked() reconcileFanStartupLocked() } startUpdateCheck() @@ -497,6 +506,9 @@ public final class SmctlDaemon: @unchecked Sendable { let parsedLimit = try ChargeLimit.parse(limit) let effectiveForceDischarge = parsedLimit.isLimiting && forceDischarge try queue.sync { + if effectiveForceDischarge, foregroundDischarge != nil { + throw DaemonError.unsupported("Stop the foreground discharge before enabling force-discharge.") + } let wasForcing = config.battery.force_discharge config.battery.limit = limit config.battery.force_discharge = effectiveForceDischarge @@ -517,21 +529,98 @@ public final class SmctlDaemon: @unchecked Sendable { } } - func setAdapterEnabled(_ enabled: Bool) throws { + func setAdapterEnabled(_ enabled: Bool, dischargeTarget: Int? = nil, owner: UUID? = nil, now: TimeInterval = ProcessInfo.processInfo.systemUptime) throws { try queue.sync { - try applyAdapterLocked(enabled) + if let target = dischargeTarget { + guard !enabled, (0...100).contains(target), let owner else { + throw DaemonError.unsupported("Foreground discharge requires an owner and a target between 0 and 100.") + } + guard !config.battery.force_discharge else { + throw DaemonError.unsupported("Stop force-discharge before starting a foreground discharge.") + } + if let foregroundDischarge { + guard case .active(let currentOwner, _, _) = foregroundDischarge, currentOwner == owner else { + throw DaemonError.unsupported("Another discharge is active or adapter restoration is pending.") + } + } + guard let charge = readNumericLocked("BUIC"), charge.isFinite else { + throw DaemonError.unsupported("No readable battery was found.") + } + guard charge > Double(target) else { + try restoreForegroundDischargeLocked() + return + } + foregroundDischarge = .active(owner: owner, target: target, expiresAt: now + Self.foregroundDischargeLease) + do { + try applyAdapterLocked(false) + } catch { + let operationError = error + try? restoreForegroundDischargeLocked() + throw operationError + } + return + } + if enabled, foregroundDischarge != nil { + try restoreForegroundDischargeLocked() + } else { + guard foregroundDischarge == nil else { + throw DaemonError.unsupported("A foreground discharge already controls the adapter.") + } + try applyAdapterLocked(enabled) + } evaluateLocked() } } + func cancelForegroundDischarge(owner: UUID) { + queue.async { [self] in + guard case .active(let currentOwner, _, _) = foregroundDischarge, currentOwner == owner else { return } + do { + try restoreForegroundDischargeLocked() + } catch { + lastError = String(describing: error) + logger.error("Foreground discharge restore failed: \(String(describing: error), privacy: .public)") + } + } + } + + private func restoreForegroundDischargeLocked() throws { + foregroundDischarge = .restoring + try applyAdapterLocked(true) + foregroundDischarge = nil + } + + func evaluateForegroundDischargeLocked(now: TimeInterval = ProcessInfo.processInfo.systemUptime) { + guard let foregroundDischarge else { return } + if case .active(_, let target, let expiresAt) = foregroundDischarge, + now < expiresAt, !config.battery.force_discharge, + let charge = readNumericLocked("BUIC"), charge.isFinite, charge > Double(target) { + do { + if readAdapterEnabledLocked() != false { try applyAdapterLocked(false) } + return + } catch { + lastError = String(describing: error) + } + } + do { + try restoreForegroundDischargeLocked() + } catch { + lastError = String(describing: error) + logger.error("Foreground discharge restore failed: \(String(describing: error), privacy: .public)") + } + } + + func reconcileAdapterStartupLocked() { + guard capabilities.adapterControl != nil, !config.battery.force_discharge else { return } + foregroundDischarge = .restoring + evaluateForegroundDischargeLocked() + } + func setFanManual(index: Int, rpm: Double, force: Bool) throws { try queue.sync { try rejectIfSafetyLatchedLocked() let target = try normalizedFanRPM(index: index, rpm: rpm, force: force) - let result = try writeSMCOperationLocked { - try fanControllerLocked().setManual(index: index, rpm: target) - } - ftstGate.enterManual(index: index) + let result = try setManualFanTargetLocked(index: index, rpm: target) manualFanTargets[index] = target fanCurveEngines.removeAll() runtimeFanProfile = "manual" @@ -606,6 +695,9 @@ public final class SmctlDaemon: @unchecked Sendable { ?? BatteryObservation(chargePercent: 100, isChargingAllowed: false, isPluggedIn: false) let evaluation = sleepMachine.handle(event: event, context: SleepContext(limit: limit, observation: observation)) do { + if event == .systemWillSleep, foregroundDischarge != nil { + try restoreForegroundDischargeLocked() + } try applyActionsLocked(evaluation.actions) if evaluation.forcesReevaluation { evaluateLocked() @@ -807,6 +899,7 @@ public final class SmctlDaemon: @unchecked Sendable { /// alert engine. Alerts run even on fanless Macs (where the fan subsystem /// early-returns) so temperature/write-error alerts still work there. func tickFanAndAlertsLocked() { + evaluateForegroundDischargeLocked() let samples = temperatureSamplesLocked() evaluateFanSubsystemLocked(samples: samples) evaluateAlertsLocked(samples: samples) @@ -817,7 +910,14 @@ public final class SmctlDaemon: @unchecked Sendable { return } - let samples = providedSamples ?? temperatureSamplesLocked() + for index in pendingFanRestores.sorted() { + do { + try restoreFansAutoLocked(indices: [index]) + } catch { + lastError = String(describing: error) + } + } + let samples = (providedSamples ?? temperatureSamplesLocked()).filter { $0.celsius.isFinite } let profile = currentFanProfileLocked() // "Manual policy active" covers direct manual targets, residual Ftst/manual // modes, and curve profiles (which drive fans via manual mode every tick). @@ -849,6 +949,7 @@ public final class SmctlDaemon: @unchecked Sendable { guard manualPolicyActive else { return } + guard pendingFanRestores.isEmpty else { return } do { if profile == "manual" { @@ -948,6 +1049,9 @@ public final class SmctlDaemon: @unchecked Sendable { } private func rejectIfSafetyLatchedLocked() throws { + guard pendingFanRestores.isEmpty else { + throw DaemonError.unsupported("Manual fan control is unavailable until pending auto restoration succeeds.") + } guard safetyGuard.isLatched else { return } @@ -962,12 +1066,31 @@ public final class SmctlDaemon: @unchecked Sendable { guard !manualFanTargets.isEmpty else { return } - let controller = try fanControllerLocked() for (index, rpm) in manualFanTargets.sorted(by: { $0.key < $1.key }) { - _ = try writeSMCOperationLocked { - try controller.setManual(index: index, rpm: rpm) + _ = try setManualFanTargetLocked(index: index, rpm: rpm) + } + } + + private func setManualFanTargetLocked(index: Int, rpm: Double) throws -> FanManualResult { + do { + let result = try writeSMCOperationLocked { + try fanControllerLocked().setManual( + index: index, + rpm: rpm, + otherManualFansRemaining: ftstGate.otherManualFansRemain(afterLeaving: index) + ) } ftstGate.enterManual(index: index) + return result + } catch { + manualFanTargets.removeValue(forKey: index) + if case FanControlError.restorationFailed = error { + ftstGate.enterManual(index: index) + pendingFanRestores.insert(index) + } else { + _ = ftstGate.leaveManual(index: index) + } + throw error } } @@ -975,7 +1098,6 @@ public final class SmctlDaemon: @unchecked Sendable { guard !samples.isEmpty else { throw DaemonError.unsupported("No readable temperature sensors are available for fan profile '\(profile)'.") } - let controller = try fanControllerLocked() for fan in capabilities.fans { let maximum = readNumericLocked(fan.maximumKey) ?? 6000 let minimum = readNumericLocked(fan.minimumKey) ?? 0 @@ -984,10 +1106,7 @@ public final class SmctlDaemon: @unchecked Sendable { let evaluation = try engine.evaluate(curve: curve, samples: samples, now: Date()) fanCurveEngines[fan.index] = engine let target = min(maximum, max(minimum, evaluation.targetRPM)) - _ = try writeSMCOperationLocked { - try controller.setManual(index: fan.index, rpm: target) - } - ftstGate.enterManual(index: fan.index) + _ = try setManualFanTargetLocked(index: fan.index, rpm: target) } } @@ -1040,6 +1159,7 @@ public final class SmctlDaemon: @unchecked Sendable { try controller.setAuto(index: index, otherManualFansRemaining: otherManual) } _ = ftstGate.leaveManual(index: index) + pendingFanRestores.remove(index) manualFanTargets.removeValue(forKey: index) } if clearPolicy { @@ -1088,8 +1208,8 @@ public final class SmctlDaemon: @unchecked Sendable { guard let reader else { return [] } - return reader.snapshot().temperatures.map { - FanTemperatureSample(sensor: $0.key, celsius: $0.celsius) + return reader.snapshot().temperatures.compactMap { + $0.celsius.isFinite ? FanTemperatureSample(sensor: $0.key, celsius: $0.celsius) : nil } } @@ -1134,7 +1254,8 @@ public final class SmctlDaemon: @unchecked Sendable { message: statusMessage, timeToEmptyMinutes: timeEstimate.toEmpty, timeToFullMinutes: timeEstimate.toFull, - forceDischarge: config.battery.force_discharge + forceDischarge: config.battery.force_discharge, + foregroundDischargeSupported: true ) } @@ -1325,10 +1446,12 @@ private func powerCallback( final class XPCService: NSObject, SMCtlDaemonXPCProtocol { private let daemon: SmctlDaemon private weak var connection: NSXPCConnection? + private let owner: UUID - init(daemon: SmctlDaemon, connection: NSXPCConnection) { + init(daemon: SmctlDaemon, connection: NSXPCConnection, owner: UUID) { self.daemon = daemon self.connection = connection + self.owner = owner } func daemonPing(withReply reply: @escaping (Data?, String?) -> Void) { @@ -1400,7 +1523,7 @@ final class XPCService: NSObject, SMCtlDaemonXPCProtocol { func setAdapterEnabled(_ requestData: Data, withReply reply: @escaping (Data?, String?) -> Void) { doWrite(reply) { let request = try SMCtlProtocolCoding.decode(SetEnabledRequestDTO.self, from: requestData) - try daemon.setAdapterEnabled(request.enabled) + try daemon.setAdapterEnabled(request.enabled, dischargeTarget: request.dischargeTarget, owner: owner) } } @@ -1481,8 +1604,12 @@ public final class ListenerDelegate: NSObject, NSXPCListenerDelegate { } public func listener(_ listener: NSXPCListener, shouldAcceptNewConnection connection: NSXPCConnection) -> Bool { + let owner = UUID() + let daemon = self.daemon connection.exportedInterface = NSXPCInterface(with: SMCtlDaemonXPCProtocol.self) - connection.exportedObject = XPCService(daemon: daemon, connection: connection) + connection.exportedObject = XPCService(daemon: daemon, connection: connection, owner: owner) + connection.invalidationHandler = { [weak daemon] in daemon?.cancelForegroundDischarge(owner: owner) } + connection.interruptionHandler = { [weak daemon] in daemon?.cancelForegroundDischarge(owner: owner) } connection.resume() return true } diff --git a/Sources/SMCtlProtocol/SMCtlProtocol.swift b/Sources/SMCtlProtocol/SMCtlProtocol.swift index 5609cbf..72d68e8 100644 --- a/Sources/SMCtlProtocol/SMCtlProtocol.swift +++ b/Sources/SMCtlProtocol/SMCtlProtocol.swift @@ -97,6 +97,7 @@ public struct BatteryStatusDTO: Codable, Equatable, Sendable { public var timeToFullMinutes: Int? /// Whether maintain actively discharges down to the band (nil from older daemons). public var forceDischarge: Bool? + public var foregroundDischargeSupported: Bool? public init( timestamp: Date, @@ -114,7 +115,8 @@ public struct BatteryStatusDTO: Codable, Equatable, Sendable { message: String?, timeToEmptyMinutes: Int? = nil, timeToFullMinutes: Int? = nil, - forceDischarge: Bool? = nil + forceDischarge: Bool? = nil, + foregroundDischargeSupported: Bool? = nil ) { self.timestamp = timestamp self.chargePercent = chargePercent @@ -132,6 +134,7 @@ public struct BatteryStatusDTO: Codable, Equatable, Sendable { self.timeToEmptyMinutes = timeToEmptyMinutes self.timeToFullMinutes = timeToFullMinutes self.forceDischarge = forceDischarge + self.foregroundDischargeSupported = foregroundDischargeSupported } } @@ -202,9 +205,11 @@ public struct SetChargeLimitRequestDTO: Codable, Equatable, Sendable { public struct SetEnabledRequestDTO: Codable, Equatable, Sendable { public var enabled: Bool + public var dischargeTarget: Int? - public init(enabled: Bool) { + public init(enabled: Bool, dischargeTarget: Int? = nil) { self.enabled = enabled + self.dischargeTarget = dischargeTarget } } diff --git a/Sources/smctl/SMCtl.swift b/Sources/smctl/SMCtl.swift index 2313e35..002810b 100644 --- a/Sources/smctl/SMCtl.swift +++ b/Sources/smctl/SMCtl.swift @@ -1,4 +1,5 @@ import ArgumentParser +import Darwin import Foundation import SMCCore import SMCtlClient @@ -395,18 +396,14 @@ struct Discharge: ParsableCommand { throw ValidationError("Discharge target must be between 0 and 100.") } let client = DaemonClient() - var shouldRestoreAdapter = false - defer { - if shouldRestoreAdapter { - _ = try? client.setAdapterEnabled(true) - } - } - let initial: BatteryStatusDTO = try mapDaemonClient { try client.getBatteryStatus() } guard initial.adapterControlSupported else { print("Adapter cutoff keys are unavailable on this Mac/system; active discharge is unsupported.") return } + guard initial.foregroundDischargeSupported == true else { + throw ValidationError("This smctld cannot safely supervise foreground discharge. Run 'sudo smctl daemon install' to update it.") + } guard let charge = initial.chargePercent else { print("No readable battery was found; active discharge is unsupported on this Mac.") return @@ -416,8 +413,7 @@ struct Discharge: ParsableCommand { return } - try mapDaemonClient { try client.setAdapterEnabled(false) } - shouldRestoreAdapter = true + try mapDaemonClient { try client.setAdapterEnabled(false, dischargeTarget: target) } print("Adapter disabled for foreground discharge to \(target)%. Press Ctrl-C to restore adapter power.") while true { @@ -435,7 +431,7 @@ struct Discharge: ParsableCommand { // Re-assert the cut every poll: SMC firmware can silently re-enable // adapter power (observed on M2 when the charging-enable key is // written while a cut is active), which would stall the discharge. - try mapDaemonClient { try client.setAdapterEnabled(false) } + try mapDaemonClient { try client.setAdapterEnabled(false, dischargeTarget: target) } } } } @@ -679,7 +675,7 @@ struct DaemonPing: ParsableCommand { struct DaemonRestart: ParsableCommand { static let configuration = CommandConfiguration( commandName: "restart", - abstract: "Restart smctld so it picks up an upgraded binary (requires root)." + abstract: "Restart the installed helper and reload configuration (requires root)." ) func run() throws { @@ -747,16 +743,75 @@ struct DaemonInstall: ParsableCommand { throw ValidationError("daemon install must be run as root. Try: sudo smctl daemon install") } - let smctldPath = try Self.currentSmctldPath() - let plist = Self.plist(smctldPath: smctldPath) + let source = try Self.currentSmctldPath() + try Self.validateHelperDirectory(URL(fileURLWithPath: "/")) + try Self.validateHelperDirectory(URL(fileURLWithPath: "/Library")) + try Self.installHelper(from: URL(fileURLWithPath: source), to: URL(fileURLWithPath: Self.installedHelperPath)) + let plist = try Self.plist() try plist.write(toFile: Self.plistPath, atomically: true, encoding: .utf8) try runProcess("/bin/chmod", ["0644", Self.plistPath]) try runProcess("/usr/sbin/chown", ["root:wheel", Self.plistPath]) + if (try? runProcess("/bin/launchctl", ["print", "system/\(SMCtlProtocolInfo.machServiceName)"])) != nil { + try runProcess("/bin/launchctl", ["bootout", "system/\(SMCtlProtocolInfo.machServiceName)"]) + } try runProcess("/bin/launchctl", ["bootstrap", "system", Self.plistPath]) - print("Installed smctld at \(Self.plistPath)") + print("Installed smctld at \(Self.installedHelperPath)") } static let plistPath = "/Library/LaunchDaemons/one.leaper.smctl.daemon.plist" + static let installedHelperPath = "/Library/PrivilegedHelperTools/one.leaper.smctl.daemon" + + static func installHelper(from source: URL, to destination: URL) throws { + let manager = FileManager.default + let directory = destination.deletingLastPathComponent() + var info = stat() + if lstat(directory.path, &info) != 0 { + guard errno == ENOENT else { throw NSError(domain: NSPOSIXErrorDomain, code: Int(errno)) } + try manager.createDirectory(at: directory, withIntermediateDirectories: false, attributes: [.posixPermissions: 0o755]) + guard lstat(directory.path, &info) == 0 else { throw NSError(domain: NSPOSIXErrorDomain, code: Int(errno)) } + } + try validateHelperDirectory(directory) + // Source ACLs can grant write access before they are cleared. + let stagingDirectory = directory.appendingPathComponent(".smctld-\(UUID().uuidString)") + try manager.createDirectory(at: stagingDirectory, withIntermediateDirectories: false, attributes: [.posixPermissions: 0o700]) + defer { try? manager.removeItem(at: stagingDirectory) } + let staging = stagingDirectory.appendingPathComponent("smctld") + try manager.copyItem(at: source.resolvingSymlinksInPath(), to: staging) + guard lstat(staging.path, &info) == 0, info.st_mode & mode_t(S_IFMT) == mode_t(S_IFREG) else { + throw ValidationError("The smctld helper must be a regular file.") + } + guard let emptyACL = acl_init(0) else { throw NSError(domain: NSPOSIXErrorDomain, code: Int(errno)) } + defer { acl_free(UnsafeMutableRawPointer(emptyACL)) } + guard acl_set_file(staging.path, ACL_TYPE_EXTENDED, emptyACL) == 0 else { + throw NSError(domain: NSPOSIXErrorDomain, code: Int(errno)) + } + try manager.setAttributes([ + .ownerAccountID: NSNumber(value: geteuid()), + .groupOwnerAccountID: NSNumber(value: geteuid() == 0 ? 0 : getegid()), + .posixPermissions: 0o755 + ], ofItemAtPath: staging.path) + guard rename(staging.path, destination.path) == 0 else { + throw NSError(domain: NSPOSIXErrorDomain, code: Int(errno)) + } + } + + private static func validateHelperDirectory(_ directory: URL) throws { + var info = stat() + guard lstat(directory.path, &info) == 0 else { throw NSError(domain: NSPOSIXErrorDomain, code: Int(errno)) } + guard info.st_mode & mode_t(S_IFMT) == mode_t(S_IFDIR), + info.st_uid == geteuid(), info.st_mode & 0o022 == 0 else { + throw ValidationError("Helper directory must be owned by the installing user and must not be writable by other users or groups.") + } + guard let acl = acl_get_file(directory.path, ACL_TYPE_EXTENDED) else { + if errno == ENOENT { return } + throw NSError(domain: NSPOSIXErrorDomain, code: Int(errno)) + } + defer { acl_free(UnsafeMutableRawPointer(acl)) } + var entry: acl_entry_t? + guard acl_get_entry(acl, ACL_FIRST_ENTRY.rawValue, &entry) == -1 else { + throw ValidationError("Helper directory must not have extended ACLs: \(directory.path)") + } + } private static func currentSmctldPath() throws -> String { guard @@ -771,31 +826,16 @@ struct DaemonInstall: ParsableCommand { return path } - private static func plist(smctldPath: String) -> String { - // SMAppService requires an app bundle. The CLI-only M2 milestone installs a hand-written LaunchDaemon plist. - """ - - - - - Label - one.leaper.smctl.daemon - ProgramArguments - - \(smctldPath) - - MachServices - - one.leaper.smctl.daemon - - - KeepAlive - - RunAtLoad - - - - """ + static func plist() throws -> String { + let values: [String: Any] = [ + "Label": SMCtlProtocolInfo.machServiceName, + "ProgramArguments": [installedHelperPath], + "MachServices": [SMCtlProtocolInfo.machServiceName: true], + "KeepAlive": true, + "RunAtLoad": true + ] + let data = try PropertyListSerialization.data(fromPropertyList: values, format: .xml, options: 0) + return String(decoding: data, as: UTF8.self) } } @@ -820,6 +860,9 @@ struct DaemonUninstall: ParsableCommand { try FileManager.default.removeItem(atPath: plistPath) } } + if FileManager.default.fileExists(atPath: DaemonInstall.installedHelperPath) { + try FileManager.default.removeItem(atPath: DaemonInstall.installedHelperPath) + } print("Uninstalled smctld and restored fans plus charging/adapter power when supported.") } } diff --git a/Tests/PolicyEngineTests/FanSafetyGuardTests.swift b/Tests/PolicyEngineTests/FanSafetyGuardTests.swift index 17da8d5..21f192d 100644 --- a/Tests/PolicyEngineTests/FanSafetyGuardTests.swift +++ b/Tests/PolicyEngineTests/FanSafetyGuardTests.swift @@ -2,6 +2,31 @@ import XCTest @testable import PolicyEngine final class FanSafetyGuardTests: XCTestCase { + func testNonFiniteCeilingsUseSafeDefault() { + for ceiling in [Double.nan, .infinity, -.infinity] { + var guardrail = FanSafetyGuard(configuredCeilingCelsius: ceiling) + XCTAssertEqual(guardrail.configuredCeilingCelsius, 100) + _ = guardrail.evaluate(samples: [sample(105)], manualPolicyActive: true) + XCTAssertTrue(guardrail.evaluate(samples: [sample(105)], manualPolicyActive: true).forceAuto) + } + } + + func testNonFiniteTemperaturesCountAsSensorBlindness() { + var guardrail = FanSafetyGuard() + XCTAssertTrue(guardrail.evaluate(samples: [sample(.nan), sample(.infinity)], manualPolicyActive: true).forceAuto) + XCTAssertTrue(guardrail.isLatched) + XCTAssertFalse(guardrail.evaluate(samples: [sample(90), sample(.nan)], manualPolicyActive: true).forceAuto) + XCTAssertFalse(guardrail.isLatched) + } + + func testMutatingCeilingCannotDisableTheGuard() { + var guardrail = FanSafetyGuard() + guardrail.configuredCeilingCelsius = .nan + _ = guardrail.evaluate(samples: [sample(105)], manualPolicyActive: true) + XCTAssertTrue(guardrail.evaluate(samples: [sample(105)], manualPolicyActive: true).forceAuto) + XCTAssertEqual(guardrail.configuredCeilingCelsius, 100) + } + func testCeilingClampAndSustainedTrip() { var defaultGuard = FanSafetyGuard() // ceiling 100 XCTAssertFalse( diff --git a/Tests/SMCCoreTests/FanControllerTests.swift b/Tests/SMCCoreTests/FanControllerTests.swift index 8b0d62e..9dbbfb6 100644 --- a/Tests/SMCCoreTests/FanControllerTests.swift +++ b/Tests/SMCCoreTests/FanControllerTests.swift @@ -46,11 +46,23 @@ final class FanControllerTests: XCTestCase { return } } - XCTAssertEqual(backend.uint8Value("Ftst"), 1) - XCTAssertEqual(backend.uint8Value("F0Md"), 3) + XCTAssertEqual(backend.uint8Value("Ftst"), 0) + XCTAssertEqual(backend.uint8Value("F0Md"), 0) XCTAssertGreaterThanOrEqual(clock.sleeps.count, 4) } + func testTargetWriteFailureRestoresAuto() { + let backend = MockFanBackend() + backend.installSingleFan(ftst: false, mode: 0) + backend.failingWriteKeys = ["F0Tg"] + let controller = FanController(backend: backend, capabilities: capabilities(ftst: false)) + + XCTAssertThrowsError(try controller.setManual(index: 0, rpm: 2400, timing: .test)) + + XCTAssertEqual(backend.uint8Value("F0Md"), 0) + XCTAssertEqual(backend.floatValue("F0Tg"), 1800) + } + func testSetAutoClearsFtstOnlyWhenLastManualFanLeaves() throws { let backend = MockFanBackend() backend.installTwoFans(ftst: true) @@ -65,6 +77,19 @@ final class FanControllerTests: XCTestCase { XCTAssertEqual(backend.uint8Value("Ftst"), 0) } + func testTargetFailureKeepsFtstForOtherManualFan() { + let backend = MockFanBackend() + backend.installTwoFans(ftst: true) + backend.failingWriteKeys = ["F0Tg"] + let controller = FanController(backend: backend, capabilities: twoFanCapabilities(ftst: true)) + + XCTAssertThrowsError(try controller.setManual(index: 0, rpm: 2400, timing: .test, otherManualFansRemaining: true)) + + XCTAssertEqual(backend.uint8Value("F0Md"), 0) + XCTAssertEqual(backend.uint8Value("F1Md"), 1) + XCTAssertEqual(backend.uint8Value("Ftst"), 1) + } + private func capabilities(ftst: Bool) -> Capabilities { Capabilities( fans: [ @@ -115,6 +140,7 @@ private final class MockFanBackend: SMCWriteBackend { } var values: [String: StoredValue] = [:] + var failingWriteKeys: Set = [] var modeFailuresBeforeSuccess = 0 private var modeWriteAttempts = 0 @@ -167,6 +193,7 @@ private final class MockFanBackend: SMCWriteBackend { } func writeRawValue(_ key: String, bytes: [UInt8]) throws { + if failingWriteKeys.contains(key) { throw SMCError.badCommand } if key.hasSuffix("Md"), bytes == [1] { modeWriteAttempts += 1 if modeWriteAttempts <= modeFailuresBeforeSuccess { diff --git a/Tests/SMCtlDaemonCoreTests/SmctlDaemonTests.swift b/Tests/SMCtlDaemonCoreTests/SmctlDaemonTests.swift index 87da987..78bbca5 100644 --- a/Tests/SMCtlDaemonCoreTests/SmctlDaemonTests.swift +++ b/Tests/SMCtlDaemonCoreTests/SmctlDaemonTests.swift @@ -4,6 +4,7 @@ import XCTest @testable import SMCtlDaemonCore @testable import SMCCore import PolicyEngine +import SMCtlProtocol final class SmctlDaemonTests: XCTestCase { private var configPath: String! @@ -59,6 +60,23 @@ final class SmctlDaemonTests: XCTestCase { // MARK: - Startup reconciliation + func testFailedManualRestoreRemainsTrackedAndRetries() throws { + let backend = RecordingBackend() + let daemon = makeDaemon(backend: backend, capabilities: .oneFan) + backend.failingWriteKeys = ["F0Tg"] + backend.failingAutoWrites = 1 + + XCTAssertThrowsError(try daemon.setFanManual(index: 0, rpm: 2000, force: false)) + XCTAssertEqual(backend.values["F0Md"], [1]) + XCTAssertTrue(daemon.ftstGate.hasManualFans) + + XCTAssertThrowsError(try daemon.setFanManual(index: 0, rpm: 2500, force: false)) + daemon.evaluateFanSubsystemLocked() + + XCTAssertEqual(backend.values["F0Md"], [0]) + XCTAssertFalse(daemon.ftstGate.hasManualFans) + } + func testStartupReconciliationRestoresCrashResidue() { // SMC reports a fan stuck in manual mode but the daemon has no local policy: // startup must restore auto. @@ -95,6 +113,145 @@ final class SmctlDaemonTests: XCTestCase { // MARK: - Below-minimum gating + func testForegroundDischargeStopsAtTargetAndReassertsFirmwareReset() throws { + let (backend, daemon) = makeAdapterDaemon() + let owner = UUID() + let now: TimeInterval = 100 + try daemon.setAdapterEnabled(false, dischargeTarget: 40, owner: owner, now: now) + XCTAssertEqual(backend.values["CHIE"], [1]) + + backend.values["CHIE"] = [0] + daemon.evaluateForegroundDischargeLocked(now: now + 1) + XCTAssertEqual(backend.values["CHIE"], [1]) + + backend.values["BUIC"] = [40] + daemon.evaluateForegroundDischargeLocked(now: now + 2) + XCTAssertEqual(backend.values["CHIE"], [0]) + daemon.evaluateForegroundDischargeLocked(now: now + 3) + XCTAssertEqual(backend.values["CHIE"], [0]) + } + + func testForegroundDischargeLeaseRequiresLiveOwnerAndRenewal() throws { + let (backend, daemon) = makeAdapterDaemon() + let owner = UUID() + let now: TimeInterval = 100 + try daemon.setAdapterEnabled(false, dischargeTarget: 40, owner: owner, now: now) + XCTAssertThrowsError(try daemon.setAdapterEnabled(false, dischargeTarget: 40, owner: UUID(), now: now)) + daemon.cancelForegroundDischarge(owner: UUID()) + _ = daemon.daemonStatus() + XCTAssertEqual(backend.values["CHIE"], [1]) + + try daemon.setAdapterEnabled(false, dischargeTarget: 40, owner: owner, now: now + 20) + daemon.evaluateForegroundDischargeLocked(now: now + 31) + XCTAssertEqual(backend.values["CHIE"], [1]) + daemon.evaluateForegroundDischargeLocked(now: now + 51) + XCTAssertEqual(backend.values["CHIE"], [0]) + } + + func testForegroundDischargeRetriesFailedDisconnectRestore() throws { + let (backend, daemon) = makeAdapterDaemon() + let owner = UUID() + try daemon.setAdapterEnabled(false, dischargeTarget: 40, owner: owner) + backend.failingWriteKeys = ["CHIE"] + + daemon.cancelForegroundDischarge(owner: owner) + _ = daemon.daemonStatus() + XCTAssertEqual(backend.values["CHIE"], [1]) + XCTAssertThrowsError(try daemon.setAdapterEnabled(false, dischargeTarget: 40, owner: UUID())) + + backend.failingWriteKeys = [] + daemon.tickFanAndAlertsLocked() + XCTAssertEqual(backend.values["CHIE"], [0]) + try daemon.setAdapterEnabled(false, dischargeTarget: 40, owner: UUID()) + XCTAssertEqual(backend.values["CHIE"], [1]) + } + + func testCompletedOwnersDisconnectDoesNotStopTheNextDischarge() throws { + let (backend, daemon) = makeAdapterDaemon() + let firstOwner = UUID() + let secondOwner = UUID() + try daemon.setAdapterEnabled(false, dischargeTarget: 40, owner: firstOwner) + backend.values["BUIC"] = [40] + daemon.tickFanAndAlertsLocked() + XCTAssertEqual(backend.values["CHIE"], [0]) + + try daemon.setAdapterEnabled(false, dischargeTarget: 30, owner: secondOwner) + daemon.cancelForegroundDischarge(owner: firstOwner) + _ = daemon.daemonStatus() + XCTAssertEqual(backend.values["CHIE"], [1]) + + daemon.cancelForegroundDischarge(owner: secondOwner) + _ = daemon.daemonStatus() + XCTAssertEqual(backend.values["CHIE"], [0]) + } + + func testForegroundDischargeRestoresAfterUnreadableBatteryAndPartialCutFailure() throws { + let (backend, daemon) = makeAdapterDaemon() + backend.adapterCutFailuresAfterWrite = 3 + XCTAssertThrowsError(try daemon.setAdapterEnabled(false, dischargeTarget: 40, owner: UUID())) + XCTAssertEqual(backend.values["CHIE"], [0]) + + try daemon.setAdapterEnabled(false, dischargeTarget: 40, owner: UUID()) + backend.values.removeValue(forKey: "BUIC") + daemon.tickFanAndAlertsLocked() + XCTAssertEqual(backend.values["CHIE"], [0]) + } + + func testStartupRestoresOrphanAdapterCutButPreservesForceDischarge() throws { + let (backend, daemon) = makeAdapterDaemon() + backend.values["CHIE"] = [1] + daemon.reconcileAdapterStartupLocked() + XCTAssertEqual(backend.values["CHIE"], [0]) + + try daemon.setChargeLimit("70-80", forceDischarge: true) + XCTAssertEqual(backend.values["CHIE"], [1]) + daemon.reconcileAdapterStartupLocked() + XCTAssertEqual(backend.values["CHIE"], [1]) + XCTAssertThrowsError(try daemon.setAdapterEnabled(false, dischargeTarget: 40, owner: UUID())) + } + + func testForegroundDischargeRejectsInvalidRequestsBeforeWriting() throws { + let (backend, daemon) = makeAdapterDaemon() + for target in [-1, 101] { + XCTAssertThrowsError(try daemon.setAdapterEnabled(false, dischargeTarget: target, owner: UUID())) + } + XCTAssertThrowsError(try daemon.setAdapterEnabled(true, dischargeTarget: 40, owner: UUID())) + XCTAssertThrowsError(try daemon.setAdapterEnabled(false, dischargeTarget: 40)) + XCTAssertEqual(backend.values["CHIE"], [0]) + XCTAssertEqual(daemon.batteryStatus().foregroundDischargeSupported, true) + } + + func testXPCDisconnectRestoresForegroundDischarge() throws { + let (backend, daemon) = makeAdapterDaemon() + let listener = NSXPCListener.anonymous() + let delegate = ListenerDelegate(daemon: daemon) + listener.delegate = delegate + listener.resume() + defer { listener.invalidate() } + let connection = NSXPCConnection(listenerEndpoint: listener.endpoint) + connection.remoteObjectInterface = NSXPCInterface(with: SMCtlDaemonXPCProtocol.self) + connection.resume() + let started = expectation(description: "discharge started through XPC") + let proxy = try XCTUnwrap(connection.remoteObjectProxyWithErrorHandler { error in + XCTFail("XPC failed: \(error)") + started.fulfill() + } as? SMCtlDaemonXPCProtocol) + let request = try SMCtlProtocolCoding.encode(SetEnabledRequestDTO(enabled: false, dischargeTarget: 40)) + proxy.setAdapterEnabled(request) { _, error in + XCTAssertNil(error) + started.fulfill() + } + wait(for: [started], timeout: 5) + XCTAssertEqual(backend.values["CHIE"], [1]) + + connection.invalidate() + let deadline = Date().addingTimeInterval(5) + while backend.values["CHIE"] != [0], Date() < deadline { + Thread.sleep(forTimeInterval: 0.01) + } + XCTAssertEqual(backend.values["CHIE"], [0]) + } + func testBelowMinimumIsClampedWithoutForce() throws { let backend = RecordingBackend() let daemon = makeDaemon(backend: backend, capabilities: .oneFan) @@ -442,8 +599,33 @@ final class SmctlDaemonTests: XCTestCase { XCTAssertTrue(reported.contains("failed to parse"), "status should explain the failure: \(reported)") } + func testNanTomlCeilingStillRestoresOverheatedManualFan() throws { + try "[safety]\ntemp_ceiling = nan\n".write(toFile: configPath, atomically: true, encoding: .utf8) + let backend = RecordingBackend() + let daemon = makeDaemon(backend: backend, capabilities: .oneFan) + XCTAssertTrue(daemon.config.safety.temp_ceiling.isNaN) + try daemon.setFanManual(index: 0, rpm: 2000, force: false) + let samples = [FanTemperatureSample(sensor: "TC0P", celsius: 105)] + + daemon.evaluateFanSubsystemLocked(samples: samples) + daemon.evaluateFanSubsystemLocked(samples: samples) + + XCTAssertTrue(daemon.safetyGuard.isLatched) + XCTAssertEqual(backend.values["F0Md"], [0]) + XCTAssertEqual(daemon.safetyGuard.configuredCeilingCelsius, 100) + } + // MARK: - Helpers + private func makeAdapterDaemon() -> (RecordingBackend, SmctlDaemon) { + let backend = RecordingBackend() + backend.values["BUIC"] = [85] + backend.values["CH0B"] = [0] + backend.values["CH0C"] = [0] + backend.values["CHIE"] = [0] + return (backend, makeDaemon(backend: backend, capabilities: .oneFanWithAdapter)) + } + private func makeDaemon(backend: RecordingBackend, capabilities: Capabilities) -> SmctlDaemon { // Seed the fan keys so mode/target writes pass the key-info size check. backend.values["F0Md"] = backend.values["F0Md"] ?? [0] @@ -499,6 +681,16 @@ final class AlertActionRunnerTests: XCTestCase { } private extension Capabilities { + static var oneFanWithAdapter: Capabilities { + var capabilities = oneFanWithCharging + capabilities.adapterControl = SMCControlKeyGroup( + identifier: "CHIE", statusKey: "CHIE", requiredKeys: ["CHIE"], + enableWrites: [SMCKeyWrite(key: "CHIE", bytes: [0])], + disableWrites: [SMCKeyWrite(key: "CHIE", bytes: [1])] + ) + return capabilities + } + static var oneFan: Capabilities { Capabilities( fans: [FanCapability( @@ -530,9 +722,16 @@ private extension Capabilities { /// Mock SMC backend: key/value store for reads, append log for writes. /// Writes apply immediately (no async-settle simulation needed at this layer). private final class RecordingBackend: SMCWriteBackend { - var values: [String: [UInt8]] = [:] + private let lock = NSLock() + private var storedValues: [String: [UInt8]] = [:] + var values: [String: [UInt8]] { + get { lock.withLock { storedValues } } + set { lock.withLock { storedValues = newValue } } + } var writes: [(key: String, bytes: [UInt8])] = [] var failingWriteKeys: Set = [] + var failingAutoWrites = 0 + var adapterCutFailuresAfterWrite = 0 func readKeyInfo(_ key: String) throws -> SMCKeyInfo { guard let bytes = values[key] else { @@ -556,10 +755,18 @@ private final class RecordingBackend: SMCWriteBackend { } func writeRawValue(_ key: String, bytes: [UInt8]) throws { + if key.hasSuffix("Md"), bytes == [0], failingAutoWrites > 0 { + failingAutoWrites -= 1 + throw SMCError.badCommand + } if failingWriteKeys.contains(key) { throw SMCError.badCommand } writes.append((key, bytes)) values[key] = bytes + if key == "CHIE", bytes == [1], adapterCutFailuresAfterWrite > 0 { + adapterCutFailuresAfterWrite -= 1 + throw SMCError.badCommand + } } } diff --git a/Tests/SMCtlProtocolTests/VersionCompareTests.swift b/Tests/SMCtlProtocolTests/VersionCompareTests.swift index a3b965f..8500edd 100644 --- a/Tests/SMCtlProtocolTests/VersionCompareTests.swift +++ b/Tests/SMCtlProtocolTests/VersionCompareTests.swift @@ -2,6 +2,20 @@ import XCTest @testable import SMCtlProtocol final class VersionCompareTests: XCTestCase { + func testAdapterRequestsRemainCompatibleWithOlderClients() throws { + let legacy = try SMCtlProtocolCoding.decode(SetEnabledRequestDTO.self, from: Data(#"{"enabled":false}"#.utf8)) + XCTAssertEqual(legacy, SetEnabledRequestDTO(enabled: false)) + let request = SetEnabledRequestDTO(enabled: false, dischargeTarget: 40) + XCTAssertEqual(try SMCtlProtocolCoding.decode(SetEnabledRequestDTO.self, from: SMCtlProtocolCoding.encode(request)), request) + } + + func testOldBatteryStatusDoesNotAdvertiseSupervisedDischarge() throws { + let data = Data(#"{"timestamp":"2026-06-08T00:00:00Z","chargingControlSupported":true,"adapterControlSupported":true,"configuredLimit":"100","lowerBound":100,"upperBound":100,"sleepPolicy":"strict"}"#.utf8) + let status = try SMCtlProtocolCoding.decode(BatteryStatusDTO.self, from: data) + XCTAssertNil(status.foregroundDischargeSupported) + XCTAssertTrue(status.adapterControlSupported) + } + func testNewerDetection() { XCTAssertTrue(SMCtlProtocolInfo.isVersion("0.1.7", newerThan: "0.1.6")) XCTAssertTrue(SMCtlProtocolInfo.isVersion("0.2.0", newerThan: "0.1.9")) diff --git a/Tests/smctlTests/DaemonInstallPathTests.swift b/Tests/smctlTests/DaemonInstallPathTests.swift index b3851c1..3c5bab6 100644 --- a/Tests/smctlTests/DaemonInstallPathTests.swift +++ b/Tests/smctlTests/DaemonInstallPathTests.swift @@ -1,7 +1,82 @@ import XCTest +import Darwin @testable import smctl final class DaemonInstallPathTests: XCTestCase { + func testInstalledHelperIsAnIndependentRegularFile() throws { + let root = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(UUID().uuidString) + let manager = FileManager.default + try manager.createDirectory(at: root, withIntermediateDirectories: false, attributes: [.posixPermissions: 0o755]) + defer { try? manager.removeItem(at: root) } + let source = root.appendingPathComponent("smctld-source") + let link = root.appendingPathComponent("smctld-link") + let destination = root.appendingPathComponent("protected/smctld") + try Data("original helper".utf8).write(to: source) + try addACL("everyone allow write,append,writeattr,writeextattr", to: source) + let sourceACL = try XCTUnwrap(acl_get_file(source.path, ACL_TYPE_EXTENDED)) + defer { acl_free(UnsafeMutableRawPointer(sourceACL)) } + var sourceEntry: acl_entry_t? + XCTAssertEqual(acl_get_entry(sourceACL, ACL_FIRST_ENTRY.rawValue, &sourceEntry), 0) + try manager.createSymbolicLink(at: link, withDestinationURL: source) + + try DaemonInstall.installHelper(from: link, to: destination) + try Data("replaced source".utf8).write(to: source) + + XCTAssertEqual(try String(contentsOf: destination, encoding: .utf8), "original helper") + var info = stat() + XCTAssertEqual(lstat(destination.path, &info), 0) + XCTAssertEqual(info.st_mode & mode_t(S_IFMT), mode_t(S_IFREG)) + XCTAssertEqual(info.st_mode & 0o777, 0o755) + XCTAssertEqual(info.st_uid, geteuid()) + if let acl = acl_get_file(destination.path, ACL_TYPE_EXTENDED) { + defer { acl_free(UnsafeMutableRawPointer(acl)) } + var entry: acl_entry_t? + XCTAssertEqual(acl_get_entry(acl, ACL_FIRST_ENTRY.rawValue, &entry), -1, "the protected copy must not retain source ACLs") + } else { + XCTAssertEqual(errno, ENOENT) + } + + try DaemonInstall.installHelper(from: link, to: destination) + XCTAssertEqual(try String(contentsOf: destination, encoding: .utf8), "replaced source") + } + + func testInstallerRejectsWritableOrSymlinkDirectories() throws { + let root = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(UUID().uuidString) + let manager = FileManager.default + try manager.createDirectory(at: root, withIntermediateDirectories: false, attributes: [.posixPermissions: 0o755]) + defer { try? manager.removeItem(at: root) } + let source = root.appendingPathComponent("source") + try Data("helper".utf8).write(to: source) + let writable = root.appendingPathComponent("writable") + try manager.createDirectory(at: writable, withIntermediateDirectories: false) + try manager.setAttributes([.posixPermissions: 0o777], ofItemAtPath: writable.path) + XCTAssertThrowsError(try DaemonInstall.installHelper(from: source, to: writable.appendingPathComponent("helper"))) + let link = root.appendingPathComponent("link") + try manager.createSymbolicLink(at: link, withDestinationURL: root) + XCTAssertThrowsError(try DaemonInstall.installHelper(from: source, to: link.appendingPathComponent("helper"))) + let aclDirectory = root.appendingPathComponent("acl-directory") + try manager.createDirectory(at: aclDirectory, withIntermediateDirectories: false, attributes: [.posixPermissions: 0o755]) + try addACL("everyone allow add_file,delete_child", to: aclDirectory) + XCTAssertThrowsError(try DaemonInstall.installHelper(from: source, to: aclDirectory.appendingPathComponent("helper"))) + XCTAssertFalse(manager.fileExists(atPath: writable.appendingPathComponent("helper").path)) + } + + private func addACL(_ acl: String, to url: URL) throws { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/chmod") + process.arguments = ["+a", acl, url.path] + try process.run() + process.waitUntilExit() + XCTAssertEqual(process.terminationStatus, 0, "ACL fixture must be applied") + } + + func testLaunchDaemonExecutesTheProtectedCopy() throws { + let data = Data(try DaemonInstall.plist().utf8) + let plist = try XCTUnwrap(PropertyListSerialization.propertyList(from: data, format: nil) as? [String: Any]) + XCTAssertEqual(plist["ProgramArguments"] as? [String], ["/Library/PrivilegedHelperTools/one.leaper.smctl.daemon"]) + XCTAssertEqual(plist["KeepAlive"] as? Bool, true) + } + func testResolvesSmctldNextToExecutable() { let path = smctldPath( besideExecutable: "/opt/homebrew/bin/smctl", diff --git a/docs/README.zh-CN.md b/docs/README.zh-CN.md index 147cc1e..d9a8a6d 100644 --- a/docs/README.zh-CN.md +++ b/docs/README.zh-CN.md @@ -73,6 +73,10 @@ $ sudo .build/release/smctl daemon install 充电限制和风扇控制由 root LaunchDaemon `smctld` 执行,CLI 通过 XPC 与之通信;只读传感器不需要 daemon 也不需要 root。 +`daemon install` 会将 helper 复制到 root 拥有的 `/Library/PrivilegedHelperTools/one.leaper.smctl.daemon`,并重启服务。执行 `brew upgrade smctl` 或从源码重新构建后,需要再次运行 `sudo smctl daemon install` 更新受保护的副本,仅 restart 不会更新二进制。 + +前台放电由 daemon 监督。达到目标、CLI 断连或续约超时后,daemon 恢复适配器供电;恢复失败会继续重试,不依赖 CLI 的退出钩子。 + Homebrew 安装会自动配置 shell 补全(bash/zsh/fish)和 `man smctl` 手册页。 ## 安全设计 diff --git a/docs/smctl.1 b/docs/smctl.1 index e260c15..6e6269a 100644 --- a/docs/smctl.1 +++ b/docs/smctl.1 @@ -55,7 +55,8 @@ One-shot charge up to PERCENT, then stop. .IR PERCENT .RS Cut adapter power and discharge down to PERCENT. Runs in the foreground; -adapter power is restored on completion or interrupt. +the daemon restores adapter power on completion, client disconnect, or renewal +timeout. Failed restoration is retried. .RE .SS fan .B smctl fan status @@ -103,9 +104,12 @@ Manage require root. .B uninstall restores system control of fans and charging before removing the daemon. -.B restart -reloads an upgraded binary \(em run it after every +.B install +updates the root-owned helper in /Library/PrivilegedHelperTools and restarts +the service. Run it after every .BR "brew upgrade" . +.B restart +restarts the installed copy and reloads configuration; it does not copy a new binary. .RE .SH CONFIGURATION Policy lives in