diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 3e470dc..af111e3 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -594,3 +594,35 @@ placeholder checks accompany the localization tests.
`--snapshot
` 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
+```
diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md
index e3c5cf8..92b8995 100644
--- a/DEVELOPMENT.md
+++ b/DEVELOPMENT.md
@@ -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 版本摘要与活跃度图。
diff --git a/Packages/XStatsKit/Sources/Localization/Resources/ar.tsv b/Packages/XStatsKit/Sources/Localization/Resources/ar.tsv
index 18b1a93..f2ddc51 100644
--- a/Packages/XStatsKit/Sources/Localization/Resources/ar.tsv
+++ b/Packages/XStatsKit/Sources/Localization/Resources/ar.tsv
@@ -1657,3 +1657,19 @@ Token 用量 الرموز
正在启动风扇… جارٍ تشغيل المراوح…
打开进程监控 فتح مراقب العمليات
IP 归属地 موقع عنوان IP
+显示器信息与支持的亮度、音量、对比度控制 معلومات الشاشة وعناصر التحكم المدعومة للسطوع والصوت والتباين
+显示器 {} 台 {} شاشات
+重新检测显示器控制 إعادة اكتشاف عناصر التحكم بالشاشة
+内置显示器请在系统设置中调节 اضبط الشاشة المدمجة في إعدادات النظام
+松开滑杆后应用到显示器 اترك شريط التمرير لتطبيق الإعداد على الشاشة
+正在检测… جارٍ الاكتشاف…
+显示器不支持此控制 هذه الشاشة لا تدعم هذا التحكم
+暂时无法读取,请检查 DDC/CI 与连接 تعذرت القراءة؛ تحقق من DDC/CI والاتصال
+显示器响应超时 انتهت مهلة استجابة الشاشة
+显示器通信繁忙 الاتصال بالشاشة مشغول
+修改未确认,请重新检测 لم يتم تأكيد التغيير؛ أعد الاكتشاف
+尚未检测 لم يُكتشف بعد
+亮度 السطوع
+音量 مستوى الصوت
+显示链路正在恢复… جارٍ استعادة اتصال الشاشة…
+对比度 التباين
diff --git a/Packages/XStatsKit/Sources/Localization/Resources/de.tsv b/Packages/XStatsKit/Sources/Localization/Resources/de.tsv
index 6e41682..2789d9d 100644
--- a/Packages/XStatsKit/Sources/Localization/Resources/de.tsv
+++ b/Packages/XStatsKit/Sources/Localization/Resources/de.tsv
@@ -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
diff --git a/Packages/XStatsKit/Sources/Localization/Resources/es.tsv b/Packages/XStatsKit/Sources/Localization/Resources/es.tsv
index c99873c..51b6c15 100644
--- a/Packages/XStatsKit/Sources/Localization/Resources/es.tsv
+++ b/Packages/XStatsKit/Sources/Localization/Resources/es.tsv
@@ -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
diff --git a/Packages/XStatsKit/Sources/Localization/Resources/fr.tsv b/Packages/XStatsKit/Sources/Localization/Resources/fr.tsv
index 6abb359..5e4caca 100644
--- a/Packages/XStatsKit/Sources/Localization/Resources/fr.tsv
+++ b/Packages/XStatsKit/Sources/Localization/Resources/fr.tsv
@@ -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
diff --git a/Packages/XStatsKit/Sources/Localization/Resources/ja.tsv b/Packages/XStatsKit/Sources/Localization/Resources/ja.tsv
index 207660c..419e82c 100644
--- a/Packages/XStatsKit/Sources/Localization/Resources/ja.tsv
+++ b/Packages/XStatsKit/Sources/Localization/Resources/ja.tsv
@@ -1657,3 +1657,19 @@ Token 用量 トークン
正在启动风扇… ファンを起動中…
打开进程监控 プロセスモニターを開く
IP 归属地 IPの所在地
+显示器信息与支持的亮度、音量、对比度控制 ディスプレイ情報と対応する明るさ・音量・コントラストの調整
+显示器 {} 台 ディスプレイ {} 台
+重新检测显示器控制 ディスプレイの制御を再検出
+内置显示器请在系统设置中调节 内蔵ディスプレイはシステム設定で調整してください
+松开滑杆后应用到显示器 スライダーを離すとディスプレイに適用します
+正在检测… 検出中…
+显示器不支持此控制 このディスプレイでは非対応です
+暂时无法读取,请检查 DDC/CI 与连接 読み取れません。DDC/CIと接続を確認してください
+显示器响应超时 ディスプレイの応答がタイムアウトしました
+显示器通信繁忙 ディスプレイとの通信中です
+修改未确认,请重新检测 変更を確認できません。再検出してください
+尚未检测 未検出
+亮度 明るさ
+音量 音量
+显示链路正在恢复… ディスプレイの接続を復旧中…
+对比度 コントラスト
diff --git a/Packages/XStatsKit/Sources/Localization/Resources/ko.tsv b/Packages/XStatsKit/Sources/Localization/Resources/ko.tsv
index d10ef96..a842d87 100644
--- a/Packages/XStatsKit/Sources/Localization/Resources/ko.tsv
+++ b/Packages/XStatsKit/Sources/Localization/Resources/ko.tsv
@@ -1657,3 +1657,19 @@ Token 用量 토큰
正在启动风扇… 팬 시작 중…
打开进程监控 프로세스 모니터 열기
IP 归属地 IP 위치
+显示器信息与支持的亮度、音量、对比度控制 디스플레이 정보 및 지원되는 밝기, 음량, 명암 조절
+显示器 {} 台 디스플레이 {}대
+重新检测显示器控制 디스플레이 제어 다시 감지
+内置显示器请在系统设置中调节 내장 디스플레이는 시스템 설정에서 조절하세요
+松开滑杆后应用到显示器 슬라이더를 놓으면 디스플레이에 적용됩니다
+正在检测… 감지 중…
+显示器不支持此控制 이 디스플레이에서 지원하지 않습니다
+暂时无法读取,请检查 DDC/CI 与连接 읽을 수 없습니다. DDC/CI와 연결을 확인하세요
+显示器响应超时 디스플레이 응답 시간 초과
+显示器通信繁忙 디스플레이 통신 중
+修改未确认,请重新检测 변경이 확인되지 않았습니다. 다시 감지하세요
+尚未检测 아직 감지되지 않음
+亮度 밝기
+音量 음량
+显示链路正在恢复… 디스플레이 연결 복구 중…
+对比度 명암
diff --git a/Packages/XStatsKit/Sources/Localization/Resources/zh-Hant.tsv b/Packages/XStatsKit/Sources/Localization/Resources/zh-Hant.tsv
index a8c187f..581b3cd 100644
--- a/Packages/XStatsKit/Sources/Localization/Resources/zh-Hant.tsv
+++ b/Packages/XStatsKit/Sources/Localization/Resources/zh-Hant.tsv
@@ -1658,3 +1658,19 @@ Token 用量 Token 用量
正在启动风扇… 正在啟動風扇…
打开进程监控 開啟程序監控
IP 归属地 IP 歸屬地
+显示器信息与支持的亮度、音量、对比度控制 顯示器資訊與支援的亮度、音量、對比度控制
+显示器 {} 台 顯示器 {} 台
+重新检测显示器控制 重新偵測顯示器控制
+内置显示器请在系统设置中调节 內建顯示器請在系統設定中調節
+松开滑杆后应用到显示器 放開滑桿後套用至顯示器
+正在检测… 正在偵測…
+显示器不支持此控制 顯示器不支援此控制
+暂时无法读取,请检查 DDC/CI 与连接 暫時無法讀取,請檢查 DDC/CI 與連線
+显示器响应超时 顯示器回應逾時
+显示器通信繁忙 顯示器通訊忙碌
+修改未确认,请重新检测 修改未確認,請重新偵測
+尚未检测 尚未偵測
+亮度 亮度
+音量 音量
+显示链路正在恢复… 顯示連線正在恢復…
+对比度 對比度
diff --git a/Packages/XStatsKit/Sources/Localization/Translations.swift b/Packages/XStatsKit/Sources/Localization/Translations.swift
index 2d6405c..0739906 100644
--- a/Packages/XStatsKit/Sources/Localization/Translations.swift
+++ b/Packages/XStatsKit/Sources/Localization/Translations.swift
@@ -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…
"""#
}
diff --git a/Packages/XStatsKit/Sources/XStatsUI/AppKit/AppController.swift b/Packages/XStatsKit/Sources/XStatsUI/AppKit/AppController.swift
index 25393a2..57951b6 100644
--- a/Packages/XStatsKit/Sources/XStatsUI/AppKit/AppController.swift
+++ b/Packages/XStatsKit/Sources/XStatsUI/AppKit/AppController.swift
@@ -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 {
diff --git a/Packages/XStatsKit/Sources/XStatsUI/AppKit/MenuBarRenderer.swift b/Packages/XStatsKit/Sources/XStatsUI/AppKit/MenuBarRenderer.swift
index 1b5f8f8..0bac68d 100644
--- a/Packages/XStatsKit/Sources/XStatsUI/AppKit/MenuBarRenderer.swift
+++ b/Packages/XStatsKit/Sources/XStatsUI/AppKit/MenuBarRenderer.swift
@@ -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:
diff --git a/Packages/XStatsKit/Sources/XStatsUI/Displays/DDCProtocol.swift b/Packages/XStatsKit/Sources/XStatsUI/Displays/DDCProtocol.swift
new file mode 100644
index 0000000..46877ac
--- /dev/null
+++ b/Packages/XStatsKit/Sources/XStatsUI/Displays/DDCProtocol.swift
@@ -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)
+ }
+}
diff --git a/Packages/XStatsKit/Sources/XStatsUI/Displays/DisplayController.swift b/Packages/XStatsKit/Sources/XStatsUI/Displays/DisplayController.swift
new file mode 100644
index 0000000..936ac30
--- /dev/null
+++ b/Packages/XStatsKit/Sources/XStatsUI/Displays/DisplayController.swift
@@ -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?
+ @ObservationIgnored private var editingTokens: Set = []
+ @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
+ }
+}
diff --git a/Packages/XStatsKit/Sources/XStatsUI/Displays/DisplayDetails.swift b/Packages/XStatsKit/Sources/XStatsUI/Displays/DisplayDetails.swift
new file mode 100644
index 0000000..7276006
--- /dev/null
+++ b/Packages/XStatsKit/Sources/XStatsUI/Displays/DisplayDetails.swift
@@ -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("音量")
+ }
+ }
+}
diff --git a/Packages/XStatsKit/Sources/XStatsUI/Displays/NativeDisplayDDC.swift b/Packages/XStatsKit/Sources/XStatsUI/Displays/NativeDisplayDDC.swift
new file mode 100644
index 0000000..cdf4034
--- /dev/null
+++ b/Packages/XStatsKit/Sources/XStatsUI/Displays/NativeDisplayDDC.swift
@@ -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(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: @unchecked Sendable {
+ private let lock = NSLock()
+ private var continuation: CheckedContinuation?
+ init(_ continuation: CheckedContinuation) { 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?
+ typealias Transfer = @convention(c) (CFTypeRef, UInt32, UInt32, UnsafeMutableRawPointer, UInt32) -> IOReturn
+ typealias Info = @convention(c) (UInt32) -> Unmanaged?
+ 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))
+ }()
+ }
+}
diff --git a/Packages/XStatsKit/Sources/XStatsUI/Panel/SystemInfoPage.swift b/Packages/XStatsKit/Sources/XStatsUI/Panel/SystemInfoPage.swift
index 81384ef..af122a4 100644
--- a/Packages/XStatsKit/Sources/XStatsUI/Panel/SystemInfoPage.swift
+++ b/Packages/XStatsKit/Sources/XStatsUI/Panel/SystemInfoPage.swift
@@ -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: 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: " · "))
}
}
diff --git a/Packages/XStatsKit/Sources/XStatsUI/Popover/CombinedPopover.swift b/Packages/XStatsKit/Sources/XStatsUI/Popover/CombinedPopover.swift
index 15ba4dd..0f629de 100644
--- a/Packages/XStatsKit/Sources/XStatsUI/Popover/CombinedPopover.swift
+++ b/Packages/XStatsKit/Sources/XStatsUI/Popover/CombinedPopover.swift
@@ -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 {
diff --git a/Packages/XStatsKit/Sources/XStatsUI/Popover/PopoverRootView.swift b/Packages/XStatsKit/Sources/XStatsUI/Popover/PopoverRootView.swift
index a23d2f9..a7c4021 100644
--- a/Packages/XStatsKit/Sources/XStatsUI/Popover/PopoverRootView.swift
+++ b/Packages/XStatsKit/Sources/XStatsUI/Popover/PopoverRootView.swift
@@ -77,6 +77,7 @@ struct PopoverDetail: View {
case .temperature, .fan: ThermalPopover(item: item)
case .battery: BatteryPopover()
case .aiUsage: AIUsagePopover()
+ case .display: DisplayDetails()
}
}
}
diff --git a/Packages/XStatsKit/Sources/XStatsUI/Snapshot/SnapshotRenderer.swift b/Packages/XStatsKit/Sources/XStatsUI/Snapshot/SnapshotRenderer.swift
index b08d0dd..cc2fc61 100644
--- a/Packages/XStatsKit/Sources/XStatsUI/Snapshot/SnapshotRenderer.swift
+++ b/Packages/XStatsKit/Sources/XStatsUI/Snapshot/SnapshotRenderer.swift
@@ -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
diff --git a/Packages/XStatsKit/Sources/XStatsUI/State/AppModel.swift b/Packages/XStatsKit/Sources/XStatsUI/State/AppModel.swift
index 79aac7d..ace7d6c 100644
--- a/Packages/XStatsKit/Sources/XStatsUI/State/AppModel.swift
+++ b/Packages/XStatsKit/Sources/XStatsUI/State/AppModel.swift
@@ -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()
diff --git a/Packages/XStatsKit/Sources/XStatsUI/State/AppSettings.swift b/Packages/XStatsKit/Sources/XStatsUI/State/AppSettings.swift
index bb0f78e..a36d3c3 100644
--- a/Packages/XStatsKit/Sources/XStatsUI/State/AppSettings.swift
+++ b/Packages/XStatsKit/Sources/XStatsUI/State/AppSettings.swift
@@ -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
}
}
}
diff --git a/Packages/XStatsKit/Tests/LocalizationTests/LanguageSupportTests.swift b/Packages/XStatsKit/Tests/LocalizationTests/LanguageSupportTests.swift
index cbc6c7d..9874528 100644
--- a/Packages/XStatsKit/Tests/LocalizationTests/LanguageSupportTests.swift
+++ b/Packages/XStatsKit/Tests/LocalizationTests/LanguageSupportTests.swift
@@ -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 图标,点击查看已选项目的状态总览",
"所选项目显示在弹出面板中,原有图标风格会保留。", "打开进程监控",
diff --git a/Packages/XStatsKit/Tests/XStatsUITests/DDCExecutorTests.swift b/Packages/XStatsKit/Tests/XStatsUITests/DDCExecutorTests.swift
new file mode 100644
index 0000000..2ef9acb
--- /dev/null
+++ b/Packages/XStatsKit/Tests/XStatsUITests/DDCExecutorTests.swift
@@ -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)
+ }
+}
diff --git a/Packages/XStatsKit/Tests/XStatsUITests/DDCProtocolTests.swift b/Packages/XStatsKit/Tests/XStatsUITests/DDCProtocolTests.swift
new file mode 100644
index 0000000..fcfa610
--- /dev/null
+++ b/Packages/XStatsKit/Tests/XStatsUITests/DDCProtocolTests.swift
@@ -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") }
+ }
+ }
+}
diff --git a/Packages/XStatsKit/Tests/XStatsUITests/DisplayControllerTests.swift b/Packages/XStatsKit/Tests/XStatsUITests/DisplayControllerTests.swift
new file mode 100644
index 0000000..912efb0
--- /dev/null
+++ b/Packages/XStatsKit/Tests/XStatsUITests/DisplayControllerTests.swift
@@ -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)
+ }
+}
diff --git a/Packages/XStatsKit/Tests/XStatsUITests/DisplayHardwareReadTests.swift b/Packages/XStatsKit/Tests/XStatsUITests/DisplayHardwareReadTests.swift
new file mode 100644
index 0000000..84e566a
--- /dev/null
+++ b/Packages/XStatsKit/Tests/XStatsUITests/DisplayHardwareReadTests.swift
@@ -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)
+ }
+}