From dc03cb3b0e64ee114e824280304835e9ef691322 Mon Sep 17 00:00:00 2001 From: ysicing Date: Sun, 4 Oct 2026 16:09:45 +0800 Subject: [PATCH] =?UTF-8?q?fix(display):=20=E5=A4=9A=E5=B1=8F=E4=B8=94?= =?UTF-8?q?=E6=97=A0=E6=B3=95=E8=AF=BB=E5=8F=96=20EDID=20=E6=97=B6?= =?UTF-8?q?=E6=8B=92=E7=BB=9D=20DDC=20=E6=9C=8D=E5=8A=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 读不到 proxy 的 EDID 时无法确认其归属,仅在只有一台外接屏时接受,多屏时拒绝,避免亮度、音量写到其他显示器。 --- ARCHITECTURE.md | 3 ++- .../Sources/XStatsUI/Displays/DDCProtocol.swift | 6 ++++++ .../Sources/XStatsUI/Displays/NativeDisplayDDC.swift | 9 +++++++-- .../Tests/XStatsUITests/DDCProtocolTests.swift | 10 ++++++++++ 4 files changed, 25 insertions(+), 3 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 13119ba..e24fa7a 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -616,7 +616,8 @@ control calls are not a Store-target compatibility claim; a future sandboxed tar backend. Services are matched by the CoreDisplay registry location, not by a guessed model name; 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. +(vendor, product, serial) disagrees with the CG display. An unreadable EDID is accepted only when a +single external display is online; with several, unverifiable proxies are rejected. 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. diff --git a/Packages/XStatsKit/Sources/XStatsUI/Displays/DDCProtocol.swift b/Packages/XStatsKit/Sources/XStatsUI/Displays/DDCProtocol.swift index 40028b5..24cc7da 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/Displays/DDCProtocol.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/Displays/DDCProtocol.swift @@ -101,6 +101,12 @@ struct EDIDSignature: Equatable, Sendable { serial = UInt32(bytes[12]) | UInt32(bytes[13]) << 8 | UInt32(bytes[14]) << 16 | UInt32(bytes[15]) << 24 } + /// 读不到 EDID 时无法确认 proxy 归属:只有一台外接屏时不存在错写对象,多屏则拒绝。 + static func verifies(_ observed: EDIDSignature?, expected: EDIDSignature, externalDisplays: Int) -> Bool { + guard let observed else { return externalDisplays == 1 } + return observed.matches(expected) + } + /// 序列号为 0 表示 EDID 未提供,此时只比较厂商与产品。 func matches(_ other: EDIDSignature) -> Bool { vendor == other.vendor && product == other.product diff --git a/Packages/XStatsKit/Sources/XStatsUI/Displays/NativeDisplayDDC.swift b/Packages/XStatsKit/Sources/XStatsUI/Displays/NativeDisplayDDC.swift index 7f17174..2f448ff 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/Displays/NativeDisplayDDC.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/Displays/NativeDisplayDDC.swift @@ -128,6 +128,10 @@ final class NativeDisplayDDC: DisplayDDCBackend, @unchecked Sendable { defer { IOObjectRelease(iterator) } let expected = EDIDSignature(vendor: CGDisplayVendorNumber(target.id), product: CGDisplayModelNumber(target.id), serial: CGDisplaySerialNumber(target.id)) + var online = [CGDirectDisplayID](repeating: 0, count: 16) + var onlineCount: UInt32 = 0 + guard CGGetOnlineDisplayList(UInt32(online.count), &online, &onlineCount) == .success else { return nil } + let externalDisplays = online.prefix(Int(onlineCount)).filter { CGDisplayIsBuiltin($0) == 0 }.count var matchingFramebuffer = false var candidates: [Service] = [] for _ in 0..<20_000 { @@ -144,7 +148,8 @@ final class NativeDisplayDDC: DisplayDDCBackend, @unchecked Sendable { } 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(), - edid(of: service, api: api)?.matches(expected) ?? true else { continue } + EDIDSignature.verifies(edid(of: service, api: api), expected: expected, + externalDisplays: externalDisplays) else { continue } let provider = IORegistryEntrySearchCFProperty(entry, kIOServicePlane, "EPICProviderClass" as CFString, nil, IOOptionBits(kIORegistryIterateParents | kIORegistryIterateRecursively)) as? String // 部分 M1/M2 HDMI 桥使用另一条路由地址;协议校验和仍使用标准 DDC 地址。 @@ -155,7 +160,7 @@ final class NativeDisplayDDC: DisplayDDCBackend, @unchecked Sendable { return candidates[0] } - /// 读不到 EDID(符号缺失或链路不提供)时返回 nil,由调用方保留位置匹配结果。 + /// 读不到 EDID(符号缺失或链路不提供)时返回 nil,由 EDIDSignature.verifies 决定是否接受。 private static func edid(of service: CFTypeRef, api: Functions) -> EDIDSignature? { guard let copyEDID = api.copyEDID else { return nil } var data: Unmanaged? diff --git a/Packages/XStatsKit/Tests/XStatsUITests/DDCProtocolTests.swift b/Packages/XStatsKit/Tests/XStatsUITests/DDCProtocolTests.swift index 8cc0183..4174e1f 100644 --- a/Packages/XStatsKit/Tests/XStatsUITests/DDCProtocolTests.swift +++ b/Packages/XStatsKit/Tests/XStatsUITests/DDCProtocolTests.swift @@ -49,6 +49,16 @@ struct DDCProtocolTests { #expect(target.matches(EDIDSignature(vendor: 4268, product: 41400, serial: 0))) } + @Test func unverifiableProxyIsOnlyAcceptedWithASingleExternalDisplay() { + let target = EDIDSignature(vendor: 4268, product: 41400, serial: 810_567_746) + let other = EDIDSignature(vendor: 4268, product: 41400, serial: 1) + #expect(EDIDSignature.verifies(nil, expected: target, externalDisplays: 1)) + #expect(!EDIDSignature.verifies(nil, expected: target, externalDisplays: 2)) + #expect(!EDIDSignature.verifies(nil, expected: target, externalDisplays: 0)) + #expect(EDIDSignature.verifies(target, expected: target, externalDisplays: 3)) + #expect(!EDIDSignature.verifies(other, expected: target, externalDisplays: 1)) + } + @Test func preservesFullRangeAndRejectsInvalidUserValues() { let value = DDCValue(current: 32768, maximum: 65535) #expect(DDCProtocol.parse(reply(current: 32768, maximum: 65535), control: .brightness) == .value(value))