feat(display): 添加显示器信息与 DDC 控制
This commit is contained in:
1 parent
c21a3cbaa1
commit
9707abba1d
27 files changed
+1108
-28
No files matched your search
@@ -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
|
||||
```
|
||||
@@ -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 والاتصال
|
||||
显示器响应超时 انتهت مهلة استجابة الشاشة
|
||||
显示器通信繁忙 الاتصال بالشاشة مشغول
|
||||
修改未确认,请重新检测 لم يتم تأكيد التغيير؛ أعد الاكتشاف
|
||||
尚未检测 لم يُكتشف بعد
|
||||
亮度 السطوع
|
||||
音量 مستوى الصوت
|
||||
显示链路正在恢复… جارٍ استعادة اتصال الشاشة…
|
||||
对比度 التباين
|
||||
|
@@ -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
|
||||
|
@@ -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
|
||||
|
@@ -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
|
||||
|
@@ -1657,3 +1657,19 @@ Token 用量 トークン
|
||||
正在启动风扇… ファンを起動中…
|
||||
打开进程监控 プロセスモニターを開く
|
||||
IP 归属地 IPの所在地
|
||||
显示器信息与支持的亮度、音量、对比度控制 ディスプレイ情報と対応する明るさ・音量・コントラストの調整
|
||||
显示器 {} 台 ディスプレイ {} 台
|
||||
重新检测显示器控制 ディスプレイの制御を再検出
|
||||
内置显示器请在系统设置中调节 内蔵ディスプレイはシステム設定で調整してください
|
||||
松开滑杆后应用到显示器 スライダーを離すとディスプレイに適用します
|
||||
正在检测… 検出中…
|
||||
显示器不支持此控制 このディスプレイでは非対応です
|
||||
暂时无法读取,请检查 DDC/CI 与连接 読み取れません。DDC/CIと接続を確認してください
|
||||
显示器响应超时 ディスプレイの応答がタイムアウトしました
|
||||
显示器通信繁忙 ディスプレイとの通信中です
|
||||
修改未确认,请重新检测 変更を確認できません。再検出してください
|
||||
尚未检测 未検出
|
||||
亮度 明るさ
|
||||
音量 音量
|
||||
显示链路正在恢复… ディスプレイの接続を復旧中…
|
||||
对比度 コントラスト
|
||||
|
@@ -1657,3 +1657,19 @@ Token 用量 토큰
|
||||
正在启动风扇… 팬 시작 중…
|
||||
打开进程监控 프로세스 모니터 열기
|
||||
IP 归属地 IP 위치
|
||||
显示器信息与支持的亮度、音量、对比度控制 디스플레이 정보 및 지원되는 밝기, 음량, 명암 조절
|
||||
显示器 {} 台 디스플레이 {}대
|
||||
重新检测显示器控制 디스플레이 제어 다시 감지
|
||||
内置显示器请在系统设置中调节 내장 디스플레이는 시스템 설정에서 조절하세요
|
||||
松开滑杆后应用到显示器 슬라이더를 놓으면 디스플레이에 적용됩니다
|
||||
正在检测… 감지 중…
|
||||
显示器不支持此控制 이 디스플레이에서 지원하지 않습니다
|
||||
暂时无法读取,请检查 DDC/CI 与连接 읽을 수 없습니다. DDC/CI와 연결을 확인하세요
|
||||
显示器响应超时 디스플레이 응답 시간 초과
|
||||
显示器通信繁忙 디스플레이 통신 중
|
||||
修改未确认,请重新检测 변경이 확인되지 않았습니다. 다시 감지하세요
|
||||
尚未检测 아직 감지되지 않음
|
||||
亮度 밝기
|
||||
音量 음량
|
||||
显示链路正在恢复… 디스플레이 연결 복구 중…
|
||||
对比度 명암
|
||||
|
@@ -1658,3 +1658,19 @@ Token 用量 Token 用量
|
||||
正在启动风扇… 正在啟動風扇…
|
||||
打开进程监控 開啟程序監控
|
||||
IP 归属地 IP 歸屬地
|
||||
显示器信息与支持的亮度、音量、对比度控制 顯示器資訊與支援的亮度、音量、對比度控制
|
||||
显示器 {} 台 顯示器 {} 台
|
||||
重新检测显示器控制 重新偵測顯示器控制
|
||||
内置显示器请在系统设置中调节 內建顯示器請在系統設定中調節
|
||||
松开滑杆后应用到显示器 放開滑桿後套用至顯示器
|
||||
正在检测… 正在偵測…
|
||||
显示器不支持此控制 顯示器不支援此控制
|
||||
暂时无法读取,请检查 DDC/CI 与连接 暫時無法讀取,請檢查 DDC/CI 與連線
|
||||
显示器响应超时 顯示器回應逾時
|
||||
显示器通信繁忙 顯示器通訊忙碌
|
||||
修改未确认,请重新检测 修改未確認,請重新偵測
|
||||
尚未检测 尚未偵測
|
||||
亮度 亮度
|
||||
音量 音量
|
||||
显示链路正在恢复… 顯示連線正在恢復…
|
||||
对比度 對比度
|
||||
|
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user