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
11 changes: 10 additions & 1 deletion Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ let package = Package(
],
dependencies: [
.package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.5.0"),
.package(url: "https://github.com/LebJe/TOMLKit.git", from: "0.6.0")
.package(url: "https://github.com/LebJe/TOMLKit.git", from: "0.6.0"),
.package(url: "https://github.com/getsentry/sentry-cocoa.git", from: "9.17.1")
],
targets: [
.target(
Expand All @@ -33,6 +34,7 @@ let package = Package(
"SMCCore",
"PolicyEngine",
"SMCtlProtocol",
.product(name: "Sentry", package: "sentry-cocoa"),
.product(name: "TOMLKit", package: "TOMLKit")
],
linkerSettings: [
Expand Down
10 changes: 7 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,19 +87,23 @@ Controlling fans and charging from userspace demands paranoia. smctl treats thes

## Privacy

The daemon makes outbound network requests in exactly two cases, both under your control:
The daemon makes outbound network requests in these cases, all under your control:

1. **Update check** — a once-a-day check of the GitHub releases API to learn the latest version, surfaced by the CLI as an upgrade hint. On by default; turn it off with `[update] check = false`.
2. **Alert webhooks** — only the webhook URLs *you* configure in `[[alert]]` rules. No alerts configured (the default) means no such requests.
3. **Sentry crash/error reporting** — only when you configure a DSN under `[sentry]`. The default empty DSN disables the SDK entirely. smctl sets `sendDefaultPii = false` and does not enable performance tracing unless you opt in with `traces_sample_rate`.

There is no telemetry and no analytics. The CLI itself never makes network requests — all outbound traffic originates in the daemon, from the two opt-in/opt-out cases above. To go fully offline, set:
There is no product analytics. The CLI itself never makes network requests — all outbound traffic originates in the daemon, from the opt-in/opt-out cases above. To go fully offline, set:

```toml
[update]
check = false

[sentry]
dsn = ""
```

in `/etc/smctl/config.toml` (then `sudo smctl daemon restart`) and configure no webhook alerts.
in `/etc/smctl/config.toml` (then `sudo smctl daemon restart`) and configure no webhook alerts. You can also hard-disable Sentry for the daemon process with `SMCTL_SENTRY_DISABLED=1`.

## Alerts

Expand Down
47 changes: 44 additions & 3 deletions Sources/SMCtlDaemonCore/Daemon.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,24 +21,27 @@ struct DaemonConfig: Codable, Equatable, Sendable {
var fan: FanConfig
var safety: SafetyConfig
var update: UpdateConfig
var sentry: SentryConfig
var alerts: [AlertConfig]

init(
battery: BatteryConfig = BatteryConfig(),
fan: FanConfig = FanConfig(),
safety: SafetyConfig = SafetyConfig(),
update: UpdateConfig = UpdateConfig(),
sentry: SentryConfig = SentryConfig(),
alerts: [AlertConfig] = []
) {
self.battery = battery
self.fan = fan
self.safety = safety
self.update = update
self.sentry = sentry
self.alerts = alerts
}

enum CodingKeys: String, CodingKey {
case battery, fan, safety, update
case battery, fan, safety, update, sentry
case alerts = "alert"
}

Expand All @@ -48,14 +51,15 @@ struct DaemonConfig: Codable, Equatable, Sendable {
fan = try container.decodeIfPresent(FanConfig.self, forKey: .fan) ?? FanConfig()
safety = try container.decodeIfPresent(SafetyConfig.self, forKey: .safety) ?? SafetyConfig()
update = try container.decodeIfPresent(UpdateConfig.self, forKey: .update) ?? UpdateConfig()
sentry = try container.decodeIfPresent(SentryConfig.self, forKey: .sentry) ?? SentryConfig()
alerts = try container.decodeIfPresent([AlertConfig].self, forKey: .alerts) ?? []
}
}

struct UpdateConfig: Codable, Equatable, Sendable {
/// When true, the daemon contacts the GitHub releases API once a day to learn the
/// latest version, surfaced as a hint in the CLI. This is the only outbound network
/// request smctl makes; set to false for a fully offline daemon.
/// latest version, surfaced as a hint in the CLI. Set to false for a fully offline
/// daemon when Sentry and alert webhooks are also unconfigured.
var check: Bool

init(check: Bool = true) {
Expand All @@ -68,6 +72,31 @@ struct UpdateConfig: Codable, Equatable, Sendable {
}
}

struct SentryConfig: Codable, Equatable, Sendable {
/// Empty means disabled. The project DSN is intentionally config-owned so public
/// builds do not phone home unless the operator opts in.
var dsn: String
var environment: String
var debug: Bool
var traces_sample_rate: Double

init(dsn: String = "", environment: String = "production", debug: Bool = false, traces_sample_rate: Double = 0) {
self.dsn = dsn
self.environment = environment
self.debug = debug
self.traces_sample_rate = traces_sample_rate
}

init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
dsn = try container.decodeIfPresent(String.self, forKey: .dsn) ?? ""
environment = try container.decodeIfPresent(String.self, forKey: .environment) ?? "production"
debug = try container.decodeIfPresent(Bool.self, forKey: .debug) ?? false
let sampleRate = try container.decodeIfPresent(Double.self, forKey: .traces_sample_rate) ?? 0
traces_sample_rate = min(max(sampleRate, 0), 1)
}
}

struct BatteryConfig: Codable, Equatable, Sendable {
var limit: String
var sleep_policy: String
Expand Down Expand Up @@ -383,6 +412,7 @@ public final class SmctlDaemon: @unchecked Sendable {
queue.sync {
let wasForcing = config.battery.force_discharge
config = Self.loadConfig(path: configPath)
SentryReporter.startIfConfigured(config: config.sentry)
let policy = (try? SleepPolicy.parse(config.battery.sleep_policy)) ?? .strict
sleepMachine.setPolicy(policy)
if wasForcing, !config.battery.force_discharge, readAdapterEnabledLocked() == false {
Expand Down Expand Up @@ -515,6 +545,7 @@ public final class SmctlDaemon: @unchecked Sendable {
} catch {
lastError = String(describing: error)
logger.error("Sleep event handling failed: \(String(describing: error), privacy: .public)")
SentryReporter.capture(error, context: "Sleep event handling failed")
}
return evaluation
}
Expand Down Expand Up @@ -552,6 +583,7 @@ public final class SmctlDaemon: @unchecked Sendable {
lastEvaluation = now
lastError = String(describing: error)
logger.error("Battery policy evaluation failed: \(String(describing: error), privacy: .public)")
SentryReporter.capture(error, context: "Battery policy evaluation failed")
}
}

Expand Down Expand Up @@ -738,6 +770,7 @@ public final class SmctlDaemon: @unchecked Sendable {
} catch {
lastError = String(describing: error)
logger.error("Fan safety restore failed: \(String(describing: error), privacy: .public)")
SentryReporter.capture(error, context: "Fan safety restore failed")
}
return
}
Expand All @@ -755,6 +788,7 @@ public final class SmctlDaemon: @unchecked Sendable {
} catch {
lastError = String(describing: error)
logger.error("Fan policy evaluation failed: \(String(describing: error), privacy: .public)")
SentryReporter.capture(error, context: "Fan policy evaluation failed")
}
}

Expand Down Expand Up @@ -961,6 +995,7 @@ public final class SmctlDaemon: @unchecked Sendable {
// (e.g. Macs Fan Control); surface it — never swallow a failed restore.
lastError = String(describing: error)
logger.error("Startup fan restore failed (external fan controller running?): \(String(describing: error), privacy: .public)")
SentryReporter.capture(error, context: "Startup fan restore failed")
}
}
}
Expand Down Expand Up @@ -1110,6 +1145,12 @@ public final class SmctlDaemon: @unchecked Sendable {
[update]
check = \(config.update.check ? "true" : "false")

[sentry]
dsn = "\(config.sentry.dsn)"
environment = "\(config.sentry.environment)"
debug = \(config.sentry.debug ? "true" : "false")
traces_sample_rate = \(config.sentry.traces_sample_rate)

"""
for curve in config.fan.curves {
text += """
Expand Down
77 changes: 77 additions & 0 deletions Sources/SMCtlDaemonCore/SentryReporting.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import Foundation
import OSLog
import Sentry
import SMCtlProtocol

private let sentryLogger = Logger(subsystem: "one.leaper.smctl", category: "sentry")

enum SentryReporter {
private static let lock = NSLock()
private nonisolated(unsafe) static var started = false

static func startIfConfigured(config: SentryConfig) {
let environment = ProcessInfo.processInfo.environment
guard environment["SMCTL_SENTRY_DISABLED"] != "1" else { return }

let dsn = firstNonEmpty(environment["SMCTL_SENTRY_DSN"], config.dsn)

guard let dsn else { return }

lock.lock()
if started {
lock.unlock()
return
}
started = true
lock.unlock()

let sentryEnvironment = firstNonEmpty(environment["SMCTL_SENTRY_ENVIRONMENT"], config.environment) ?? "production"
let tracesSampleRate = min(max(config.traces_sample_rate, 0), 1)

SentrySDK.start { options in
options.dsn = dsn
options.environment = sentryEnvironment
options.releaseName = "smctl@\(SMCtlProtocolInfo.version)"
options.debug = config.debug
options.sendDefaultPii = false
options.tracesSampleRate = NSNumber(value: tracesSampleRate)
options.beforeSend = { event in
event.user = nil
event.serverName = nil
if var tags = event.tags {
tags.removeValue(forKey: "server_name")
tags.removeValue(forKey: "user")
event.tags = tags
}
return event
}
}

sentryLogger.notice("Sentry crash/error reporting enabled for environment \(sentryEnvironment, privacy: .public)")
}

static func capture(_ error: Error, context: String) {
guard isStarted else { return }
let report = NSError(
domain: "one.leaper.smctl.daemon",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "\(context): \(String(describing: error))"]
)
SentrySDK.capture(error: report)
}

private static var isStarted: Bool {
lock.lock()
defer { lock.unlock() }
return started
}

private static func firstNonEmpty(_ values: String?...) -> String? {
values
.map { $0?.trimmingCharacters(in: .whitespacesAndNewlines) }
.first { value in
guard let value else { return false }
return !value.isEmpty
} ?? nil
}
}
51 changes: 51 additions & 0 deletions Tests/SMCtlDaemonCoreTests/SmctlDaemonTests.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import Foundation
import Darwin
import XCTest
@testable import SMCtlDaemonCore
@testable import SMCCore
Expand All @@ -9,13 +10,15 @@ final class SmctlDaemonTests: XCTestCase {

override func setUp() {
super.setUp()
setenv("SMCTL_SENTRY_DISABLED", "1", 1)
configPath = NSTemporaryDirectory() + "smctl-test-\(UUID().uuidString).toml"
}

override func tearDown() {
if let configPath {
try? FileManager.default.removeItem(atPath: configPath)
}
unsetenv("SMCTL_SENTRY_DISABLED")
super.tearDown()
}

Expand Down Expand Up @@ -187,6 +190,54 @@ final class SmctlDaemonTests: XCTestCase {
XCTAssertEqual(alert?.rule?.trigger.kind, .temp)
}

func testSentryConfigDecodesFromToml() throws {
try """
[sentry]
dsn = "https://public@example.invalid/1"
environment = "staging"
debug = true
traces_sample_rate = 0.25
""".write(toFile: configPath, atomically: true, encoding: .utf8)

let daemon = makeDaemon(backend: RecordingBackend(), capabilities: .oneFan)
XCTAssertEqual(daemon.config.sentry.dsn, "https://public@example.invalid/1")
XCTAssertEqual(daemon.config.sentry.environment, "staging")
XCTAssertTrue(daemon.config.sentry.debug)
XCTAssertEqual(daemon.config.sentry.traces_sample_rate, 0.25)
}

func testSentrySampleRateIsClamped() throws {
try """
[sentry]
traces_sample_rate = 2.0
""".write(toFile: configPath, atomically: true, encoding: .utf8)

let daemon = makeDaemon(backend: RecordingBackend(), capabilities: .oneFan)
XCTAssertEqual(daemon.config.sentry.traces_sample_rate, 1)
}

func testWriteConfigPreservesSentry() throws {
try """
[battery]
limit = "80"

[sentry]
dsn = "https://public@example.invalid/1"
environment = "staging"
debug = true
traces_sample_rate = 0.5
""".write(toFile: configPath, atomically: true, encoding: .utf8)

let daemon = makeDaemon(backend: RecordingBackend(), capabilities: .oneFan)
try daemon.setChargeLimit("70-80")

let reloaded = makeDaemon(backend: RecordingBackend(), capabilities: .oneFan)
XCTAssertEqual(reloaded.config.sentry.dsn, "https://public@example.invalid/1")
XCTAssertEqual(reloaded.config.sentry.environment, "staging")
XCTAssertTrue(reloaded.config.sentry.debug)
XCTAssertEqual(reloaded.config.sentry.traces_sample_rate, 0.5)
}

func testWriteConfigPreservesAlerts() throws {
try """
[battery]
Expand Down
10 changes: 7 additions & 3 deletions docs/README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,19 +85,23 @@ Homebrew 安装会自动配置 shell 补全(bash/zsh/fish)和 `man smctl`

## 隐私

daemon 仅在两种情况下发起对外网络请求,且都由你掌控:
daemon 仅在以下情况下发起对外网络请求,且都由你掌控:

1. **更新检查**——每天一次查询 GitHub releases API 获取最新版本号,由 CLI 提示你升级。默认开启,可用 `[update] check = false` 关闭。
2. **告警 webhook**——只发往*你自己*在 `[[alert]]` 规则里配置的 URL。不配告警(默认)就没有这类请求。
3. **Sentry 崩溃/错误上报**——只有当你在 `[sentry]` 里配置 DSN 时才启用。默认空 DSN 会让 SDK 完全不启动。smctl 设置 `sendDefaultPii = false`,也不会启用性能追踪,除非你显式配置 `traces_sample_rate`。

无遥测、无统计。CLI 本身从不发起网络请求——所有对外流量都来自 daemon 的上述两种可开关情况。完全离线:在 `/etc/smctl/config.toml` 写入
无产品分析。CLI 本身从不发起网络请求——所有对外流量都来自 daemon 的上述可开关情况。完全离线:在 `/etc/smctl/config.toml` 写入

```toml
[update]
check = false

[sentry]
dsn = ""
```

然后 `sudo smctl daemon restart`,并且不配置任何 webhook 告警。
然后 `sudo smctl daemon restart`,并且不配置任何 webhook 告警。也可以用 `SMCTL_SENTRY_DISABLED=1` 在 daemon 进程层硬关闭 Sentry。

## 告警

Expand Down
Loading
Loading