From 1bd75ba0dbbb53c00a14e4f3b0ff4f047f1ab66c Mon Sep 17 00:00:00 2001 From: ysicing Date: Sun, 4 Oct 2026 15:18:33 +0800 Subject: [PATCH] =?UTF-8?q?fix(display):=20=E7=BC=93=E5=AD=98=20DDC=20?= =?UTF-8?q?=E6=9C=8D=E5=8A=A1=E5=8C=B9=E9=85=8D=E5=B9=B6=E9=9A=94=E7=A6=BB?= =?UTF-8?q?=E9=80=90=E5=B1=8F=E8=B6=85=E6=97=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 按连接缓存 IORegistry 匹配结果,屏幕配置变化、唤醒和手动重新检测时清空,轮询不再每 5 秒全量遍历注册表 - DCPAVServiceProxy 按遍历顺序归属后,再用服务自身 EDID 与 CG 显示器的厂商、型号、序列号比对,排除其他显示器的服务 - 每台显示器读取使用独立取消令牌,一台超时不再导致后续显示器跳过 --- ARCHITECTURE.md | 11 +++-- .../XStatsUI/Displays/DDCProtocol.swift | 28 +++++++++++ .../XStatsUI/Displays/DisplayController.swift | 13 ++++- .../XStatsUI/Displays/DisplayDetails.swift | 2 +- .../XStatsUI/Displays/NativeDisplayDDC.swift | 41 ++++++++++++++-- .../XStatsUITests/DDCProtocolTests.swift | 21 ++++++++ .../DisplayControllerTests.swift | 48 +++++++++++++++++++ 7 files changed, 153 insertions(+), 11 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index ed098e7..bbb7bf9 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -615,8 +615,12 @@ submits one target value. Wake and reconfiguration settling gates also block man 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. +ambiguous/missing matches disable controls. `DCPAVServiceProxy` is not a descendant of its +framebuffer, so it is paired by registry order and then rejected if the service's own EDID +(vendor, product, serial) disagrees with the CG display; an unreadable EDID keeps the location match. +Match results, including misses, are cached per connection on the I/O queue and dropped on +screen reconfiguration or wake, so polling does not rescan the registry. 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 @@ -626,7 +630,8 @@ that the display applied it. No software dimming or speculative compatibility wr 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 +Each display read gets its own cancellation ticket, so one timed-out display does not skip the +others in the same refresh. 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): diff --git a/Packages/XStatsKit/Sources/XStatsUI/Displays/DDCProtocol.swift b/Packages/XStatsKit/Sources/XStatsUI/Displays/DDCProtocol.swift index 46877ac..40028b5 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/Displays/DDCProtocol.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/Displays/DDCProtocol.swift @@ -78,6 +78,34 @@ protocol DisplayDDCBackend: Sendable { func read(_ target: DisplayTarget, cancellation: DDCCancellation) async -> [DisplayControl: DDCResult] func write(_ target: DisplayTarget, control: DisplayControl, percent: Double, cancellation: DDCCancellation) async -> DDCResult + /// 显示链路可能已变化(屏幕配置变更、唤醒),丢弃已匹配的服务,下次读写重新匹配。 + func resetConnections() +} + +/// EDID 基本块中的厂商、产品与序列号,与 CGDisplay*Number 同源,用于确认 DDC 服务属于目标显示器。 +struct EDIDSignature: Equatable, Sendable { + let vendor: UInt32 + let product: UInt32 + let serial: UInt32 + + init(vendor: UInt32, product: UInt32, serial: UInt32) { + self.vendor = vendor + self.product = product + self.serial = serial + } + + init?(_ bytes: [UInt8]) { + guard bytes.count >= 128, bytes.prefix(8) == [0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x00] else { return nil } + vendor = UInt32(bytes[8]) << 8 | UInt32(bytes[9]) + product = UInt32(bytes[10]) | UInt32(bytes[11]) << 8 + serial = UInt32(bytes[12]) | UInt32(bytes[13]) << 8 | UInt32(bytes[14]) << 16 | UInt32(bytes[15]) << 24 + } + + /// 序列号为 0 表示 EDID 未提供,此时只比较厂商与产品。 + func matches(_ other: EDIDSignature) -> Bool { + vendor == other.vendor && product == other.product + && (serial == 0 || other.serial == 0 || serial == other.serial) + } } /// 写前重新确认范围,写后读取真实值;发送成功本身不等于显示器已应用。 diff --git a/Packages/XStatsKit/Sources/XStatsUI/Displays/DisplayController.swift b/Packages/XStatsKit/Sources/XStatsUI/Displays/DisplayController.swift index 936ac30..15cb993 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/Displays/DisplayController.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/Displays/DisplayController.swift @@ -61,6 +61,7 @@ final class DisplayController { private func screenConfigurationChanged() { invalidate() + backend.resetConnections() readings = [:] refreshCatalog() deferCommunication(for: .seconds(1)) @@ -79,6 +80,7 @@ final class DisplayController { isPaused = value invalidate() if !value { + backend.resetConnections() refreshCatalog() // 唤醒后让显示链路先稳定,期间不发送 DDC 报文。 deferCommunication(for: .seconds(3)) @@ -128,12 +130,13 @@ final class DisplayController { 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 ticket = DDCCancellation() + cancellation = ticket let result = await withTaskCancellationHandler { await backend.read(display.target, cancellation: ticket) } onCancel: { ticket.cancel() } @@ -146,6 +149,12 @@ final class DisplayController { cancellation = nil } + /// 手动重新检测:丢弃已缓存的服务匹配(含未找到),再读取一次。 + func redetect() async { + backend.resetConnections() + await refresh() + } + func write(_ percent: Double, control: DisplayControl, display: DisplayInfo) async { guard visible, !isPaused, !isSettling, !Task.isCancelled, !isRefreshing, !isWriting, catalog.contains(where: { $0.target == display.target }), diff --git a/Packages/XStatsKit/Sources/XStatsUI/Displays/DisplayDetails.swift b/Packages/XStatsKit/Sources/XStatsUI/Displays/DisplayDetails.swift index 7276006..b42bce4 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/Displays/DisplayDetails.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/Displays/DisplayDetails.swift @@ -10,7 +10,7 @@ struct DisplayDetails: View { var body: some View { SectionCard(title: tr("显示器"), trailing: { RefreshButton(loading: model.displays.isRefreshing, help: tr("重新检测显示器控制")) { - Task { await model.displays.refresh() } + Task { await model.displays.redetect() } } }) { if model.displays.catalog.isEmpty { diff --git a/Packages/XStatsKit/Sources/XStatsUI/Displays/NativeDisplayDDC.swift b/Packages/XStatsKit/Sources/XStatsUI/Displays/NativeDisplayDDC.swift index cdf4034..7f17174 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/Displays/NativeDisplayDDC.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/Displays/NativeDisplayDDC.swift @@ -12,13 +12,20 @@ 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 + /// 只在 queue 内读写:注册表遍历成本高,按连接缓存匹配结果(含未找到), + /// 由 resetConnections() 在链路变化时清空,条目数不超过当前连接的外接屏。 + private var services: [DisplayTarget: Service?] = [:] + + func resetConnections() { + queue.async { [self] in services.removeAll() } + } 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 } + 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 } @@ -33,7 +40,7 @@ final class NativeDisplayDDC: DisplayDDCBackend, @unchecked Sendable { 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 } + 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) }, @@ -97,9 +104,19 @@ final class NativeDisplayDDC: DisplayDDCBackend, @unchecked Sendable { let address: UInt32 } + /// 在 queue 内调用;命中缓存时仍校验连接身份,显示器下线或编号复用时不使用旧服务。 + private func service(for target: DisplayTarget, api: Functions) -> Service? { + guard Self.identity(for: target.id) == target.identity else { return nil } + if let cached = services[target] { return cached } + let found = Self.matchService(for: target, api: api) + services[target] = found + return found + } + /// 通过 CoreDisplay 的注册表位置匹配 framebuffer;只接受一个明确对应的外接服务。 + /// proxy 不在 framebuffer 子树下,只能按遍历顺序归属,因此再用服务自身的 EDID 排除其他显示器的 proxy。 /// 不用型号名、枚举序号或相近 EDID 猜测目标,以免同型号多屏发生错写。 - private static func service(for target: DisplayTarget, api: Functions) -> Service? { + private static func matchService(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 } @@ -109,6 +126,8 @@ final class NativeDisplayDDC: DisplayDDCBackend, @unchecked Sendable { var iterator: io_iterator_t = 0 guard IORegistryEntryCreateIterator(root, kIOServicePlane, IOOptionBits(kIORegistryIterateRecursively), &iterator) == KERN_SUCCESS else { return nil } defer { IOObjectRelease(iterator) } + let expected = EDIDSignature(vendor: CGDisplayVendorNumber(target.id), product: CGDisplayModelNumber(target.id), + serial: CGDisplaySerialNumber(target.id)) var matchingFramebuffer = false var candidates: [Service] = [] for _ in 0..<20_000 { @@ -124,7 +143,8 @@ final class NativeDisplayDDC: DisplayDDCBackend, @unchecked Sendable { && 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 } + guard kind == "External", let service = api.create(nil, entry)?.takeRetainedValue(), + edid(of: service, api: api)?.matches(expected) ?? true else { continue } let provider = IORegistryEntrySearchCFProperty(entry, kIOServicePlane, "EPICProviderClass" as CFString, nil, IOOptionBits(kIORegistryIterateParents | kIORegistryIterateRecursively)) as? String // 部分 M1/M2 HDMI 桥使用另一条路由地址;协议校验和仍使用标准 DDC 地址。 @@ -135,6 +155,14 @@ final class NativeDisplayDDC: DisplayDDCBackend, @unchecked Sendable { return candidates[0] } + /// 读不到 EDID(符号缺失或链路不提供)时返回 nil,由调用方保留位置匹配结果。 + private static func edid(of service: CFTypeRef, api: Functions) -> EDIDSignature? { + guard let copyEDID = api.copyEDID else { return nil } + var data: Unmanaged? + guard copyEDID(service, &data) == KERN_SUCCESS, let bytes = data?.takeRetainedValue() as Data? else { return nil } + return EDIDSignature([UInt8](bytes)) + } + private static func read(_ service: Service, control: DisplayControl, api: Functions, cancellation: DDCCancellation) -> DDCResult { for _ in 0..<2 { @@ -162,10 +190,12 @@ final class NativeDisplayDDC: DisplayDDCBackend, @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? + typealias CopyEDID = @convention(c) (CFTypeRef, UnsafeMutablePointer?>) -> IOReturn let create: Create let read: Transfer let write: Transfer let info: Info + let copyEDID: CopyEDID? 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), @@ -174,7 +204,8 @@ final class NativeDisplayDDC: DisplayDDCBackend, @unchecked Sendable { 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)) + write: unsafeBitCast(write, to: Transfer.self), info: unsafeBitCast(info, to: Info.self), + copyEDID: dlsym(io, "IOAVServiceCopyEDID").map { unsafeBitCast($0, to: CopyEDID.self) }) }() } } diff --git a/Packages/XStatsKit/Tests/XStatsUITests/DDCProtocolTests.swift b/Packages/XStatsKit/Tests/XStatsUITests/DDCProtocolTests.swift index fcfa610..8cc0183 100644 --- a/Packages/XStatsKit/Tests/XStatsUITests/DDCProtocolTests.swift +++ b/Packages/XStatsKit/Tests/XStatsUITests/DDCProtocolTests.swift @@ -28,6 +28,27 @@ struct DDCProtocolTests { #expect(DDCProtocol.parse(reply(current: 0, maximum: 0), control: .brightness) == .unavailable) } + @Test func readsEDIDSignatureInCoreGraphicsByteOrder() throws { + // 本机 DELL U2422H 的真实值:CGDisplayVendorNumber 4268、ModelNumber 41400、SerialNumber 810567746 + var edid = [UInt8](repeating: 0, count: 128) + edid.replaceSubrange(0..<16, with: [0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x00, + 0x10, 0xac, 0xb8, 0xa1, 0x42, 0x48, 0x50, 0x30]) + let signature = try #require(EDIDSignature(edid)) + #expect(signature == EDIDSignature(vendor: 4268, product: 41400, serial: 810_567_746)) + var badHeader = edid; badHeader[0] = 1 + #expect(EDIDSignature(badHeader) == nil) + #expect(EDIDSignature(Array(edid.prefix(127))) == nil) + } + + @Test func edidMatchRejectsOtherDisplaysAndToleratesMissingSerial() { + let target = EDIDSignature(vendor: 4268, product: 41400, serial: 810_567_746) + #expect(target.matches(target)) + #expect(!target.matches(EDIDSignature(vendor: 4268, product: 41400, serial: 1))) + #expect(!target.matches(EDIDSignature(vendor: 4268, product: 41401, serial: 810_567_746))) + #expect(!target.matches(EDIDSignature(vendor: 1552, product: 41400, serial: 810_567_746))) + #expect(target.matches(EDIDSignature(vendor: 4268, product: 41400, serial: 0))) + } + @Test func preservesFullRangeAndRejectsInvalidUserValues() { let value = DDCValue(current: 32768, maximum: 65535) #expect(DDCProtocol.parse(reply(current: 32768, maximum: 65535), control: .brightness) == .value(value)) diff --git a/Packages/XStatsKit/Tests/XStatsUITests/DisplayControllerTests.swift b/Packages/XStatsKit/Tests/XStatsUITests/DisplayControllerTests.swift index 912efb0..df8fefb 100644 --- a/Packages/XStatsKit/Tests/XStatsUITests/DisplayControllerTests.swift +++ b/Packages/XStatsKit/Tests/XStatsUITests/DisplayControllerTests.swift @@ -5,6 +5,13 @@ import Foundation import Testing @testable import XStatsUI +private final class ResetCounter: @unchecked Sendable { + private let lock = NSLock() + private var count = 0 + var value: Int { lock.withLock { count } } + func increment() { lock.withLock { count += 1 } } +} + private actor TestDisplayBackend: DisplayDDCBackend { var reads = 0 var writes = 0 @@ -13,9 +20,16 @@ private actor TestDisplayBackend: DisplayDDCBackend { var gateReads = false var gates: [CheckedContinuation<[DisplayControl: DDCResult], Never>] = [] var tickets: [DDCCancellation] = [] + var timingOutTargets: Set = [] + nonisolated let resets = ResetCounter() func read(_ target: DisplayTarget, cancellation: DDCCancellation) async -> [DisplayControl: DDCResult] { reads += 1 tickets.append(cancellation) + // 与 NativeDisplayDDC.execute 一致:超时时取消传入的令牌 + if timingOutTargets.contains(target.id) { + cancellation.cancel() + return [.brightness: .timedOut] + } if gateReads { return await withCheckedContinuation { gates.append($0) } } return readResult } @@ -23,7 +37,9 @@ private actor TestDisplayBackend: DisplayDDCBackend { writes += 1 return writeResult } + nonisolated func resetConnections() { resets.increment() } func block() { gateReads = true } + func timeOut(_ id: UInt32) { timingOutTargets.insert(id) } 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 } @@ -48,6 +64,38 @@ struct DisplayControllerTests { #expect(!controller.isRefreshing) } + @Test func timeoutOnOneDisplayDoesNotSkipTheNextDisplay() async throws { + let backend = TestDisplayBackend() + await backend.timeOut(7) + let second = DisplayInfo(target: DisplayTarget(id: 8, identity: "fixture-second"), name: "Second Display", + isBuiltIn: false, isMain: false, summary: "2560×1440 · 60Hz") + let controller = DisplayController(backend: backend, catalog: [display, second]) + defer { controller.stop() } + controller.setVisible(true) + try await waitForReads(backend, 2) + try await settle(controller) + #expect(await backend.wasCancelled(0)) + #expect(!(await backend.wasCancelled(1))) + #expect(controller.readings[7]?[.brightness] == .timedOut) + #expect(controller.readings[8]?[.brightness] == .value(DDCValue(current: 75, maximum: 100))) + } + + @Test func wakeDropsMatchedConnectionsButVisibilityChangesKeepThem() async throws { + let backend = TestDisplayBackend() + let controller = DisplayController(backend: backend, catalog: [display]) + defer { controller.stop() } + controller.setVisible(true) + controller.setVisible(false) + controller.setVisible(true) + #expect(backend.resets.value == 0) + controller.setPaused(true) + #expect(backend.resets.value == 0) + controller.setPaused(false) + #expect(backend.resets.value == 1) + await controller.redetect() + #expect(backend.resets.value == 2) + } + @Test func onlyVisibleControlsReadAndOnlyKnownSupportedControlsWrite() async throws { let backend = TestDisplayBackend() let controller = DisplayController(backend: backend, catalog: [display])