feat(display): 添加显示器信息与 DDC 控制

This commit is contained in:
ysicing authored and 缘生 committed 2026-10-04 21:52:14 +08:00
1 parent c21a3cbaa1
commit 9707abba1d
27 files changed
+1108 -28

No files matched your search

+32
View File
@@ -594,3 +594,35 @@ placeholder checks accompany the localization tests.
`--snapshot <dir>` renders every main-window page, popover, settings section and the menu bar in light and
dark, through real `NSHostingView`s in off-screen windows — `ImageRenderer` washes out pages
that contain bitmaps.
## Display information and DDC/CI
`DisplayController` caches the public NSScreen/CoreGraphics display inventory and refreshes it on
screen-configuration events. The optional display menu item shows the count; the same information
and control view is available in This Mac. DDC polling runs only while that control view is visible,
at a five-second interval, and stops on close, lock or sleep. Dragging a slider defers polling; release
submits one target value. Wake and reconfiguration settling gates also block manual reads and writes.
`NativeDisplayDDC` resolves IOAVService and CoreDisplay entry points at runtime for the direct macOS
build. The information view remains usable if these entry points are unavailable. These non-public
control calls are not a Store-target compatibility claim; a future sandboxed target must exclude this
backend. Services are matched by the CoreDisplay registry location, not by a guessed model name;
ambiguous/missing matches disable controls. Device signatures and controller generations reject
stale requests after reconnects. No brightness/volume/contrast values are automatically restored.
Only standard brightness (0x10), contrast (0x12), and speaker volume (0x62) are supported. An intact
Get VCP reply and nonzero range enable each control independently. Unsupported replies, invalid
frames, timeouts, busy transport, and unconfirmed writes remain distinct. Every set rechecks the
range and connection and reads the value back; a successful I2C send is never presented as proof
that the display applied it. No software dimming or speculative compatibility writes are performed.
Blocking native work uses one bounded serial execution slot with a two-second caller deadline.
A timed-out kernel call retains the slot until it returns, so retries cannot accumulate stuck work.
Cancellation is checked between bus operations; late results cannot repopulate a closed or replaced
view. Protocol, timeout, cancellation and write-confirmation tests use simulated devices. To generate
a repeatable, read-only report on actual hardware (no Set VCP):
```bash
XSTATS_DDC_READ_REPORT=/tmp/xstats-displays.json \
swift test --package-path Packages/XStatsKit --filter DisplayHardwareReadTests
```
+1
View File
@@ -34,6 +34,7 @@ task clean # 删除 build/
菜单栏布局走查可对当前构建运行 `XStats --snapshot <目录> --menubar-only --language en`;
仅采集相关指标,输出三种布局、总览和详情的明暗截图,不扫描清理目录或 AI 日志。
显示器控制页面可改用 `--display-only`,只读本机信息和 DDC 能力,不修改硬件设置。
`project.yml` 是 Xcode 工程配置来源,调整 target、entitlement 或签名设置后重新生成工程。
修改 `CHANGELOG.md` 后运行 `python3 scripts/sync_changelog.py`,同步 README 版本摘要与活跃度图。
@@ -1657,3 +1657,19 @@ Token 用量 الرموز
正在启动风扇… جارٍ تشغيل المراوح…
打开进程监控 فتح مراقب العمليات
IP 归属地 موقع عنوان IP
显示器信息与支持的亮度、音量、对比度控制 معلومات الشاشة وعناصر التحكم المدعومة للسطوع والصوت والتباين
显示器 {} 台 {} شاشات
重新检测显示器控制 إعادة اكتشاف عناصر التحكم بالشاشة
内置显示器请在系统设置中调节 اضبط الشاشة المدمجة في إعدادات النظام
松开滑杆后应用到显示器 اترك شريط التمرير لتطبيق الإعداد على الشاشة
正在检测… جارٍ الاكتشاف…
显示器不支持此控制 هذه الشاشة لا تدعم هذا التحكم
暂时无法读取,请检查 DDC/CI 与连接 تعذرت القراءة؛ تحقق من DDC/CI والاتصال
显示器响应超时 انتهت مهلة استجابة الشاشة
显示器通信繁忙 الاتصال بالشاشة مشغول
修改未确认,请重新检测 لم يتم تأكيد التغيير؛ أعد الاكتشاف
尚未检测 لم يُكتشف بعد
亮度 السطوع
音量 مستوى الصوت
显示链路正在恢复… جارٍ استعادة اتصال الشاشة…
对比度 التباين
1 # Copyright (C) 2026 ysicing
1657 正在启动风扇…
1658 打开进程监控
1659 IP 归属地
1660 显示器信息与支持的亮度、音量、对比度控制
1661 显示器 {} 台
1662 重新检测显示器控制
1663 内置显示器请在系统设置中调节
1664 松开滑杆后应用到显示器
1665 正在检测…
1666 显示器不支持此控制
1667 暂时无法读取,请检查 DDC/CI 与连接
1668 显示器响应超时
1669 显示器通信繁忙
1670 修改未确认,请重新检测
1671 尚未检测
1672 亮度
1673 音量
1674 显示链路正在恢复…
1675 对比度
@@ -1657,3 +1657,19 @@ Token 用量 Tokens
正在启动风扇… Lüfter starten…
打开进程监控 Prozessmonitor öffnen
IP 归属地 IP-Standort
显示器信息与支持的亮度、音量、对比度控制 Displayinformationen und unterstützte Helligkeits-, Lautstärke- und Kontrastregler
显示器 {} 台 {} Displays
重新检测显示器控制 Displaysteuerung erneut erkennen
内置显示器请在系统设置中调节 Das integrierte Display in den Systemeinstellungen anpassen
松开滑杆后应用到显示器 Regler loslassen, um die Einstellung anzuwenden
正在检测… Wird erkannt…
显示器不支持此控制 Von diesem Display nicht unterstützt
暂时无法读取,请检查 DDC/CI 与连接 Derzeit nicht lesbar; DDC/CI und Verbindung prüfen
显示器响应超时 Zeitüberschreitung der Displayantwort
显示器通信繁忙 Displaykommunikation ist ausgelastet
修改未确认,请重新检测 Änderung nicht bestätigt; erneut erkennen
尚未检测 Noch nicht erkannt
亮度 Helligkeit
音量 Lautstärke
显示链路正在恢复… Displayverbindung wird wiederhergestellt…
对比度 Kontrast
1 # Copyright (C) 2026 ysicing
1657 正在启动风扇…
1658 打开进程监控
1659 IP 归属地
1660 显示器信息与支持的亮度、音量、对比度控制
1661 显示器 {} 台
1662 重新检测显示器控制
1663 内置显示器请在系统设置中调节
1664 松开滑杆后应用到显示器
1665 正在检测…
1666 显示器不支持此控制
1667 暂时无法读取,请检查 DDC/CI 与连接
1668 显示器响应超时
1669 显示器通信繁忙
1670 修改未确认,请重新检测
1671 尚未检测
1672 亮度
1673 音量
1674 显示链路正在恢复…
1675 对比度
@@ -1657,3 +1657,19 @@ Token 用量 Tokens
正在启动风扇… Iniciando ventiladores…
打开进程监控 Abrir monitor de procesos
IP 归属地 Ubicación IP
显示器信息与支持的亮度、音量、对比度控制 Información de pantalla y controles compatibles de brillo, volumen y contraste
显示器 {} 台 {} pantallas
重新检测显示器控制 Detectar de nuevo los controles de pantalla
内置显示器请在系统设置中调节 Ajusta la pantalla integrada en Ajustes del Sistema
松开滑杆后应用到显示器 Suelta el deslizador para aplicar el ajuste a la pantalla
正在检测… Detectando…
显示器不支持此控制 Esta pantalla no admite este control
暂时无法读取,请检查 DDC/CI 与连接 No se puede leer; comprueba DDC/CI y la conexión
显示器响应超时 Se agotó el tiempo de respuesta de la pantalla
显示器通信繁忙 La comunicación con la pantalla está ocupada
修改未确认,请重新检测 Cambio sin confirmar; vuelve a detectar
尚未检测 Aún no detectado
亮度 Brillo
音量 Volumen
显示链路正在恢复… Restableciendo la conexión de pantalla…
对比度 Contraste
1 # Copyright (C) 2026 ysicing
1657 正在启动风扇…
1658 打开进程监控
1659 IP 归属地
1660 显示器信息与支持的亮度、音量、对比度控制
1661 显示器 {} 台
1662 重新检测显示器控制
1663 内置显示器请在系统设置中调节
1664 松开滑杆后应用到显示器
1665 正在检测…
1666 显示器不支持此控制
1667 暂时无法读取,请检查 DDC/CI 与连接
1668 显示器响应超时
1669 显示器通信繁忙
1670 修改未确认,请重新检测
1671 尚未检测
1672 亮度
1673 音量
1674 显示链路正在恢复…
1675 对比度
@@ -1657,3 +1657,19 @@ Token 用量 Tokens
正在启动风扇… Démarrage des ventilateurs…
打开进程监控 Ouvrir le moniteur de processus
IP 归属地 Localisation IP
显示器信息与支持的亮度、音量、对比度控制 Informations de l’écran et réglages pris en charge : luminosité, volume et contraste
显示器 {} 台 {} écrans
重新检测显示器控制 Détecter à nouveau les commandes de l’écran
内置显示器请在系统设置中调节 Réglez l’écran intégré dans Réglages Système
松开滑杆后应用到显示器 Relâchez le curseur pour appliquer le réglage à l’écran
正在检测… Détection…
显示器不支持此控制 Non pris en charge par cet écran
暂时无法读取,请检查 DDC/CI 与连接 Lecture indisponible ; vérifiez DDC/CI et la connexion
显示器响应超时 Délai de réponse de l’écran dépassé
显示器通信繁忙 Communication avec l’écran occupée
修改未确认,请重新检测 Modification non confirmée ; relancez la détection
尚未检测 Pas encore détecté
亮度 Luminosité
音量 Volume
显示链路正在恢复… Rétablissement de la connexion à l’écran…
对比度 Contraste
1 # Copyright (C) 2026 ysicing
1657 正在启动风扇…
1658 打开进程监控
1659 IP 归属地
1660 显示器信息与支持的亮度、音量、对比度控制
1661 显示器 {} 台
1662 重新检测显示器控制
1663 内置显示器请在系统设置中调节
1664 松开滑杆后应用到显示器
1665 正在检测…
1666 显示器不支持此控制
1667 暂时无法读取,请检查 DDC/CI 与连接
1668 显示器响应超时
1669 显示器通信繁忙
1670 修改未确认,请重新检测
1671 尚未检测
1672 亮度
1673 音量
1674 显示链路正在恢复…
1675 对比度
@@ -1657,3 +1657,19 @@ Token 用量 トークン
正在启动风扇… ファンを起動中…
打开进程监控 プロセスモニターを開く
IP 归属地 IPの所在地
显示器信息与支持的亮度、音量、对比度控制 ディスプレイ情報と対応する明るさ・音量・コントラストの調整
显示器 {} 台 ディスプレイ {} 台
重新检测显示器控制 ディスプレイの制御を再検出
内置显示器请在系统设置中调节 内蔵ディスプレイはシステム設定で調整してください
松开滑杆后应用到显示器 スライダーを離すとディスプレイに適用します
正在检测… 検出中…
显示器不支持此控制 このディスプレイでは非対応です
暂时无法读取,请检查 DDC/CI 与连接 読み取れません。DDC/CIと接続を確認してください
显示器响应超时 ディスプレイの応答がタイムアウトしました
显示器通信繁忙 ディスプレイとの通信中です
修改未确认,请重新检测 変更を確認できません。再検出してください
尚未检测 未検出
亮度 明るさ
音量 音量
显示链路正在恢复… ディスプレイの接続を復旧中…
对比度 コントラスト
1 # Copyright (C) 2026 ysicing
1657 正在启动风扇…
1658 打开进程监控
1659 IP 归属地
1660 显示器信息与支持的亮度、音量、对比度控制
1661 显示器 {} 台
1662 重新检测显示器控制
1663 内置显示器请在系统设置中调节
1664 松开滑杆后应用到显示器
1665 正在检测…
1666 显示器不支持此控制
1667 暂时无法读取,请检查 DDC/CI 与连接
1668 显示器响应超时
1669 显示器通信繁忙
1670 修改未确认,请重新检测
1671 尚未检测
1672 亮度
1673 音量
1674 显示链路正在恢复…
1675 对比度
@@ -1657,3 +1657,19 @@ Token 用量 토큰
正在启动风扇… 팬 시작 중…
打开进程监控 프로세스 모니터 열기
IP 归属地 IP 위치
显示器信息与支持的亮度、音量、对比度控制 디스플레이 정보 및 지원되는 밝기, 음량, 명암 조절
显示器 {} 台 디스플레이 {}대
重新检测显示器控制 디스플레이 제어 다시 감지
内置显示器请在系统设置中调节 내장 디스플레이는 시스템 설정에서 조절하세요
松开滑杆后应用到显示器 슬라이더를 놓으면 디스플레이에 적용됩니다
正在检测… 감지 중…
显示器不支持此控制 이 디스플레이에서 지원하지 않습니다
暂时无法读取,请检查 DDC/CI 与连接 읽을 수 없습니다. DDC/CI와 연결을 확인하세요
显示器响应超时 디스플레이 응답 시간 초과
显示器通信繁忙 디스플레이 통신 중
修改未确认,请重新检测 변경이 확인되지 않았습니다. 다시 감지하세요
尚未检测 아직 감지되지 않음
亮度 밝기
音量 음량
显示链路正在恢复… 디스플레이 연결 복구 중…
对比度 명암
1 # Copyright (C) 2026 ysicing
1657 正在启动风扇…
1658 打开进程监控
1659 IP 归属地
1660 显示器信息与支持的亮度、音量、对比度控制
1661 显示器 {} 台
1662 重新检测显示器控制
1663 内置显示器请在系统设置中调节
1664 松开滑杆后应用到显示器
1665 正在检测…
1666 显示器不支持此控制
1667 暂时无法读取,请检查 DDC/CI 与连接
1668 显示器响应超时
1669 显示器通信繁忙
1670 修改未确认,请重新检测
1671 尚未检测
1672 亮度
1673 音量
1674 显示链路正在恢复…
1675 对比度
@@ -1658,3 +1658,19 @@ Token 用量 Token 用量
正在启动风扇… 正在啟動風扇…
打开进程监控 開啟程序監控
IP 归属地 IP 歸屬地
显示器信息与支持的亮度、音量、对比度控制 顯示器資訊與支援的亮度、音量、對比度控制
显示器 {} 台 顯示器 {} 台
重新检测显示器控制 重新偵測顯示器控制
内置显示器请在系统设置中调节 內建顯示器請在系統設定中調節
松开滑杆后应用到显示器 放開滑桿後套用至顯示器
正在检测… 正在偵測…
显示器不支持此控制 顯示器不支援此控制
暂时无法读取,请检查 DDC/CI 与连接 暫時無法讀取,請檢查 DDC/CI 與連線
显示器响应超时 顯示器回應逾時
显示器通信繁忙 顯示器通訊忙碌
修改未确认,请重新检测 修改未確認,請重新偵測
尚未检测 尚未偵測
亮度 亮度
音量 音量
显示链路正在恢复… 顯示連線正在恢復…
对比度 對比度
1 # Copyright (c) 2026 GiantAccel, LLC
1658 正在启动风扇…
1659 打开进程监控
1660 IP 归属地
1661 显示器信息与支持的亮度、音量、对比度控制
1662 显示器 {} 台
1663 重新检测显示器控制
1664 内置显示器请在系统设置中调节
1665 松开滑杆后应用到显示器
1666 正在检测…
1667 显示器不支持此控制
1668 暂时无法读取,请检查 DDC/CI 与连接
1669 显示器响应超时
1670 显示器通信繁忙
1671 修改未确认,请重新检测
1672 尚未检测
1673 亮度
1674 音量
1675 显示链路正在恢复…
1676 对比度
@@ -1469,5 +1469,21 @@ Homebrew 将按执行时的状态清理旧版本与缓存,不自动移除依
正在启动风扇… Starting fans…
打开进程监控 Open process monitor
IP 归属地 IP location
显示器信息与支持的亮度、音量、对比度控制 Display information and supported brightness, volume and contrast controls
显示器 {} 台 {} displays
重新检测显示器控制 Detect display controls again
内置显示器请在系统设置中调节 Adjust the built-in display in System Settings
松开滑杆后应用到显示器 Release the slider to apply the setting to the display
正在检测… Detecting…
显示器不支持此控制 Not supported by this display
暂时无法读取,请检查 DDC/CI 与连接 Cannot read now; check DDC/CI and the connection
显示器响应超时 Display response timed out
显示器通信繁忙 Display communication is busy
修改未确认,请重新检测 Change not confirmed; detect again
尚未检测 Not yet detected
亮度 Brightness
对比度 Contrast
音量 Volume
显示链路正在恢复… Display connection is recovering…
"""#
}
@@ -68,6 +68,7 @@ public final class AppController: NSObject, NSApplicationDelegate {
self?.restWindows.showHUD()
self?.mainWindow.close()
}
model.displays.start()
menuBar.update()
calendarMenuBar.start()
@@ -198,6 +199,7 @@ public final class AppController: NSObject, NSApplicationDelegate {
let appModel = model
appModel.bluetooth.setDemand(appModel.bluetoothDemand)
appModel.displays.setVisible(appModel.displayControlsVisible)
Task { [weak self] in
await appModel.hub.update(appModel.demand)
await appModel.hub.start { [weak self] snapshot in
@@ -212,6 +214,7 @@ public final class AppController: NSObject, NSApplicationDelegate {
rest.prepareForTermination()
widgetTimer?.invalidate()
calendarMenuBar.stop()
model.displays.stop()
model.aiUsage.stop()
model.keepAwake.releaseForTermination()
if model.fans.mode != .automatic || model.keepAwake.lidClosedActive {
@@ -301,6 +304,8 @@ public final class AppController: NSObject, NSApplicationDelegate {
withObservationTracking {
_ = model.demand
_ = model.bluetoothDemand
_ = model.displayControlsVisible
_ = model.displays.catalog
_ = model.settings.menuBarItems
_ = model.settings.menuBarLayout
_ = model.settings.menuBarStyle
@@ -330,6 +335,7 @@ public final class AppController: NSObject, NSApplicationDelegate {
self.model.alerts.applySettings()
await self.model.hub.update(self.model.demand)
self.model.bluetooth.setDemand(self.model.bluetoothDemand)
self.model.displays.setVisible(self.model.displayControlsVisible)
self.applyAppearance()
self.updateActivationPolicy()
self.menuBar.update()
@@ -513,6 +519,7 @@ public final class AppController: NSObject, NSApplicationDelegate {
return
}
appliedLanguage = language
model.displays.refreshCatalog()
NSApp.mainMenu = MainMenu.make(target: self, settingsAction: #selector(openSettingsFromMenu),
updateAction: #selector(checkForUpdatesFromMenu))
menuBar.dismissPopovers()
@@ -578,6 +585,7 @@ public final class AppController: NSObject, NSApplicationDelegate {
menuBar.dismissPopovers()
model.network.setPaused(true)
model.aiUsage.setPaused(true)
model.displays.setPaused(true)
model.history.flush()
restWindows.hideRest()
rest.suspend()
@@ -589,6 +597,7 @@ public final class AppController: NSObject, NSApplicationDelegate {
guard !screenLocked else { return }
model.network.setPaused(false)
model.aiUsage.setPaused(false)
model.displays.setPaused(false)
rest.sync()
if rest.phase.isResting && rest.isRunning { restWindows.ensureRestVisible() }
Task {
@@ -28,6 +28,7 @@ struct MenuBarQuota {
/// 菜单栏上要画的读数,与数据来源解耦,设置页预览可以用示例数据绘制
struct MenuBarReading {
var displayCount = 0
var cpu: Double?
var cpuHistory: [Double] = []
var gpu: Double?
@@ -67,6 +68,7 @@ struct MenuBarReading {
let store = model.store
for item in items {
switch item {
case .display: displayCount = model.displays.catalog.count
case .cpu:
cpu = store.cpu?.total
cpuHistory = store.cpuTotal.elements
@@ -163,6 +165,7 @@ struct MenuBarReading {
func hasSameImage(as previous: Self, item: MenuBarItem, style: MenuBarStyle) -> Bool {
guard keepAwake == previous.keepAwake else { return false }
switch item {
case .display: return displayCount == previous.displayCount
case .cpu, .gpu, .memory, .disk:
let current = percent(item), old = previous.percent(item)
return current.value == old.value
@@ -195,6 +198,7 @@ struct MenuBarReading {
func tooltip(items: [MenuBarItem], fahrenheit: Bool) -> String {
items.compactMap { item -> String? in
switch item {
case .display: tr("显示器 \(displayCount.formatted(.number.locale(L10n.locale))) 台")
case .cpu: cpu.map { "CPU \(Format.percent($0))" }
case .gpu: gpu.map { "GPU \(Format.percent($0))" }
case .memory: memory.map { tr("内存 \(Format.percent($0))") }
@@ -368,6 +372,8 @@ enum MenuBarRenderer {
return "\(Int(degrees.rounded()))°"
} ?? "—"
return textSegment(item: item, value: text, sample: "100°", style: style)
case .display:
return textSegment(item: item, value: reading.displayCount.formatted(.number.locale(L10n.locale)), sample: "8", style: style)
case .fan:
return textSegment(item: item, value: reading.fanRPM.map { "\(Int($0))" } ?? "—", sample: "8888", style: style)
case .battery:
@@ -0,0 +1,103 @@
// Copyright (C) 2026 ysicing
// SPDX-License-Identifier: AGPL-3.0-or-later
import Foundation
/// 仅实现本期需要的 MCCS 连续值控制,不扫描或写入未知 VCP 码。
enum DisplayControl: UInt8, CaseIterable, Sendable {
case brightness = 0x10
case contrast = 0x12
case volume = 0x62
}
struct DDCValue: Equatable, Sendable {
let current: UInt16
let maximum: UInt16
var percent: Double { Double(current) / Double(maximum) * 100 }
func rawValue(for percent: Double) -> UInt16? {
guard percent.isFinite, (0...100).contains(percent), maximum > 0 else { return nil }
return UInt16((percent / 100 * Double(maximum)).rounded())
}
}
enum DDCResult: Equatable, Sendable {
case value(DDCValue)
case unsupported
case unavailable
case timedOut
case busy
case cancelled
case unconfirmed(DDCValue?)
}
struct DisplayTarget: Hashable, Sendable {
let id: UInt32
/// CG 显示器编号会复用,用设备签名与注册表位置校验同一连接,避免热插拔后写到另一块屏。
let identity: String
}
enum DDCProtocol {
static func get(_ control: DisplayControl) -> [UInt8] {
packet(payload: [0x01, control.rawValue])
}
static func set(_ control: DisplayControl, value: UInt16) -> [UInt8] {
packet(payload: [0x03, control.rawValue, UInt8(value >> 8), UInt8(value & 0xff)])
}
private static func packet(payload: [UInt8]) -> [UInt8] {
let bytes = [0x80 | UInt8(payload.count)] + payload
return bytes + [bytes.reduce(UInt8(0x6e ^ 0x51), ^)]
}
/// Get VCP Feature Reply:只接受完整帧、对应控制码及非零范围;坏帧不是“不支持”。
static func parse(_ bytes: [UInt8], control: DisplayControl) -> DDCResult {
guard bytes.count == 11, bytes[0] == 0x6e, bytes[1] == 0x88,
bytes[2] == 0x02, bytes[4] == control.rawValue,
bytes.reduce(UInt8(0x50), ^) == 0 else { return .unavailable }
if bytes[3] == 1 { return .unsupported }
guard bytes[3] == 0, bytes[5] == 0 else { return .unavailable }
let maximum = UInt16(bytes[6]) << 8 | UInt16(bytes[7])
let current = UInt16(bytes[8]) << 8 | UInt16(bytes[9])
guard maximum > 0, current <= maximum else { return .unavailable }
return .value(DDCValue(current: current, maximum: maximum))
}
}
/// 同步内核调用不可强制取消;每个请求在总线操作前后检查本令牌。
final class DDCCancellation: @unchecked Sendable {
private let lock = NSLock()
private var cancelled = false
var isCancelled: Bool { lock.withLock { cancelled } }
func cancel() { lock.withLock { cancelled = true } }
}
protocol DisplayDDCBackend: Sendable {
func read(_ target: DisplayTarget, cancellation: DDCCancellation) async -> [DisplayControl: DDCResult]
func write(_ target: DisplayTarget, control: DisplayControl, percent: Double,
cancellation: DDCCancellation) async -> DDCResult
}
/// 写前重新确认范围,写后读取真实值;发送成功本身不等于显示器已应用。
/// 闭包在同一串行工作线程同步执行,便于独立验证错误路径而不改动真实显示器。
enum DDCWriteTransaction {
static func apply(percent: Double, cancellation: DDCCancellation, isCurrent: () -> Bool,
read: () -> DDCResult, send: (UInt16) -> Bool) -> DDCResult {
guard percent.isFinite, (0...100).contains(percent) else { return .unavailable }
guard !cancellation.isCancelled, isCurrent() else { return .cancelled }
let before = read()
guard case .value(let value) = before else { return before }
guard let raw = value.rawValue(for: percent) else { return .unavailable }
guard !cancellation.isCancelled, isCurrent() else { return .cancelled }
guard send(raw) else { return .unavailable }
Thread.sleep(forTimeInterval: 0.05)
guard !cancellation.isCancelled, isCurrent() else { return .cancelled }
guard case .value(let confirmed) = read() else { return .unconfirmed(nil) }
let tolerance = max(1, Int(confirmed.maximum) / 100)
guard confirmed.maximum == value.maximum,
abs(Int(confirmed.current) - Int(raw)) <= tolerance else { return .unconfirmed(confirmed) }
return .value(confirmed)
}
}
@@ -0,0 +1,166 @@
// Copyright (C) 2026 ysicing
// SPDX-License-Identifier: AGPL-3.0-or-later
import AppKit
import Foundation
import Observation
/// 显示器信息由系统事件刷新;仅控制界面可见时读取 DDC,不将滑杆值自动恢复到硬件。
@MainActor
@Observable
final class DisplayController {
private(set) var catalog: [DisplayInfo]
private(set) var readings: [UInt32: [DisplayControl: DDCResult]] = [:]
private(set) var isRefreshing = false
private(set) var isWriting = false
private(set) var isPaused = false
private var settlingUntil: ContinuousClock.Instant?
var isSettling: Bool { settlingUntil != nil }
@ObservationIgnored private let backend: any DisplayDDCBackend
@ObservationIgnored private var visible = false
@ObservationIgnored private var generation = UUID()
@ObservationIgnored private var cancellation: DDCCancellation?
@ObservationIgnored private var loop: Task<Void, Never>?
@ObservationIgnored private var editingTokens: Set<UUID> = []
@ObservationIgnored private var screenObserver: NSObjectProtocol?
init(backend: any DisplayDDCBackend = NativeDisplayDDC(), catalog: [DisplayInfo] = []) {
self.backend = backend
self.catalog = catalog
}
func start() {
guard screenObserver == nil else { return }
refreshCatalog()
screenObserver = NotificationCenter.default.addObserver(forName: NSApplication.didChangeScreenParametersNotification,
object: nil, queue: .main) { [weak self] _ in
MainActor.assumeIsolated { self?.screenConfigurationChanged() }
}
}
func stop() {
visible = false
invalidate()
if let screenObserver { NotificationCenter.default.removeObserver(screenObserver) }
screenObserver = nil
}
func refreshCatalog() {
let updated = DisplayInfo.all()
let connectionsChanged = catalog.map(\.target) != updated.map(\.target)
if connectionsChanged {
invalidate()
readings = [:]
}
if updated != catalog { catalog = updated }
if connectionsChanged && visible && !isPaused {
deferCommunication(for: .seconds(1))
restart()
}
}
private func screenConfigurationChanged() {
invalidate()
readings = [:]
refreshCatalog()
deferCommunication(for: .seconds(1))
restart()
}
func setVisible(_ value: Bool) {
guard visible != value else { return }
visible = value
invalidate()
restart()
}
func setPaused(_ value: Bool) {
guard isPaused != value else { return }
isPaused = value
invalidate()
if !value {
refreshCatalog()
// 唤醒后让显示链路先稳定,期间不发送 DDC 报文。
deferCommunication(for: .seconds(3))
restart()
}
}
private func invalidate() {
generation = UUID()
loop?.cancel()
loop = nil
cancellation?.cancel()
cancellation = nil
isRefreshing = false
isWriting = false
editingTokens.removeAll()
}
private func deferCommunication(for delay: Duration) {
let deadline = ContinuousClock.now.advanced(by: delay)
settlingUntil = max(settlingUntil ?? deadline, deadline)
}
private func restart() {
loop?.cancel()
loop = nil
guard visible, !isPaused else { return }
let epoch = generation
let deadline = settlingUntil
loop = Task { [weak self] in
if let deadline {
do { try await Task.sleep(until: deadline, clock: .continuous) } catch { return }
}
guard !Task.isCancelled, self?.generation == epoch else { return }
self?.settlingUntil = nil
while !Task.isCancelled {
guard self != nil else { return }
await self?.refresh()
do { try await Task.sleep(for: .seconds(5), tolerance: .seconds(1)) } catch { return }
}
}
}
func beginEditing(_ token: UUID) { editingTokens.insert(token) }
func endEditing(_ token: UUID) { editingTokens.remove(token) }
func refresh() async {
guard visible, !isPaused, !isSettling, !Task.isCancelled, !isRefreshing, !isWriting, editingTokens.isEmpty else { return }
let epoch = generation
let ticket = DDCCancellation()
cancellation = ticket
isRefreshing = true
defer { if generation == epoch { isRefreshing = false; cancellation = nil } }
var updated: [UInt32: [DisplayControl: DDCResult]] = [:]
for display in catalog where !display.isBuiltIn {
let result = await withTaskCancellationHandler {
await backend.read(display.target, cancellation: ticket)
} onCancel: { ticket.cancel() }
guard generation == epoch, !Task.isCancelled else { return }
updated[display.id] = result
}
guard generation == epoch else { return }
readings = updated
isRefreshing = false
cancellation = nil
}
func write(_ percent: Double, control: DisplayControl, display: DisplayInfo) async {
guard visible, !isPaused, !isSettling, !Task.isCancelled, !isRefreshing, !isWriting,
catalog.contains(where: { $0.target == display.target }),
case .value = readings[display.id]?[control], percent.isFinite, (0...100).contains(percent) else { return }
let epoch = generation
let ticket = DDCCancellation()
cancellation = ticket
isWriting = true
defer { if generation == epoch { isWriting = false; cancellation = nil } }
let result = await withTaskCancellationHandler {
await backend.write(display.target, control: control, percent: percent, cancellation: ticket)
} onCancel: { ticket.cancel() }
guard generation == epoch, !Task.isCancelled else { return }
readings[display.id, default: [:]][control] = result
isWriting = false
cancellation = nil
}
}
@@ -0,0 +1,123 @@
// Copyright (C) 2026 ysicing
// SPDX-License-Identifier: AGPL-3.0-or-later
import Localization
import SwiftUI
struct DisplayDetails: View {
@Environment(AppModel.self) private var model
var body: some View {
SectionCard(title: tr("显示器"), trailing: {
RefreshButton(loading: model.displays.isRefreshing, help: tr("重新检测显示器控制")) {
Task { await model.displays.refresh() }
}
}) {
if model.displays.catalog.isEmpty {
Text(tr("没有检测到显示器")).dsFont(.sm).foregroundStyle(DS.Palette.textSecondary)
}
ForEach(model.displays.catalog) { display in
VStack(alignment: .leading, spacing: DS.Space.s2) {
HStack {
Label(display.name, systemImage: display.isBuiltIn ? "laptopcomputer" : "display")
.dsFont(.sm, weight: .semibold)
Spacer(minLength: DS.Space.s1)
if display.isMain { Chip(text: tr("主显示器"), tone: .primary) }
}
Text(verbatim: display.summary).dsFont(.xs).foregroundStyle(DS.Palette.textSecondary)
.fixedSize(horizontal: false, vertical: true)
if display.isBuiltIn {
Text(tr("内置显示器请在系统设置中调节")).dsFont(.xs).foregroundStyle(DS.Palette.textSecondary)
} else {
ForEach(DisplayControl.allCases, id: \.self) { control in
DisplayControlRow(display: display, control: control,
result: model.displays.readings[display.id]?[control])
}
}
}
.padding(.vertical, DS.Space.s2)
}
}
}
}
private struct DisplayControlRow: View {
let display: DisplayInfo
let control: DisplayControl
let result: DDCResult?
@Environment(AppModel.self) private var model
@State private var draft: Double?
@State private var submitting = false
@State private var editing = false
@State private var editingToken = UUID()
private var value: DDCValue? {
if case .value(let value) = result { return value }
return nil
}
var body: some View {
VStack(alignment: .leading, spacing: DS.Space.s1) {
HStack {
Text(control.title).dsFont(.sm)
Spacer()
if let value {
Text(verbatim: ((editing || submitting) ? (draft ?? value.percent) : value.percent).formatted(.number.precision(.fractionLength(0)).locale(L10n.locale)) + "%")
.dsFont(.sm, weight: .medium).monospacedDigit()
} else {
Text(status).dsFont(.xs).foregroundStyle(DS.Palette.textSecondary)
}
}
if value != nil {
Slider(value: Binding(get: { draft ?? value?.percent ?? 0 }, set: { draft = $0 }), in: 0...100, onEditingChanged: { active in
editing = active
if active { model.displays.beginEditing(editingToken) }
else {
let proposed = draft ?? value?.percent ?? 0
submitting = true
Task {
await model.displays.write(proposed, control: control, display: display)
model.displays.endEditing(editingToken)
submitting = false
draft = nil
}
}
})
.controlSize(.small)
.tint(DS.Palette.primary)
.disabled(submitting || model.displays.isRefreshing || model.displays.isWriting || model.displays.isPaused || model.displays.isSettling)
.accessibilityLabel(control.title)
.help(tr("松开滑杆后应用到显示器"))
.onChange(of: value?.percent) { _, percent in
if !editing && !submitting, percent != nil { draft = nil }
}
.onDisappear { model.displays.endEditing(editingToken) }
}
}
}
private var status: String {
if model.displays.isPaused { return tr("已暂停") }
if model.displays.isSettling { return tr("显示链路正在恢复…") }
if model.displays.isRefreshing { return tr("正在检测…") }
switch result {
case .unsupported: return tr("显示器不支持此控制")
case .unavailable: return tr("暂时无法读取,请检查 DDC/CI 与连接")
case .timedOut: return tr("显示器响应超时")
case .busy: return tr("显示器通信繁忙")
case .unconfirmed: return tr("修改未确认,请重新检测")
case .cancelled, .none: return tr("尚未检测")
case .value: return ""
}
}
}
extension DisplayControl {
var title: String {
switch self {
case .brightness: tr("亮度")
case .contrast: tr("对比度")
case .volume: tr("音量")
}
}
}
@@ -0,0 +1,180 @@
// Copyright (C) 2026 ysicing
// SPDX-License-Identifier: AGPL-3.0-or-later
import CoreGraphics
import Foundation
import IOKit
import Darwin
/// Apple Silicon 的 DDC/CI 传输。CF 服务只在串行 I/O 队列内创建、使用和释放。
/// 不提供软件调光或任意 VCP 写入;系统缺少 IOAV 符号时保持信息可读、控制不可用。
final class NativeDisplayDDC: DisplayDDCBackend, @unchecked Sendable {
private let queue = DispatchQueue(label: "work.12306.xstats.display-ddc", qos: .userInitiated)
private let lock = NSLock()
private var running = false
func read(_ target: DisplayTarget, cancellation: DDCCancellation) async -> [DisplayControl: DDCResult] {
let failed = Dictionary(uniqueKeysWithValues: DisplayControl.allCases.map { ($0, DDCResult.unavailable) })
return await execute(timeout: failed.mapValues { _ in .timedOut }, busy: failed.mapValues { _ in .busy },
cancellation: cancellation) {
guard !cancellation.isCancelled, let api = Functions.shared,
let service = Self.service(for: target, api: api) else { return failed }
var values: [DisplayControl: DDCResult] = [:]
for control in DisplayControl.allCases {
guard !cancellation.isCancelled else { values[control] = .cancelled; continue }
values[control] = Self.read(service, control: control, api: api, cancellation: cancellation)
}
return values
}
}
func write(_ target: DisplayTarget, control: DisplayControl, percent: Double,
cancellation: DDCCancellation) async -> DDCResult {
guard percent.isFinite, (0...100).contains(percent) else { return .unavailable }
return await execute(timeout: .timedOut, busy: .busy, cancellation: cancellation) {
guard !cancellation.isCancelled, let api = Functions.shared,
let service = Self.service(for: target, api: api) else { return .unavailable }
return DDCWriteTransaction.apply(percent: percent, cancellation: cancellation,
isCurrent: { Self.identity(for: target.id) == target.identity },
read: { Self.read(service, control: control, api: api, cancellation: cancellation) },
send: { raw in
var packet = DDCProtocol.set(control, value: raw)
return packet.withUnsafeMutableBytes {
api.write(service.value, service.address, 0x51, $0.baseAddress!, UInt32($0.count)) == KERN_SUCCESS
}
})
}
}
/// 超时只结束等待,不能终止内核调用。因此在旧调用真正返回前保留 running,
/// 后续请求立即返回 busy,不积累排队任务或为每次超时再开一个线程。
func execute<Value: Sendable>(timeout: Value, busy: Value, cancellation: DDCCancellation,
operation: @escaping @Sendable () -> Value) async -> Value {
let accepted = lock.withLock {
guard !running else { return false }
running = true
return true
}
guard accepted else { return busy }
return await withCheckedContinuation { continuation in
let completion = Completion(continuation)
DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + 2) {
completion.finish(timeout, beforeResume: { cancellation.cancel() })
}
queue.async { [self] in
let result = operation()
lock.withLock { running = false }
completion.finish(result)
}
}
}
private final class Completion<Value: Sendable>: @unchecked Sendable {
private let lock = NSLock()
private var continuation: CheckedContinuation<Value, Never>?
init(_ continuation: CheckedContinuation<Value, Never>) { self.continuation = continuation }
func finish(_ value: Value, beforeResume: () -> Void = {}) {
let pending = lock.withLock {
let pending = continuation
continuation = nil
return pending
}
guard let pending else { return }
beforeResume()
pending.resume(returning: value)
}
}
static func identity(for id: UInt32) -> String? {
guard CGDisplayIsOnline(id) != 0, let api = Functions.shared,
let info = api.info(id)?.takeRetainedValue() as? [String: Any],
let location = info["IODisplayLocation"] as? String, !location.isEmpty else { return nil }
return "\(CGDisplayVendorNumber(id)):\(CGDisplayModelNumber(id)):\(CGDisplaySerialNumber(id)):\(location)"
}
private struct Service {
let value: CFTypeRef
let address: UInt32
}
/// 通过 CoreDisplay 的注册表位置匹配 framebuffer;只接受一个明确对应的外接服务。
/// 不用型号名、枚举序号或相近 EDID 猜测目标,以免同型号多屏发生错写。
private static func service(for target: DisplayTarget, api: Functions) -> Service? {
guard identity(for: target.id) == target.identity, CGDisplayIsBuiltin(target.id) == 0,
let info = api.info(target.id)?.takeRetainedValue() as? [String: Any],
let location = info["IODisplayLocation"] as? String, !location.isEmpty else { return nil }
let root = IORegistryGetRootEntry(kIOMainPortDefault)
guard root != 0 else { return nil }
defer { IOObjectRelease(root) }
var iterator: io_iterator_t = 0
guard IORegistryEntryCreateIterator(root, kIOServicePlane, IOOptionBits(kIORegistryIterateRecursively), &iterator) == KERN_SUCCESS else { return nil }
defer { IOObjectRelease(iterator) }
var matchingFramebuffer = false
var candidates: [Service] = []
for _ in 0..<20_000 {
let entry = IOIteratorNext(iterator)
if entry == 0 { break }
defer { IOObjectRelease(entry) }
var name = [CChar](repeating: 0, count: 128)
guard IORegistryEntryGetName(entry, &name) == KERN_SUCCESS else { continue }
let label = String(decoding: name.prefix { $0 != 0 }.map { UInt8(bitPattern: $0) }, as: UTF8.self)
if label.contains("AppleCLCD2") || label.contains("IOMobileFramebufferShim") {
var path = [CChar](repeating: 0, count: 1024)
matchingFramebuffer = IORegistryEntryGetPath(entry, kIOServicePlane, &path) == KERN_SUCCESS
&& String(decoding: path.prefix { $0 != 0 }.map { UInt8(bitPattern: $0) }, as: UTF8.self) == location
} else if matchingFramebuffer && label == "DCPAVServiceProxy" {
let kind = IORegistryEntryCreateCFProperty(entry, "Location" as CFString, nil, 0)?.takeRetainedValue() as? String
guard kind == "External", let service = api.create(nil, entry)?.takeRetainedValue() else { continue }
let provider = IORegistryEntrySearchCFProperty(entry, kIOServicePlane, "EPICProviderClass" as CFString,
nil, IOOptionBits(kIORegistryIterateParents | kIORegistryIterateRecursively)) as? String
// 部分 M1/M2 HDMI 桥使用另一条路由地址;协议校验和仍使用标准 DDC 地址。
candidates.append(Service(value: service, address: provider == "AppleDCPMCDP29XX" ? 0xb7 : 0x37))
}
}
guard candidates.count == 1, identity(for: target.id) == target.identity else { return nil }
return candidates[0]
}
private static func read(_ service: Service, control: DisplayControl, api: Functions,
cancellation: DDCCancellation) -> DDCResult {
for _ in 0..<2 {
guard !cancellation.isCancelled else { return .cancelled }
var request = DDCProtocol.get(control)
let sent = request.withUnsafeMutableBytes {
api.write(service.value, service.address, 0x51, $0.baseAddress!, UInt32($0.count))
}
guard sent == KERN_SUCCESS else { continue }
Thread.sleep(forTimeInterval: 0.05)
guard !cancellation.isCancelled else { return .cancelled }
var reply = [UInt8](repeating: 0, count: 11)
let received = reply.withUnsafeMutableBytes {
api.read(service.value, service.address, 0, $0.baseAddress!, UInt32($0.count))
}
guard received == KERN_SUCCESS else { continue }
let parsed = DDCProtocol.parse(reply, control: control)
if parsed != .unavailable { return parsed }
}
return .unavailable
}
/// 不可变 C 函数指针;动态库在进程存续期间保持加载,任何线程均可读取。
private struct Functions: @unchecked Sendable {
typealias Create = @convention(c) (CFAllocator?, io_service_t) -> Unmanaged<CFTypeRef>?
typealias Transfer = @convention(c) (CFTypeRef, UInt32, UInt32, UnsafeMutableRawPointer, UInt32) -> IOReturn
typealias Info = @convention(c) (UInt32) -> Unmanaged<CFDictionary>?
let create: Create
let read: Transfer
let write: Transfer
let info: Info
static let shared: Functions? = {
guard let io = dlopen("/System/Library/Frameworks/IOKit.framework/IOKit", RTLD_LAZY),
let cd = dlopen("/System/Library/Frameworks/CoreDisplay.framework/CoreDisplay", RTLD_LAZY),
let create = dlsym(io, "IOAVServiceCreateWithService"),
let read = dlsym(io, "IOAVServiceReadI2C"),
let write = dlsym(io, "IOAVServiceWriteI2C"),
let info = dlsym(cd, "CoreDisplay_DisplayCreateInfoDictionary") else { return nil }
return Functions(create: unsafeBitCast(create, to: Create.self), read: unsafeBitCast(read, to: Transfer.self),
write: unsafeBitCast(write, to: Transfer.self), info: unsafeBitCast(info, to: Info.self))
}()
}
}
@@ -109,27 +109,7 @@ struct SystemInfoPage: View {
BluetoothCard()
InfoCard(icon: "display.2", title: tr("显示器")) {
let displays = DisplayInfo.all()
if displays.isEmpty {
Text(tr("没有检测到显示器")).dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
}
ForEach(Array(displays.enumerated()), id: \.offset) { index, display in
if index > 0 { HairlineDivider() }
HStack(spacing: DS.Space.s3) {
Image(systemName: display.isBuiltIn ? "laptopcomputer" : "display")
.font(.system(size: DS.TextSize.base.rawValue))
.foregroundStyle(DS.Palette.textSecondary)
.frame(width: DS.Size.iconStandalone)
VStack(alignment: .leading, spacing: 0) {
Text(verbatim: display.name).dsFont(.sm, weight: .medium).foregroundStyle(DS.Palette.textPrimary)
Text(verbatim: display.summary).dsFont(.xs).monospacedDigit().foregroundStyle(DS.Palette.textSecondary)
}
Spacer(minLength: 0)
if display.isMain { Chip(text: tr("主显示器"), tone: .primary) }
}
}
}
DisplayDetails()
InfoCard(icon: "number", title: tr("标识")) {
InfoRow(label: tr("机型标识符")) { CopyableText(text: system.modelIdentifier.isEmpty ? "—" : system.modelIdentifier) }
@@ -257,8 +237,10 @@ private struct InfoCard<Content: View>: View {
}
}
/// 显示器信息:名称、尺寸、原生分辨率与最高刷新率
struct DisplayInfo {
/// 显示器信息:名称、尺寸、原生分辨率与刷新率;当前模式未给出刷新率时标注上限。
struct DisplayInfo: Identifiable, Equatable, Sendable {
let target: DisplayTarget
var id: UInt32 { target.id }
let name: String
let isBuiltIn: Bool
let isMain: Bool
@@ -277,8 +259,11 @@ struct DisplayInfo {
if let native = nativeResolution(id) { parts.append("\(native.width)×\(native.height)") }
let points = screen.frame.size
parts.append(tr("显示为 \(Int(points.width))×\(Int(points.height))"))
if screen.maximumFramesPerSecond > 0 { parts.append("\(screen.maximumFramesPerSecond)Hz") }
return DisplayInfo(name: screen.localizedName, isBuiltIn: CGDisplayIsBuiltin(id) != 0,
let refresh = CGDisplayCopyDisplayMode(id)?.refreshRate ?? 0
if refresh > 0 { parts.append("\(Int(refresh.rounded()))Hz") }
else if screen.maximumFramesPerSecond > 0 { parts.append("≤\(screen.maximumFramesPerSecond)Hz") }
return DisplayInfo(target: DisplayTarget(id: id, identity: NativeDisplayDDC.identity(for: id) ?? "unavailable-\(id)"),
name: screen.localizedName, isBuiltIn: CGDisplayIsBuiltin(id) != 0,
isMain: CGDisplayIsMain(id) != 0, summary: parts.joined(separator: " · "))
}
}
@@ -238,6 +238,9 @@ struct OverviewReading {
}
switch item {
case .display:
value = model.displays.catalog.count.formatted(.number.locale(L10n.locale))
detail = model.displays.catalog.map(\.name).joined(separator: " · ")
case .cpu:
chart = .line(store.cpuTotal.elements, DS.Palette.primary)
if let total = store.cpu?.total {
@@ -77,6 +77,7 @@ struct PopoverDetail: View {
case .temperature, .fan: ThermalPopover(item: item)
case .battery: BatteryPopover()
case .aiUsage: AIUsagePopover()
case .display: DisplayDetails()
}
}
}
@@ -22,6 +22,10 @@ enum SnapshotRenderer {
renderCalendar(outputDirectory: outputDirectory)
return
}
if CommandLine.arguments.contains("--display-only") {
await renderDisplays(outputDirectory: outputDirectory)
return
}
if CommandLine.arguments.contains("--menubar-only") {
await renderMenuBar(outputDirectory: outputDirectory)
return
@@ -53,6 +57,7 @@ enum SnapshotRenderer {
let model = AppModel(settings: settings, historyURL: historyURL,
aiUsageProviders: [SnapshotAIUsageProvider()])
await model.aiUsage.refresh()
model.displays.refreshCatalog()
model.isMainWindowVisible = true
model.network.setVisibility(inMenuBar: true, detailVisible: true)
@@ -132,6 +137,34 @@ enum SnapshotRenderer {
print(tr("截图已输出到 \(outputDirectory.path)"))
}
/// 显示器走查只读信息和 Get VCP 能力,不执行设置写入。
private static func renderDisplays(outputDirectory: URL) async {
let suite = "XStats.displaySnapshot.\(UUID())"
guard let defaults = UserDefaults(suiteName: suite) else { return }
defer { defaults.removePersistentDomain(forName: suite) }
let settings = AppSettings(defaults: defaults)
settings.language = L10n.language
settings.menuBarItems = [.display]
let model = AppModel(settings: settings, historyURL: nil, aiUsageProviders: [], aiQuotaProviders: [])
model.displays.start()
model.displays.setVisible(true)
await model.displays.refresh()
model.displays.setVisible(false)
model.displays.stop()
for (name, suffix) in [(NSAppearance.Name.aqua, "light"), (.darkAqua, "dark")] {
guard let appearance = NSAppearance(named: name) else { continue }
NSApp.appearance = appearance
write(PopoverRootView(item: .display), model: model, appearance: appearance,
to: outputDirectory.appendingPathComponent("displays-\(suffix).png"))
model.combinedPopoverTab = nil
write(CombinedPopoverView(), model: model, appearance: appearance,
to: outputDirectory.appendingPathComponent("overview-\(suffix).png"))
model.combinedPopoverTab = .display
write(CombinedPopoverView(), model: model, appearance: appearance,
to: outputDirectory.appendingPathComponent("expanded-\(suffix).png"))
}
}
/// 菜单栏走查只启动相关指标采样,不扫描清理目录、不读取 AI 日志或查询更新。
private static func renderMenuBar(outputDirectory: URL) async {
let suite = "XStats.menuBarSnapshot.\(UUID().uuidString)"
@@ -150,6 +183,7 @@ enum SnapshotRenderer {
await model.hub.start(primeAllMetrics: false) { snapshot in model.store.apply(snapshot) }
try? await Task.sleep(for: .seconds(3))
await model.hub.stop()
model.displays.refreshCatalog()
model.isMainWindowVisible = true
// CPU 详情需要频率数据;显式按详情需求采一次,避免借用全量预采样的副作用。
model.combinedPopoverTab = .cpu
@@ -11,6 +11,7 @@ import Updates
@MainActor
@Observable
public final class AppModel {
let displays = DisplayController()
public let settings: AppSettings
let rest: RestController
let calendarAgenda = CalendarAgendaController()
@@ -126,6 +127,10 @@ public final class AppModel {
&& visibleMenuBarItems.contains { $0 == .cpu || $0 == .memory }
}
var displayControlsVisible: Bool {
openPopover == .display || (isMainWindowVisible && settings.panelTab == .system)
}
/// 根据当前可见内容决定采集范围:主窗口看标签页,详情弹窗看是哪一项
var demand: MetricsDemand {
var demand = MetricsDemand()
@@ -10,7 +10,7 @@ import Metrics
import Observation
public enum MenuBarItem: String, CaseIterable, Identifiable, Sendable {
case cpu, memory, network, gpu, disk, temperature, fan, battery, aiUsage
case cpu, memory, network, gpu, disk, temperature, fan, battery, aiUsage, display
public var id: String { rawValue }
@@ -25,6 +25,7 @@ public enum MenuBarItem: String, CaseIterable, Identifiable, Sendable {
case .fan: tr("风扇转速")
case .battery: tr("电池")
case .aiUsage: tr("AI 用量")
case .display: tr("显示器")
}
}
@@ -39,6 +40,7 @@ public enum MenuBarItem: String, CaseIterable, Identifiable, Sendable {
case .fan: tr("转速最高的风扇")
case .battery: tr("电量与充电状态;没有电池的 Mac 显示蓝牙设备电量")
case .aiUsage: tr("Codex / Claude Code 本机 Token 用量与订阅额度")
case .display: tr("显示器信息与支持的亮度、音量、对比度控制")
}
}
@@ -54,6 +56,7 @@ public enum MenuBarItem: String, CaseIterable, Identifiable, Sendable {
case .fan: "FAN"
case .battery: "BAT"
case .aiUsage: "AI"
case .display: "DSP"
}
}
@@ -69,6 +72,7 @@ public enum MenuBarItem: String, CaseIterable, Identifiable, Sendable {
case .fan: tr("风扇")
case .battery: tr("电池")
case .aiUsage: tr("AI 用量")
case .display: tr("显示器")
}
}
@@ -83,7 +87,7 @@ public enum MenuBarItem: String, CaseIterable, Identifiable, Sendable {
case .temperature: [.thermalSensors, .thermalFans, .thermalPower]
case .fan: [.thermalFans, .thermalSensors, .thermalPower]
case .battery: [.batteryHistory, .batteryPower, .batteryHealth, .batteryBluetooth]
case .aiUsage: []
case .aiUsage, .display: []
}
}
@@ -101,6 +105,7 @@ public enum MenuBarItem: String, CaseIterable, Identifiable, Sendable {
case .fan: "fan"
case .battery: "battery.75"
case .aiUsage: "sparkles"
case .display: "display"
}
}
}
@@ -236,7 +241,7 @@ public enum MenuBarStyle: String, CaseIterable, Identifiable, Sendable {
static func options(for item: MenuBarItem) -> [MenuBarStyle] {
switch item {
case .temperature, .fan: [.stacked, .stackedCenter, .inline, .icon]
case .temperature, .fan, .display: [.stacked, .stackedCenter, .inline, .icon]
case .aiUsage: [.stacked, .stackedCenter, .inline, .icon, .ring, .pie, .meter, .dot]
default: allCases
}
@@ -387,6 +392,7 @@ public enum PanelTab: String, CaseIterable, Identifiable, Sendable {
/// 页面对应的菜单栏项目,页面右上角可直接开关
var menuBarItem: MenuBarItem? {
switch self {
case .system: .display
case .cpu: .cpu
case .gpu: .gpu
case .memory: .memory
@@ -417,6 +423,7 @@ public enum PanelTab: String, CaseIterable, Identifiable, Sendable {
case .temperature, .fan: self = .thermal
case .battery: self = .battery
case .aiUsage: self = .aiUsage
case .display: self = .system
}
}
}
@@ -86,6 +86,9 @@ struct LanguageSupportTests {
let catalog = try #require(Translations.catalog(for: language))
#expect(catalog.sourceKeys == Translations.shared.sourceKeys)
for key in ["语言", "应用 UI 语言", "自动检测", "搜索语言", "没有匹配的语言",
"亮度", "对比度", "音量", "显示器 {} 台", "重新检测显示器控制",
"显示器不支持此控制", "暂时无法读取,请检查 DDC/CI 与连接",
"显示链路正在恢复…", "修改未确认,请重新检测",
"合并显示", "仅图标", "面板项目", "启动中…", "正在启动风扇…",
"只显示 XStats 图标,点击查看已选项目的状态总览",
"所选项目显示在弹出面板中,原有图标风格会保留。", "打开进程监控",
@@ -0,0 +1,42 @@
// Copyright (C) 2026 ysicing
// SPDX-License-Identifier: AGPL-3.0-or-later
import Foundation
import Testing
@testable import XStatsUI
struct DDCExecutorTests {
@Test func timeoutDoesNotQueueMoreWorkBehindAHungCall() async throws {
let backend = NativeDisplayDDC()
let gate = DispatchSemaphore(value: 0)
let ticket = DDCCancellation()
let first = await backend.execute(timeout: -1, busy: -2, cancellation: ticket) {
gate.wait()
return 1
}
#expect(first == -1)
#expect(ticket.isCancelled)
let second = await backend.execute(timeout: -1, busy: -2, cancellation: DDCCancellation()) {
Issue.record("A timed-out kernel operation must retain its single execution slot")
return 2
}
#expect(second == -2)
gate.signal()
var recovered = -2
let deadline = ContinuousClock.now + .seconds(30)
while recovered == -2, ContinuousClock.now < deadline {
try await Task.sleep(for: .milliseconds(10))
recovered = await backend.execute(timeout: -1, busy: -2, cancellation: DDCCancellation()) { 3 }
}
#expect(recovered == 3)
}
@Test func completedOperationIsNotCancelledByItsExpiredDeadline() async throws {
let ticket = DDCCancellation()
let backend = NativeDisplayDDC()
let value = await backend.execute(timeout: -1, busy: -2, cancellation: ticket) { 7 }
#expect(value == 7)
try await Task.sleep(for: .milliseconds(2200))
#expect(!ticket.isCancelled)
}
}
@@ -0,0 +1,85 @@
// Copyright (C) 2026 ysicing
// SPDX-License-Identifier: AGPL-3.0-or-later
import Testing
@testable import XStatsUI
struct DDCProtocolTests {
private func reply(control: DisplayControl = .brightness, status: UInt8 = 0,
current: UInt16 = 50, maximum: UInt16 = 100) -> [UInt8] {
let data: [UInt8] = [0x6e, 0x88, 0x02, status, control.rawValue, 0,
UInt8(maximum >> 8), UInt8(maximum & 0xff), UInt8(current >> 8), UInt8(current & 0xff)]
return data + [data.reduce(UInt8(0x50), ^)]
}
@Test func validatesTheWholeReplyAndKeepsUnsupportedDistinct() {
let good = reply()
#expect(DDCProtocol.parse(good, control: .brightness) == .value(DDCValue(current: 50, maximum: 100)))
#expect(DDCProtocol.parse(reply(status: 1), control: .brightness) == .unsupported)
#expect(DDCProtocol.parse(reply(status: 2), control: .brightness) == .unavailable)
#expect(DDCProtocol.parse(good, control: .volume) == .unavailable)
for index in good.indices {
var bad = good; bad[index] ^= 1
#expect(DDCProtocol.parse(bad, control: .brightness) == .unavailable)
}
#expect(DDCProtocol.parse(Array(good.dropLast()), control: .brightness) == .unavailable)
#expect(DDCProtocol.parse(good + [0], control: .brightness) == .unavailable)
#expect(DDCProtocol.parse(reply(current: 101), control: .brightness) == .unavailable)
#expect(DDCProtocol.parse(reply(current: 0, maximum: 0), control: .brightness) == .unavailable)
}
@Test func preservesFullRangeAndRejectsInvalidUserValues() {
let value = DDCValue(current: 32768, maximum: 65535)
#expect(DDCProtocol.parse(reply(current: 32768, maximum: 65535), control: .brightness) == .value(value))
#expect(value.rawValue(for: 0) == 0)
#expect(value.rawValue(for: 100) == 65535)
#expect(value.rawValue(for: -1) == nil)
#expect(value.rawValue(for: 101) == nil)
#expect(value.rawValue(for: .nan) == nil)
#expect(value.rawValue(for: .infinity) == nil)
#expect(DDCProtocol.get(.brightness) == [0x82, 0x01, 0x10, 0xac])
#expect(DDCProtocol.set(.contrast, value: 50) == [0x84, 0x03, 0x12, 0x00, 0x32, 0x98])
}
}
struct DDCWriteTransactionTests {
@Test func unsupportedUnavailableAndStaleConnectionsNeverWrite() {
for result: DDCResult in [.unsupported, .unavailable, .timedOut] {
var writes = 0
let actual = DDCWriteTransaction.apply(percent: 50, cancellation: DDCCancellation(), isCurrent: { true },
read: { result }, send: { _ in writes += 1; return true })
#expect(actual == result)
#expect(writes == 0)
}
var writes = 0
let stale = DDCWriteTransaction.apply(percent: 50, cancellation: DDCCancellation(), isCurrent: { false },
read: { .value(DDCValue(current: 75, maximum: 100)) }, send: { _ in writes += 1; return true })
#expect(stale == .cancelled && writes == 0)
}
@Test func cancellationBetweenProbeAndSetPreventsTheSet() {
let ticket = DDCCancellation()
var writes = 0
let result = DDCWriteTransaction.apply(percent: 50, cancellation: ticket, isCurrent: { true }, read: {
ticket.cancel()
return .value(DDCValue(current: 75, maximum: 100))
}, send: { _ in writes += 1; return true })
#expect(result == .cancelled && writes == 0)
}
@Test func successfulSendNeedsMatchingReadbackAndRange() {
for after: DDCResult in [.value(DDCValue(current: 50, maximum: 100)),
.value(DDCValue(current: 75, maximum: 100)),
.value(DDCValue(current: 50, maximum: 80)), .unavailable] {
var reads = 0
var sent: UInt16?
let result = DDCWriteTransaction.apply(percent: 50, cancellation: DDCCancellation(), isCurrent: { true }, read: {
reads += 1
return reads == 1 ? .value(DDCValue(current: 75, maximum: 100)) : after
}, send: { sent = $0; return true })
#expect(sent == 50)
if after == .value(DDCValue(current: 50, maximum: 100)) { #expect(result == after) }
else if case .unconfirmed = result {} else { Issue.record("An unverified write must not be shown as applied") }
}
}
}
@@ -0,0 +1,133 @@
// Copyright (C) 2026 ysicing
// SPDX-License-Identifier: AGPL-3.0-or-later
import Foundation
import Testing
@testable import XStatsUI
private actor TestDisplayBackend: DisplayDDCBackend {
var reads = 0
var writes = 0
var readResult: [DisplayControl: DDCResult] = [.brightness: .value(DDCValue(current: 75, maximum: 100)), .volume: .unsupported]
var writeResult = DDCResult.value(DDCValue(current: 60, maximum: 100))
var gateReads = false
var gates: [CheckedContinuation<[DisplayControl: DDCResult], Never>] = []
var tickets: [DDCCancellation] = []
func read(_ target: DisplayTarget, cancellation: DDCCancellation) async -> [DisplayControl: DDCResult] {
reads += 1
tickets.append(cancellation)
if gateReads { return await withCheckedContinuation { gates.append($0) } }
return readResult
}
func write(_ target: DisplayTarget, control: DisplayControl, percent: Double, cancellation: DDCCancellation) async -> DDCResult {
writes += 1
return writeResult
}
func block() { gateReads = true }
func finish(_ value: UInt16) { gates.removeFirst().resume(returning: [.brightness: .value(DDCValue(current: value, maximum: 100))]) }
func setWriteResult(_ value: DDCResult) { writeResult = value }
func count() -> Int { reads }
func writeCount() -> Int { writes }
func wasCancelled(_ index: Int) -> Bool { tickets[index].isCancelled }
}
@MainActor
struct DisplayControllerTests {
private var display: DisplayInfo {
DisplayInfo(target: DisplayTarget(id: 7, identity: "fixture-connection"), name: "Fixture Display",
isBuiltIn: false, isMain: true, summary: "1920×1080 · 60Hz")
}
private func waitForReads(_ backend: TestDisplayBackend, _ count: Int) async throws {
let deadline = ContinuousClock.now + .seconds(30)
while await backend.count() < count, ContinuousClock.now < deadline { try await Task.sleep(for: .milliseconds(10)) }
#expect(await backend.count() == count)
}
private func settle(_ controller: DisplayController) async throws {
let deadline = ContinuousClock.now + .seconds(30)
while controller.isRefreshing, ContinuousClock.now < deadline { try await Task.sleep(for: .milliseconds(10)) }
#expect(!controller.isRefreshing)
}
@Test func onlyVisibleControlsReadAndOnlyKnownSupportedControlsWrite() async throws {
let backend = TestDisplayBackend()
let controller = DisplayController(backend: backend, catalog: [display])
defer { controller.stop() }
await controller.refresh()
#expect(await backend.count() == 0)
controller.setVisible(true)
try await waitForReads(backend, 1)
try await settle(controller)
#expect(controller.readings[7]?[.volume] == .unsupported)
await controller.write(20, control: .volume, display: display)
await controller.write(.nan, control: .brightness, display: display)
#expect(await backend.writeCount() == 0)
await controller.write(60, control: .brightness, display: display)
#expect(await backend.writeCount() == 1)
#expect(controller.readings[7]?[.brightness] == .value(DDCValue(current: 60, maximum: 100)))
controller.setVisible(false)
await controller.refresh()
await controller.write(20, control: .brightness, display: display)
#expect(await backend.count() == 1)
#expect(await backend.writeCount() == 1)
}
@Test func oldReadCannotPublishAfterCloseOrClearNewRequest() async throws {
let backend = TestDisplayBackend()
await backend.block()
let controller = DisplayController(backend: backend, catalog: [display])
defer { controller.stop() }
controller.setVisible(true)
try await waitForReads(backend, 1)
controller.setVisible(false)
#expect(await backend.wasCancelled(0))
controller.setVisible(true)
try await waitForReads(backend, 2)
await backend.finish(10)
try await Task.sleep(for: .milliseconds(30))
#expect(controller.isRefreshing)
#expect(controller.readings.isEmpty)
await backend.finish(80)
try await settle(controller)
#expect(controller.readings[7]?[.brightness] == .value(DDCValue(current: 80, maximum: 100)))
}
@Test func pauseAndWakeSettlingCannotBeBypassedByManualRefreshOrReopen() async throws {
let backend = TestDisplayBackend()
let controller = DisplayController(backend: backend, catalog: [display])
defer { controller.stop() }
controller.setVisible(true)
try await waitForReads(backend, 1)
try await settle(controller)
controller.setPaused(true)
await controller.refresh()
await controller.write(20, control: .brightness, display: display)
#expect(await backend.writeCount() == 0)
controller.setPaused(false)
#expect(controller.isSettling)
controller.setVisible(false)
controller.setVisible(true)
await controller.refresh()
#expect(controller.isSettling)
#expect(await backend.count() == 1)
}
@Test func failedReadbackIsNotReportedAsSuccessfulAndEditingDefersPolling() async throws {
let backend = TestDisplayBackend()
let controller = DisplayController(backend: backend, catalog: [display])
defer { controller.stop() }
controller.setVisible(true)
try await waitForReads(backend, 1)
try await settle(controller)
let token = UUID()
controller.beginEditing(token)
await controller.refresh()
#expect(await backend.count() == 1)
await backend.setWriteResult(.unconfirmed(nil))
await controller.write(20, control: .brightness, display: display)
controller.endEditing(token)
#expect(controller.readings[7]?[.brightness] == .unconfirmed(nil))
#expect(!controller.isWriting)
await controller.write(40, control: .brightness, display: display)
#expect(await backend.writeCount() == 1)
}
}
@@ -0,0 +1,34 @@
// Copyright (C) 2026 ysicing
// SPDX-License-Identifier: AGPL-3.0-or-later
import Foundation
import Testing
@testable import XStatsUI
@MainActor
struct DisplayHardwareReadTests {
/// 显式运行才探测本机硬件,只发 Get VCP;不执行 Set VCP,也不保存显示器标识或序列号。
@Test(.enabled(if: ProcessInfo.processInfo.environment["XSTATS_DDC_READ_REPORT"] != nil))
func readOnlyCapabilityProbe() async throws {
let path = try #require(ProcessInfo.processInfo.environment["XSTATS_DDC_READ_REPORT"])
let displays = DisplayInfo.all().filter { !$0.isBuiltIn }
#expect(!displays.isEmpty)
let backend = NativeDisplayDDC()
var report: [[String: Any]] = []
for display in displays {
let readings = await backend.read(display.target, cancellation: DDCCancellation())
var controls: [String: Any] = [:]
for control in DisplayControl.allCases {
switch readings[control] {
case .value(let value):
#expect(value.maximum > 0 && value.current <= value.maximum)
controls[String(describing: control)] = ["supported": true, "current": value.current, "maximum": value.maximum]
default: controls[String(describing: control)] = ["status": String(describing: readings[control])]
}
}
report.append(["display": display.name, "controls": controls, "readOnly": true])
}
try JSONSerialization.data(withJSONObject: report, options: [.prettyPrinted, .sortedKeys])
.write(to: URL(fileURLWithPath: path), options: .atomic)
}
}