Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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。
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
12 changes: 12 additions & 0 deletions SMCtlMenuBar.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down Expand Up @@ -45,6 +48,9 @@
A10000000000000000000019 /* BatteryMenuSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatteryMenuSection.swift; sourceTree = "<group>"; };
A1000000000000000000001C /* BatteryActions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatteryActions.swift; sourceTree = "<group>"; };
A1000000000000000000001E /* BatteryActionsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatteryActionsTests.swift; sourceTree = "<group>"; };
A1000000000000000000001D /* FanMenuSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FanMenuSection.swift; sourceTree = "<group>"; };
A1000000000000000000001F /* FanActions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FanActions.swift; sourceTree = "<group>"; };
A10000000000000000000024 /* FanActionsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FanActionsTests.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */

/* Begin PBXFrameworksBuildPhase section */
Expand All @@ -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 */,
);
Expand All @@ -83,6 +91,7 @@
children = (
A10000000000000000000014 /* MenuSnapshotTests.swift */,
A1000000000000000000001E /* BatteryActionsTests.swift */,
A10000000000000000000024 /* FanActionsTests.swift */,
);
name = SMCtlMenuBarTests;
path = Tests/SMCtlMenuBarTests;
Expand Down Expand Up @@ -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 */,
Expand All @@ -236,6 +247,7 @@
files = (
A10000000000000000000034 /* MenuSnapshotTests.swift in Sources */,
A1000000000000000000003A /* BatteryActionsTests.swift in Sources */,
A1000000000000000000003D /* FanActionsTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand Down
38 changes: 31 additions & 7 deletions Sources/SMCtlMenuBar/DaemonSession.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Expand All @@ -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)
}
Expand Down
49 changes: 49 additions & 0 deletions Sources/SMCtlMenuBar/FanActions.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
53 changes: 53 additions & 0 deletions Sources/SMCtlMenuBar/FanMenuSection.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
4 changes: 3 additions & 1 deletion Sources/SMCtlMenuBar/MenuBarRootView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
107 changes: 102 additions & 5 deletions Sources/SMCtlMenuBar/MenuSnapshot.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down Expand Up @@ -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)
)
}

Expand All @@ -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)
}
Expand Down
Loading