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`。菜单栏只读 ping 与状态。
- 策略留在 `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`.
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
24 changes: 24 additions & 0 deletions SMCtlMenuBar.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@
A10000000000000000000035 /* DaemonClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000015 /* DaemonClient.swift */; };
A10000000000000000000036 /* SMCtlClientError.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000016 /* SMCtlClientError.swift */; };
A10000000000000000000037 /* SMCtlProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000017 /* SMCtlProtocol.swift */; };
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 All @@ -39,6 +45,12 @@
A10000000000000000000015 /* DaemonClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DaemonClient.swift; sourceTree = "<group>"; };
A10000000000000000000016 /* SMCtlClientError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SMCtlClientError.swift; sourceTree = "<group>"; };
A10000000000000000000017 /* SMCtlProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SMCtlProtocol.swift; sourceTree = "<group>"; };
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 @@ -63,6 +75,10 @@
isa = PBXGroup;
children = (
A10000000000000000000011 /* MenuBarRootView.swift */,
A10000000000000000000019 /* BatteryMenuSection.swift */,
A1000000000000000000001C /* BatteryActions.swift */,
A1000000000000000000001D /* FanMenuSection.swift */,
A1000000000000000000001F /* FanActions.swift */,
A10000000000000000000012 /* DaemonSession.swift */,
A10000000000000000000013 /* MenuSnapshot.swift */,
);
Expand All @@ -74,6 +90,8 @@
isa = PBXGroup;
children = (
A10000000000000000000014 /* MenuSnapshotTests.swift */,
A1000000000000000000001E /* BatteryActionsTests.swift */,
A10000000000000000000024 /* FanActionsTests.swift */,
);
name = SMCtlMenuBarTests;
path = Tests/SMCtlMenuBarTests;
Expand Down Expand Up @@ -213,6 +231,10 @@
A10000000000000000000031 /* MenuBarRootView.swift in Sources */,
A10000000000000000000032 /* DaemonSession.swift in Sources */,
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 @@ -224,6 +246,8 @@
buildActionMask = 2147483647;
files = (
A10000000000000000000034 /* MenuSnapshotTests.swift in Sources */,
A1000000000000000000003A /* BatteryActionsTests.swift in Sources */,
A1000000000000000000003D /* FanActionsTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand Down
66 changes: 66 additions & 0 deletions Sources/SMCtlMenuBar/BatteryActions.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
#if canImport(SMCtlClient)
import SMCtlClient
#endif

public enum MaintainPreset: String, CaseIterable, Equatable, Hashable, Sendable {
case cap80 = "80"
case band7080 = "70-80"
case stop = "stop"

public var wireLimit: String {
switch self {
case .cap80:
return "80"
case .band7080:
return "70-80"
case .stop:
return "100"
}
}

public var menuTitle: String {
switch self {
case .cap80:
return "Maintain 80"
case .band7080:
return "Maintain 70-80"
case .stop:
return "Stop maintain"
}
}

public static func matching(configuredLimit: String) -> MaintainPreset? {
let trimmed = configuredLimit.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
if ["100", "stop", "off", "disabled"].contains(trimmed) {
return .stop
}
if trimmed == "80" {
return .cap80
}
if trimmed == "70-80" {
return .band7080
}
return nil
}
}

public enum BatteryCommand: Sendable, Equatable {
case maintain(MaintainPreset)
case setCharging(Bool)
}

public enum BatteryActions {
public static func perform(_ command: BatteryCommand) throws {
let client = DaemonClient()
switch command {
case .maintain(let preset):
try client.setChargeLimit(preset.wireLimit)
if preset == .stop {
_ = try? client.setChargingEnabled(true)
_ = try? client.setAdapterEnabled(true)
}
case .setCharging(let enabled):
try client.setChargingEnabled(enabled)
}
}
}
50 changes: 50 additions & 0 deletions Sources/SMCtlMenuBar/BatteryMenuSection.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import SwiftUI

public struct BatteryMenuSection: View {
public var battery: BatterySnapshot
public var perform: (BatteryCommand) -> Void

public init(battery: BatterySnapshot, perform: @escaping (BatteryCommand) -> Void) {
self.battery = battery
self.perform = perform
}

public var body: some View {
Text(battery.chargeLine)
.accessibilityIdentifier("battery-charge")
Text(battery.limitLine)
.accessibilityIdentifier("battery-limit")
if let daemonMessage = battery.daemonMessage, !daemonMessage.isEmpty {
Text(daemonMessage)
}
if let lastWriteError = battery.lastWriteError, !lastWriteError.isEmpty {
Text(lastWriteError)
.accessibilityIdentifier("battery-write-error")
}

if battery.chargingControlSupported {
Divider()
ForEach(MaintainPreset.allCases, id: \.self) { preset in
Button(title(for: preset)) {
perform(.maintain(preset))
}
.accessibilityIdentifier("maintain-\(preset.rawValue)")
}
Button("Charging on") {
perform(.setCharging(true))
}
.accessibilityIdentifier("charging-on")
Button("Charging off") {
perform(.setCharging(false))
}
.accessibilityIdentifier("charging-off")
}
}

private func title(for preset: MaintainPreset) -> String {
if battery.selectedMaintain == preset {
return "✓ \(preset.menuTitle)"
}
return preset.menuTitle
}
}
51 changes: 50 additions & 1 deletion Sources/SMCtlMenuBar/DaemonSession.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,15 +23,64 @@ public final class DaemonSession {
}
}

public func perform(_ command: BatteryCommand) {
Task {
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
}
}

// XPC calls block on a semaphore inside DaemonClient. Never run them on the main actor.
nonisolated private static func fetchLive() -> MenuSnapshot {
do {
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(
_ write: () throws -> Void,
attachError: (MenuSnapshot, String) -> MenuSnapshot
) -> MenuSnapshot {
do {
try write()
return fetchLive()
} catch {
return snapshotAfterFailedWrite(error, attachError: attachError)
}
}

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 attachError(live, message.isEmpty ? "Write failed" : message)
}
return .from(error: error)
}
}
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
}
}
7 changes: 5 additions & 2 deletions Sources/SMCtlMenuBar/MenuBarRootView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,12 @@ public struct MenuBarRootView: View {
switch session.snapshot {
case .idle, .loading:
Text("Connecting…")
case .connected(let pingLine, let statusLine):
case .connected(let pingLine, let battery, let fans):
Text(pingLine)
Text(statusLine)
.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
Loading