diff --git a/AGENTS.md b/AGENTS.md index baef704..d8dd12b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,5 +3,5 @@ 只记录本项目专属约束。通用规则由 ~/.agents/AGENTS.md 提供。 - 只 import `SMCtlClient` 与 `SMCtlProtocol`。禁止 import `SMCtlDaemonCore`。 -- 策略留在 `smctld`。菜单栏通过 `DaemonClient` 读写 ping、电池状态与 maintain/charging。 +- 策略留在 `smctld`。菜单栏通过 `DaemonClient` 读写 ping、电池状态、maintain/charging,以及风扇 profile(`auto` / `quiet` / `full`,回 auto 走 `setFanProfile` / `setFanAuto`)。不提供手动 RPM。 - `SMCtlClient` 在 smctl#21 合并前通过 `Vendor/SMCtlClient` 与 `Vendor/SMCtlProtocol` 符号链接钉住 extract worktree,不要改成整个 smctl package。 diff --git a/README.md b/README.md index e99b993..8c1d79f 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ SwiftUI `MenuBarExtra` companion for [smctl](https://github.com/leaperone/smctl). -Depends on `SMCtlClient` and `SMCtlProtocol` from smctl. Policy stays in `smctld`. The extra shows charge percent and the configured limit, and sends maintain (`80`, `70-80`, `stop`) and charging on/off through `DaemonClient`. +Depends on `SMCtlClient` and `SMCtlProtocol` from smctl. Policy stays in `smctld`. The extra shows charge percent, the configured limit, and fan RPM (or the SMC no-fans message). It sends maintain (`80`, `70-80`, `stop`), charging on/off, and fan profiles (`auto`, `quiet`, `full`, plus return-to-auto) through `DaemonClient`. Manual RPM entry is not in this menu. Thermal guard stays active on the daemon. ## Path pin (stack) diff --git a/SMCtlMenuBar.xcodeproj/project.pbxproj b/SMCtlMenuBar.xcodeproj/project.pbxproj index d558bf7..18cc67d 100644 --- a/SMCtlMenuBar.xcodeproj/project.pbxproj +++ b/SMCtlMenuBar.xcodeproj/project.pbxproj @@ -18,6 +18,9 @@ A10000000000000000000038 /* BatteryMenuSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000019 /* BatteryMenuSection.swift */; }; A10000000000000000000039 /* BatteryActions.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1000000000000000000001C /* BatteryActions.swift */; }; A1000000000000000000003A /* BatteryActionsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1000000000000000000001E /* BatteryActionsTests.swift */; }; + A1000000000000000000003B /* FanMenuSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1000000000000000000001D /* FanMenuSection.swift */; }; + A1000000000000000000003C /* FanActions.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1000000000000000000001F /* FanActions.swift */; }; + A1000000000000000000003D /* FanActionsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000024 /* FanActionsTests.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -45,6 +48,9 @@ A10000000000000000000019 /* BatteryMenuSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatteryMenuSection.swift; sourceTree = ""; }; A1000000000000000000001C /* BatteryActions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatteryActions.swift; sourceTree = ""; }; A1000000000000000000001E /* BatteryActionsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatteryActionsTests.swift; sourceTree = ""; }; + A1000000000000000000001D /* FanMenuSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FanMenuSection.swift; sourceTree = ""; }; + A1000000000000000000001F /* FanActions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FanActions.swift; sourceTree = ""; }; + A10000000000000000000024 /* FanActionsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FanActionsTests.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -71,6 +77,8 @@ A10000000000000000000011 /* MenuBarRootView.swift */, A10000000000000000000019 /* BatteryMenuSection.swift */, A1000000000000000000001C /* BatteryActions.swift */, + A1000000000000000000001D /* FanMenuSection.swift */, + A1000000000000000000001F /* FanActions.swift */, A10000000000000000000012 /* DaemonSession.swift */, A10000000000000000000013 /* MenuSnapshot.swift */, ); @@ -83,6 +91,7 @@ children = ( A10000000000000000000014 /* MenuSnapshotTests.swift */, A1000000000000000000001E /* BatteryActionsTests.swift */, + A10000000000000000000024 /* FanActionsTests.swift */, ); name = SMCtlMenuBarTests; path = Tests/SMCtlMenuBarTests; @@ -224,6 +233,8 @@ A10000000000000000000033 /* MenuSnapshot.swift in Sources */, A10000000000000000000038 /* BatteryMenuSection.swift in Sources */, A10000000000000000000039 /* BatteryActions.swift in Sources */, + A1000000000000000000003B /* FanMenuSection.swift in Sources */, + A1000000000000000000003C /* FanActions.swift in Sources */, A10000000000000000000035 /* DaemonClient.swift in Sources */, A10000000000000000000036 /* SMCtlClientError.swift in Sources */, A10000000000000000000037 /* SMCtlProtocol.swift in Sources */, @@ -236,6 +247,7 @@ files = ( A10000000000000000000034 /* MenuSnapshotTests.swift in Sources */, A1000000000000000000003A /* BatteryActionsTests.swift in Sources */, + A1000000000000000000003D /* FanActionsTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Sources/SMCtlMenuBar/DaemonSession.swift b/Sources/SMCtlMenuBar/DaemonSession.swift index 8bde2a5..c109a94 100644 --- a/Sources/SMCtlMenuBar/DaemonSession.swift +++ b/Sources/SMCtlMenuBar/DaemonSession.swift @@ -25,7 +25,24 @@ public final class DaemonSession { public func perform(_ command: BatteryCommand) { Task { - let next = await Task.detached { Self.writeThenFetch(command) }.value + let next = await Task.detached { + Self.writeThenFetch( + { try BatteryActions.perform(command) }, + attachError: { $0.attachingBatteryWriteError($1) } + ) + }.value + self.snapshot = next + } + } + + public func perform(_ command: FanCommand) { + Task { + let next = await Task.detached { + Self.writeThenFetch( + { try FanActions.perform(command) }, + attachError: { $0.attachingFanWriteError($1) } + ) + }.value self.snapshot = next } } @@ -36,26 +53,33 @@ public final class DaemonSession { let client = DaemonClient() let ping = try client.ping() let battery = try client.getBatteryStatus() - return .from(ping: ping, battery: battery) + let fans = try client.getFans() + return .from(ping: ping, battery: battery, fans: fans) } catch { return .from(error: error) } } - nonisolated private static func writeThenFetch(_ command: BatteryCommand) -> MenuSnapshot { + nonisolated private static func writeThenFetch( + _ write: () throws -> Void, + attachError: (MenuSnapshot, String) -> MenuSnapshot + ) -> MenuSnapshot { do { - try BatteryActions.perform(command) + try write() return fetchLive() } catch { - return snapshotAfterFailedWrite(error) + return snapshotAfterFailedWrite(error, attachError: attachError) } } - nonisolated private static func snapshotAfterFailedWrite(_ error: Error) -> MenuSnapshot { + nonisolated private static func snapshotAfterFailedWrite( + _ error: Error, + attachError: (MenuSnapshot, String) -> MenuSnapshot + ) -> MenuSnapshot { let live = fetchLive() let message = MenuSnapshot.errorMessage(error) if case .connected = live { - return live.attachingWriteError(message.isEmpty ? "Write failed" : message) + return attachError(live, message.isEmpty ? "Write failed" : message) } return .from(error: error) } diff --git a/Sources/SMCtlMenuBar/FanActions.swift b/Sources/SMCtlMenuBar/FanActions.swift new file mode 100644 index 0000000..eb3ee60 --- /dev/null +++ b/Sources/SMCtlMenuBar/FanActions.swift @@ -0,0 +1,49 @@ +#if canImport(SMCtlClient) +import SMCtlClient +#endif + +public enum FanProfileChoice: String, CaseIterable, Equatable, Hashable, Sendable { + case auto + case quiet + case full + + public var wireName: String { + rawValue + } + + public var menuTitle: String { + switch self { + case .auto: + return "Auto" + case .quiet: + return "Quiet" + case .full: + return "Full" + } + } + + public static func matching(profile: String) -> FanProfileChoice? { + FanProfileChoice(rawValue: profile.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()) + } + + public var command: FanCommand { + .setProfile(self) + } +} + +public enum FanCommand: Sendable, Equatable { + case setProfile(FanProfileChoice) + case returnToAuto +} + +public enum FanActions { + public static func perform(_ command: FanCommand) throws { + let client = DaemonClient() + switch command { + case .returnToAuto: + try client.setFanAuto(index: nil) + case .setProfile(let choice): + try client.setFanProfile(choice.wireName) + } + } +} diff --git a/Sources/SMCtlMenuBar/FanMenuSection.swift b/Sources/SMCtlMenuBar/FanMenuSection.swift new file mode 100644 index 0000000..069a288 --- /dev/null +++ b/Sources/SMCtlMenuBar/FanMenuSection.swift @@ -0,0 +1,53 @@ +import SwiftUI + +public struct FanMenuSection: View { + public var fans: FanSnapshot + public var perform: (FanCommand) -> Void + + public init(fans: FanSnapshot, perform: @escaping (FanCommand) -> Void) { + self.fans = fans + self.perform = perform + } + + public var body: some View { + Text(fans.profileLine) + .accessibilityIdentifier("fan-profile") + if fans.rows.isEmpty { + Text(fans.emptyLine) + .accessibilityIdentifier("fan-empty") + } else { + ForEach(fans.rows, id: \.index) { row in + Text(row.line) + .accessibilityIdentifier("fan-\(row.index)") + } + if let daemonMessage = fans.daemonMessage, !daemonMessage.isEmpty { + Text(daemonMessage) + } + } + if let lastWriteError = fans.lastWriteError, !lastWriteError.isEmpty { + Text(lastWriteError) + .accessibilityIdentifier("fan-write-error") + } + Text(FanSnapshot.thermalGuardLine) + .accessibilityIdentifier("fan-thermal-guard") + + Divider() + ForEach(FanProfileChoice.allCases, id: \.self) { choice in + Button(title(for: choice)) { + perform(choice.command) + } + .accessibilityIdentifier("fan-profile-\(choice.rawValue)") + } + Button("Return to auto") { + perform(.returnToAuto) + } + .accessibilityIdentifier("fan-return-to-auto") + } + + private func title(for choice: FanProfileChoice) -> String { + if fans.selectedProfile == choice { + return "✓ \(choice.menuTitle)" + } + return choice.menuTitle + } +} diff --git a/Sources/SMCtlMenuBar/MenuBarRootView.swift b/Sources/SMCtlMenuBar/MenuBarRootView.swift index 63343c8..d299a74 100644 --- a/Sources/SMCtlMenuBar/MenuBarRootView.swift +++ b/Sources/SMCtlMenuBar/MenuBarRootView.swift @@ -13,10 +13,12 @@ public struct MenuBarRootView: View { switch session.snapshot { case .idle, .loading: Text("Connecting…") - case .connected(let pingLine, let battery): + case .connected(let pingLine, let battery, let fans): Text(pingLine) .accessibilityIdentifier("ping-line") BatteryMenuSection(battery: battery, perform: session.perform) + Divider() + FanMenuSection(fans: fans, perform: session.perform) case .disconnected(let message): Text("Not connected") Text(message) diff --git a/Sources/SMCtlMenuBar/MenuSnapshot.swift b/Sources/SMCtlMenuBar/MenuSnapshot.swift index c94f8ce..d7c41a1 100644 --- a/Sources/SMCtlMenuBar/MenuSnapshot.swift +++ b/Sources/SMCtlMenuBar/MenuSnapshot.swift @@ -6,7 +6,7 @@ import SMCtlProtocol public enum MenuSnapshot: Equatable, Sendable { case idle case loading - case connected(pingLine: String, battery: BatterySnapshot) + case connected(pingLine: String, battery: BatterySnapshot, fans: FanSnapshot) case disconnected(message: String) } @@ -88,14 +88,98 @@ public struct BatterySnapshot: Equatable, Sendable { } } +public struct FanRPMRow: Equatable, Sendable { + public var index: Int + public var actualRPM: Double? + public var targetRPM: Double? + public var mode: String + + public init(index: Int, actualRPM: Double?, targetRPM: Double?, mode: String) { + self.index = index + self.actualRPM = actualRPM + self.targetRPM = targetRPM + self.mode = mode + } + + public var line: String { + "Fan \(index) · \(Self.formatRPM(actualRPM))" + } + + public static func formatRPM(_ value: Double?) -> String { + guard let value else { + return "—" + } + return "\(String(format: "%.0f", value)) RPM" + } +} + +public struct FanSnapshot: Equatable, Sendable { + public var profile: String + public var rows: [FanRPMRow] + public var daemonMessage: String? + public var lastWriteError: String? + + public static let thermalGuardLine = "Thermal guard remains active." + + public init( + profile: String, + rows: [FanRPMRow], + daemonMessage: String? = nil, + lastWriteError: String? = nil + ) { + self.profile = profile + self.rows = rows + self.daemonMessage = daemonMessage + self.lastWriteError = lastWriteError + } + + public static func from(_ fans: FansStatusDTO, lastWriteError: String? = nil) -> FanSnapshot { + FanSnapshot( + profile: fans.profile, + rows: fans.fans.map { fan in + FanRPMRow( + index: fan.index, + actualRPM: fan.actualRPM, + targetRPM: fan.targetRPM, + mode: fan.mode + ) + }, + daemonMessage: fans.message, + lastWriteError: lastWriteError + ) + } + + public var profileLine: String { + "Profile \(profile)" + } + + public var emptyLine: String { + if let daemonMessage, !daemonMessage.isEmpty { + return daemonMessage + } + return "No fans were reported by SMC." + } + + public var selectedProfile: FanProfileChoice? { + FanProfileChoice.matching(profile: profile) + } + + public func attachingWriteError(_ message: String) -> FanSnapshot { + var copy = self + copy.lastWriteError = message + return copy + } +} + extension MenuSnapshot { - public static func from(ping: PingDTO, battery: BatteryStatusDTO) -> MenuSnapshot { + public static func from(ping: PingDTO, battery: BatteryStatusDTO, fans: FansStatusDTO) -> MenuSnapshot { guard ping.ok else { return .disconnected(message: "Daemon ping returned not ok") } return .connected( pingLine: "smctld ok \(ping.version)", - battery: BatterySnapshot.from(battery) + battery: BatterySnapshot.from(battery), + fans: FanSnapshot.from(fans) ) } @@ -108,9 +192,22 @@ extension MenuSnapshot { } public func attachingWriteError(_ message: String) -> MenuSnapshot { + attachingBatteryWriteError(message) + } + + public func attachingBatteryWriteError(_ message: String) -> MenuSnapshot { + switch self { + case .connected(let pingLine, let battery, let fans): + return .connected(pingLine: pingLine, battery: battery.attachingWriteError(message), fans: fans) + case .idle, .loading, .disconnected: + return .disconnected(message: message) + } + } + + public func attachingFanWriteError(_ message: String) -> MenuSnapshot { switch self { - case .connected(let pingLine, let battery): - return .connected(pingLine: pingLine, battery: battery.attachingWriteError(message)) + case .connected(let pingLine, let battery, let fans): + return .connected(pingLine: pingLine, battery: battery, fans: fans.attachingWriteError(message)) case .idle, .loading, .disconnected: return .disconnected(message: message) } diff --git a/Tests/SMCtlMenuBarTests/FanActionsTests.swift b/Tests/SMCtlMenuBarTests/FanActionsTests.swift new file mode 100644 index 0000000..45ab265 --- /dev/null +++ b/Tests/SMCtlMenuBarTests/FanActionsTests.swift @@ -0,0 +1,39 @@ +import Testing +@testable import SMCtlMenuBar + +@Test(arguments: [ + (FanProfileChoice.auto, "auto"), + (FanProfileChoice.quiet, "quiet"), + (FanProfileChoice.full, "full"), +]) +func fanProfileMapsToExactSetFanProfileName(choice: FanProfileChoice, expected: String) { + #expect(choice.wireName == expected) +} + +@Test(arguments: [ + ("auto", FanProfileChoice.auto), + ("quiet", FanProfileChoice.quiet), + ("full", FanProfileChoice.full), + (" Quiet ", FanProfileChoice.quiet), +]) +func fanProfileMatchesStatusName(profile: String, expected: FanProfileChoice) { + #expect(FanProfileChoice.matching(profile: profile) == expected) +} + +@Test +func returnToAutoIsNotAProfileWrite() { + #expect(FanCommand.returnToAuto != FanCommand.setProfile(.auto)) +} + +@Test +func menuChoicesMapToFanCommands() { + #expect(FanProfileChoice.auto.command == .setProfile(.auto)) + #expect(FanProfileChoice.quiet.command == .setProfile(.quiet)) + #expect(FanProfileChoice.full.command == .setProfile(.full)) +} + +@Test +func unknownFanProfileDoesNotMatchMenuChoice() { + #expect(FanProfileChoice.matching(profile: "manual") == nil) + #expect(FanProfileChoice.matching(profile: "custom-curve") == nil) +} diff --git a/Tests/SMCtlMenuBarTests/MenuSnapshotTests.swift b/Tests/SMCtlMenuBarTests/MenuSnapshotTests.swift index 3e443d0..80fe14d 100644 --- a/Tests/SMCtlMenuBarTests/MenuSnapshotTests.swift +++ b/Tests/SMCtlMenuBarTests/MenuSnapshotTests.swift @@ -14,9 +14,15 @@ func connectedSnapshotFromFakePingDTO() { ) let battery = sampleBattery(percent: 80, charging: true, pluggedIn: true, limit: "80") + let fans = sampleFans() + #expect( - MenuSnapshot.from(ping: ping, battery: battery) - == .connected(pingLine: "smctld ok 0.2.3", battery: BatterySnapshot.from(battery)) + MenuSnapshot.from(ping: ping, battery: battery, fans: fans) + == .connected( + pingLine: "smctld ok 0.2.3", + battery: BatterySnapshot.from(battery), + fans: FanSnapshot.from(fans) + ) ) } @@ -37,7 +43,7 @@ func failedPingIsDisconnected() { let battery = sampleBattery(percent: 50, charging: false, pluggedIn: false, limit: "80") #expect( - MenuSnapshot.from(ping: ping, battery: battery) + MenuSnapshot.from(ping: ping, battery: battery, fans: sampleFans()) == .disconnected(message: "Daemon ping returned not ok") ) } @@ -58,9 +64,9 @@ func errorSnapshotIsDisconnectedWithoutLiveDaemon() { func missingChargePercent() { let ping = PingDTO(ok: true, version: "0.2.3", timestamp: Date(timeIntervalSince1970: 0)) let battery = sampleBattery(percent: nil, charging: nil, pluggedIn: nil, limit: "80") - let snapshot = MenuSnapshot.from(ping: ping, battery: battery) + let snapshot = MenuSnapshot.from(ping: ping, battery: battery, fans: sampleFans()) - guard case .connected(_, let live) = snapshot else { + guard case .connected(_, let live, _) = snapshot else { Issue.record("expected connected snapshot") return } @@ -90,10 +96,10 @@ func maintainBandAndStopDisplay() { func writeErrorStaysOnConnectedSnapshot() { let ping = PingDTO(ok: true, version: "0.2.3", timestamp: Date(timeIntervalSince1970: 0)) let battery = sampleBattery(percent: 80, charging: false, pluggedIn: true, limit: "80") - let connected = MenuSnapshot.from(ping: ping, battery: battery) + let connected = MenuSnapshot.from(ping: ping, battery: battery, fans: sampleFans()) let failed = connected.attachingWriteError("Write requests require root or an admin user.") - guard case .connected(_, let live) = failed else { + guard case .connected(_, let live, _) = failed else { Issue.record("expected connected snapshot with write error") return } @@ -101,6 +107,65 @@ func writeErrorStaysOnConnectedSnapshot() { #expect(live.chargeLine == "Battery 80% · plugged in") } +@Test +func emptyFansUsesDaemonMessage() { + let fans = FanSnapshot.from(sampleFans()) + + #expect(fans.profileLine == "Profile auto") + #expect(fans.emptyLine == "No fans were reported by SMC.") + #expect(fans.rows.isEmpty) + #expect(fans.selectedProfile == .auto) + #expect(FanSnapshot.thermalGuardLine == "Thermal guard remains active.") +} + +@Test +func fanRPMRowsAndSelectedProfile() { + let fans = FanSnapshot.from( + sampleFans( + profile: "quiet", + fans: [ + FanStatusDTO( + index: 0, + actualRPM: 1843, + targetRPM: 2000, + minimumRPM: 800, + maximumRPM: 6000, + mode: "manual" + ) + ], + message: nil + ) + ) + + #expect(fans.profileLine == "Profile quiet") + #expect(fans.rows.map(\.line) == ["Fan 0 · 1843 RPM"]) + #expect(fans.selectedProfile == .quiet) + #expect(fans.emptyLine == "No fans were reported by SMC.") + #expect(FanSnapshot.thermalGuardLine == "Thermal guard remains active.") +} + +@Test +func unknownFanProfileHasNoMenuSelection() { + let fans = FanSnapshot.from(sampleFans(profile: "manual", message: nil)) + #expect(fans.selectedProfile == nil) + #expect(fans.profileLine == "Profile manual") +} + +@Test +func fanWriteErrorStaysOnConnectedSnapshot() { + let ping = PingDTO(ok: true, version: "0.2.3", timestamp: Date(timeIntervalSince1970: 0)) + let battery = sampleBattery(percent: 80, charging: false, pluggedIn: true, limit: "80") + let connected = MenuSnapshot.from(ping: ping, battery: battery, fans: sampleFans()) + let failed = connected.attachingFanWriteError("No writable fan mode keys were detected on this Mac/system.") + + guard case .connected(_, _, let live) = failed else { + Issue.record("expected connected snapshot with fan write error") + return + } + #expect(live.lastWriteError == "No writable fan mode keys were detected on this Mac/system.") + #expect(live.profileLine == "Profile auto") +} + private func sampleBattery( percent: Int?, charging: Bool?, @@ -123,3 +188,16 @@ private func sampleBattery( message: nil ) } + +private func sampleFans( + profile: String = "auto", + fans: [FanStatusDTO] = [], + message: String? = "No fans were reported by SMC." +) -> FansStatusDTO { + FansStatusDTO( + timestamp: Date(timeIntervalSince1970: 0), + profile: profile, + fans: fans, + message: message + ) +}