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) + } +}