Skip to content

Commit d7307ba

Browse files
authored
v1.4.4: discover which physical port a display answers on (Mac mini DDC fix) (#2)
v1.4.4: discover which physical port a display answers on (Mac mini DDC fix)
2 parents be26eb8 + c03149a commit d7307ba

5 files changed

Lines changed: 192 additions & 47 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
# Changelog
22

3-
## Unreleased
3+
## v1.4.4
44

55
### Changed
66
- **Apple silicon only** — release builds are now arm64-only (`ARCHS[sdk=macosx*] = arm64`, `EXCLUDED_ARCHS[sdk=macosx*] = x86_64` in `project.yml`). Apple's September 2026 guidance lets apps that require macOS 13+ drop Intel, and macOS 27 runs only on Apple silicon. Intel Macs should stay on v1.4.3.
77

88
### Fixed
9+
- **DDC on Macs with more than one display port (Mac mini, Mac Studio)** — the port a display is attached to is now discovered instead of assumed. Apple silicon publishes one `DCPAVServiceProxy` per physical port (HDMI, each Thunderbolt) even when the port is empty; with a single monitor Glint always talked to the first proxy, so a monitor on the "other" port (e.g. a Mac mini driving a DisplayPort monitor over Thunderbolt) saw every read fail and both rows showed *Not applicable*. Candidate ports are now ordered (last port that answered → EDID UUID match in the IORegistry → positional guess → unclaimed ports) and probed with a real DDC read until one replies; the winner is remembered per display and re-discovered on display reconfiguration. Writes go through the same resolution so they can't land on an empty or neighbouring port. Ports another connected display would claim positionally are skipped unless the EDID says otherwise, so a monitor without DDC can't fall through to its neighbour.
910
- **Two compiler warnings cleared** — the menu-bar visibility observer now asserts main-actor isolation (`MainActor.assumeIsolated`) instead of calling MainActor-isolated methods from a nonisolated closure, and the CoreAudio device-name lookup receives its +1 `CFString` as `Unmanaged` instead of forming a raw pointer to a `CFString` variable.
1011

1112
### Added

Glint/Resources/Info.plist

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,9 @@
1717
<key>CFBundlePackageType</key>
1818
<string>APPL</string>
1919
<key>CFBundleShortVersionString</key>
20-
<string>1.4.3</string>
20+
<string>1.4.4</string>
2121
<key>CFBundleVersion</key>
22-
<string>20</string>
22+
<string>21</string>
2323
<key>LSMinimumSystemVersion</key>
2424
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
2525
<key>LSUIElement</key>

Glint/Sources/Services/DDCService.swift

Lines changed: 184 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -210,62 +210,151 @@ final class DDCService: @unchecked Sendable {
210210

211211
// MARK: - Apple Silicon: IOAVService
212212

213-
/// Finds the IOAVService for a given display.
214-
/// Strategy: enumerate DCPAVServiceProxy services, skip those with Location=Embedded
215-
/// (built-in display), and return external services. For multi-monitor setups,
216-
/// caches a mapping of display ID to service index.
217-
private func avService(for displayID: CGDirectDisplayID) -> AnyObject? {
218-
guard let createFn = avCreateFn else { return nil }
213+
/// One external DCPAVServiceProxy (a physical HDMI / Thunderbolt display port) wrapped
214+
/// in an IOAVService, plus what the registry says about the display behind it.
215+
private struct AVServiceCandidate {
216+
let registryID: UInt64
217+
let service: AnyObject
218+
let index: Int
219+
}
220+
221+
/// Registry entry ID of the DCPAVServiceProxy that last answered DDC for each display.
222+
/// Machines such as the Mac mini publish one proxy per physical port even when the
223+
/// port is empty, so the display→port mapping has to be discovered, not assumed by index.
224+
private var resolvedProxyIDs: [CGDirectDisplayID: UInt64] = [:]
225+
226+
/// Forgets which port each display answered on. Call when displays are (re)connected —
227+
/// a display ID can come back on a different physical port.
228+
func invalidateServiceCache() {
229+
ddcQueue.sync { resolvedProxyIDs.removeAll() }
230+
}
231+
232+
/// Enumerates the external DCPAVServiceProxy services, ordered by how likely each is to
233+
/// be the port `displayID` is attached to:
234+
/// 1. the proxy that already answered DDC for this display (cached),
235+
/// 2. proxies whose dcp subtree publishes this display's EDID UUID,
236+
/// 3. the proxy at the display's position in CoreGraphics' external-display order
237+
/// (the historical guess),
238+
/// 4. proxies no other connected display would claim by that positional rule.
239+
/// Ports another display would claim positionally are left out unless the EDID says
240+
/// otherwise, so a display without DDC support can't fall through to its neighbour.
241+
private func avServiceCandidates(for displayID: CGDirectDisplayID) -> [AVServiceCandidate] {
242+
guard let createFn = avCreateFn else { return [] }
219243

220244
// Built-in displays don't support DDC
221245
if CGDisplayIsBuiltin(displayID) != 0 {
222246
log.log("DDC: Skipping built-in display \(displayID)")
223-
return nil
247+
return []
224248
}
225249

226250
var iter: io_iterator_t = 0
227-
guard let matching = IOServiceMatching("DCPAVServiceProxy") else { return nil }
228-
guard IOServiceGetMatchingServices(kIOMainPortDefault, matching, &iter) == KERN_SUCCESS else {
229-
return nil
251+
guard let matching = IOServiceMatching("DCPAVServiceProxy"),
252+
IOServiceGetMatchingServices(kIOMainPortDefault, matching, &iter) == KERN_SUCCESS else {
253+
return []
230254
}
231255
defer { IOObjectRelease(iter) }
232256

233-
// Collect all external (non-Embedded) services
234-
var externalServices: [io_service_t] = []
257+
// Collect all external (non-Embedded) proxies with their registry IDs and nearby EDID UUIDs.
258+
var externals: [(service: io_service_t, registryID: UInt64, edidUUIDs: Set<String>)] = []
235259
var service = IOIteratorNext(iter)
236260
while service != 0 {
237261
let location = registryString(for: "Location", in: service)
238-
let isEmbedded = location?.lowercased() == "embedded"
239-
240-
if !isEmbedded {
241-
externalServices.append(service)
242-
} else {
262+
if location?.lowercased() == "embedded" {
243263
IOObjectRelease(service)
264+
} else {
265+
var registryID: UInt64 = 0
266+
_ = IORegistryEntryGetRegistryEntryID(service, &registryID)
267+
externals.append((service, registryID, edidUUIDs(near: service)))
244268
}
245269
service = IOIteratorNext(iter)
246270
}
271+
defer { for external in externals { IOObjectRelease(external.service) } }
247272

248-
// If no external services found, return nil
249-
guard !externalServices.isEmpty else {
273+
guard !externals.isEmpty else {
250274
log.log("DDC: No external DCPAVServiceProxy services found")
251-
return nil
275+
return []
252276
}
253277

254-
// For single external display, just use it
255-
// For multiple externals, try to match by probing DDC — each display
256-
// reports its own EDID vendor/model via VCP, so we pick the first that works
257-
// (multi-monitor matching by index: external display order matches CGDisplay order)
258278
let externalDisplayIDs = Self.externalDisplayIDs()
259-
let targetIndex = externalDisplayIDs.firstIndex(of: displayID) ?? 0
260-
let serviceIndex = min(targetIndex, externalServices.count - 1)
279+
let guessIndex = min(externalDisplayIDs.firstIndex(of: displayID) ?? 0, externals.count - 1)
280+
// Indices the other connected displays would pick by the same positional rule.
281+
let claimedByOthers = Set((0..<min(externalDisplayIDs.count, externals.count)).filter { $0 != guessIndex })
282+
let targetUUID = Self.edidUUID(for: displayID)
283+
284+
var order: [Int] = []
285+
func append(_ index: Int) {
286+
if !order.contains(index) { order.append(index) }
287+
}
288+
if let cached = resolvedProxyIDs[displayID],
289+
let cachedIndex = externals.firstIndex(where: { $0.registryID == cached }) {
290+
append(cachedIndex)
291+
}
292+
if let uuid = targetUUID {
293+
for (index, external) in externals.enumerated() where external.edidUUIDs.contains(uuid) {
294+
append(index)
295+
}
296+
}
297+
append(guessIndex)
298+
for index in externals.indices where !claimedByOthers.contains(index) {
299+
append(index)
300+
}
261301

262-
let chosen = externalServices[serviceIndex]
263-
let avService = createFn(kCFAllocatorDefault, chosen)?.takeRetainedValue()
302+
log.log("DDC: display=\(displayID) uuid=\(targetUUID ?? "n/a") externalProxies=\(externals.count) externalDisplays=\(externalDisplayIDs.count) candidateOrder=\(order) proxyEDIDs=\(externals.map { Array($0.edidUUIDs).sorted() })")
264303

265-
log.log("DDC: display=\(displayID) serviceIndex=\(serviceIndex)/\(externalServices.count) avService=\(avService != nil ? "found" : "nil")")
304+
return order.compactMap { index -> AVServiceCandidate? in
305+
let external = externals[index]
306+
guard let avService = createFn(kCFAllocatorDefault, external.service)?.takeRetainedValue() else {
307+
log.log("DDC: IOAVServiceCreateWithService failed for proxy #\(index)")
308+
return nil
309+
}
310+
return AVServiceCandidate(registryID: external.registryID, service: avService, index: index)
311+
}
312+
}
266313

267-
for s in externalServices { IOObjectRelease(s) }
268-
return avService
314+
/// EDID UUIDs published in the registry subtree `proxy` belongs to (walking up to three
315+
/// ancestors). Each physical display port lives in its own dcp subtree, so a shared
316+
/// ancestor identifies the port; once an ancestor's subtree holds more than one proxy the
317+
/// walk has left the port and stops.
318+
private func edidUUIDs(near proxy: io_service_t) -> Set<String> {
319+
var current: io_registry_entry_t = proxy
320+
IOObjectRetain(current)
321+
defer { IOObjectRelease(current) }
322+
323+
for _ in 0..<3 {
324+
var parent: io_registry_entry_t = 0
325+
guard IORegistryEntryGetParentEntry(current, kIOServicePlane, &parent) == KERN_SUCCESS,
326+
parent != 0 else { break }
327+
IOObjectRelease(current)
328+
current = parent
329+
330+
var iter: io_iterator_t = 0
331+
guard IORegistryEntryCreateIterator(current, kIOServicePlane, IOOptionBits(kIORegistryIterateRecursively), &iter) == KERN_SUCCESS else { break }
332+
var proxyCount = 0
333+
var found = Set<String>()
334+
var child = IOIteratorNext(iter)
335+
while child != 0 {
336+
if IOObjectConformsTo(child, "DCPAVServiceProxy") != 0 {
337+
proxyCount += 1
338+
} else if let uuid = registryString(for: "EDID UUID", in: child) {
339+
found.insert(uuid.uppercased())
340+
}
341+
IOObjectRelease(child)
342+
child = IOIteratorNext(iter)
343+
}
344+
IOObjectRelease(iter)
345+
346+
if proxyCount > 1 { break } // ancestor spans several ports — no longer port-specific
347+
if !found.isEmpty { return found }
348+
}
349+
return []
350+
}
351+
352+
/// CoreGraphics' UUID for a display. It is derived from the EDID, so it matches the
353+
/// "EDID UUID" the DCP driver publishes in the IORegistry.
354+
private static func edidUUID(for displayID: CGDirectDisplayID) -> String? {
355+
guard let uuid = CGDisplayCreateUUIDFromDisplayID(displayID)?.takeRetainedValue(),
356+
let string = CFUUIDCreateString(kCFAllocatorDefault, uuid) else { return nil }
357+
return (string as String).uppercased()
269358
}
270359

271360
/// Returns ordered list of external display IDs (non-built-in).
@@ -278,10 +367,48 @@ final class DDCService: @unchecked Sendable {
278367
.filter { CGDisplayIsBuiltin($0) == 0 }
279368
}
280369

370+
private func remember(_ candidate: AVServiceCandidate, for displayID: CGDirectDisplayID) {
371+
guard resolvedProxyIDs[displayID] != candidate.registryID else { return }
372+
resolvedProxyIDs[displayID] = candidate.registryID
373+
log.log("DDC: display=\(displayID) answers on DCPAVServiceProxy #\(candidate.index) (registryID=0x\(String(candidate.registryID, radix: 16)))")
374+
}
375+
376+
/// Picks the port a write should go to. Prefers the port that already answered a read for
377+
/// this display; otherwise probes the candidates with a read (the VCP being written, then
378+
/// brightness) so the write can't land on an empty or neighbouring port. Falls back to the
379+
/// first candidate when nothing answers (write-only monitors).
380+
private func resolveWriteTarget(
381+
from candidates: [AVServiceCandidate],
382+
for displayID: CGDirectDisplayID,
383+
command: UInt8
384+
) -> AVServiceCandidate? {
385+
guard let first = candidates.first else { return nil }
386+
if let cached = resolvedProxyIDs[displayID],
387+
let candidate = candidates.first(where: { $0.registryID == cached }) {
388+
return candidate
389+
}
390+
if candidates.count == 1 { return first }
391+
392+
var probes = [command]
393+
if command != VCPCode.brightness.rawValue { probes.append(VCPCode.brightness.rawValue) }
394+
for vcp in probes {
395+
for candidate in candidates {
396+
if avServiceRead(command: vcp, on: candidate.service) != nil {
397+
remember(candidate, for: displayID)
398+
usleep(busCooldownMicros)
399+
return candidate
400+
}
401+
}
402+
}
403+
log.log("DDC: no port answered a probe read for display \(displayID) — writing to candidate #\(first.index)")
404+
return first
405+
}
406+
281407
private func avServiceWrite(command: UInt8, value: UInt16, displayID: CGDirectDisplayID) -> Bool {
282-
guard let writeFn = avWriteI2CFn,
283-
let service = avService(for: displayID) else {
284-
print("[Glint] DDC: No IOAVService found for display \(displayID)")
408+
guard let writeFn = avWriteI2CFn else { return false }
409+
let candidates = avServiceCandidates(for: displayID)
410+
guard let target = resolveWriteTarget(from: candidates, for: displayID, command: command) else {
411+
log.log("DDC: No IOAVService found for display \(displayID)")
285412
return false
286413
}
287414

@@ -300,23 +427,38 @@ final class DDCService: @unchecked Sendable {
300427
data.append(checksum)
301428

302429
let result = data.withUnsafeMutableBufferPointer { buffer -> IOReturn in
303-
writeFn(service, 0x37, 0x51, buffer.baseAddress!, UInt32(buffer.count))
430+
writeFn(target.service, 0x37, 0x51, buffer.baseAddress!, UInt32(buffer.count))
304431
}
305432

306433
if result == KERN_SUCCESS {
307434
usleep(50_000)
308435
return true
309436
}
310-
print("[Glint] DDC write failed: \(result)")
437+
log.log("DDC write failed on proxy #\(target.index): \(result)")
311438
return false
312439
}
313440

441+
/// Reads a VCP from whichever external port answers for `displayID`, trying the
442+
/// candidates in likelihood order and remembering the one that replied.
314443
private func avServiceRead(command: UInt8, displayID: CGDirectDisplayID) -> DDCReadResult? {
315-
guard let writeFn = avWriteI2CFn, let readFn = avReadI2CFn,
316-
let service = avService(for: displayID) else {
317-
print("[Glint] DDC: No IOAVService found for display \(displayID)")
444+
let candidates = avServiceCandidates(for: displayID)
445+
guard !candidates.isEmpty else {
446+
log.log("DDC: No IOAVService found for display \(displayID)")
318447
return nil
319448
}
449+
for candidate in candidates {
450+
if let result = avServiceRead(command: command, on: candidate.service) {
451+
remember(candidate, for: displayID)
452+
return result
453+
}
454+
}
455+
return nil
456+
}
457+
458+
/// One DDC GET VCP round-trip on a specific IOAVService. Returns nil when the port has no
459+
/// display, the display doesn't answer, or the reply doesn't echo the requested VCP.
460+
private func avServiceRead(command: UInt8, on service: AnyObject) -> DDCReadResult? {
461+
guard let writeFn = avWriteI2CFn, let readFn = avReadI2CFn else { return nil }
320462

321463
// Step 1: Send GET VCP Feature request
322464
var sendData: [UInt8] = [
@@ -333,7 +475,7 @@ final class DDCService: @unchecked Sendable {
333475
}
334476

335477
guard writeResult == KERN_SUCCESS else {
336-
print("[Glint] DDC read (write phase) failed: \(writeResult)")
478+
log.log("DDC read (write phase) failed: \(writeResult)")
337479
return nil
338480
}
339481

@@ -347,7 +489,7 @@ final class DDCService: @unchecked Sendable {
347489
}
348490

349491
guard readResult == KERN_SUCCESS else {
350-
print("[Glint] DDC read (read phase) failed: \(readResult)")
492+
log.log("DDC read (read phase) failed: \(readResult)")
351493
return nil
352494
}
353495

@@ -357,7 +499,7 @@ final class DDCService: @unchecked Sendable {
357499
guard let replyStart = replyData.firstIndex(of: 0x02),
358500
replyStart + 8 <= replyData.count,
359501
replyData[replyStart + 2] == command else {
360-
print("[Glint] DDC read: invalid reply for VCP 0x\(String(command, radix: 16))")
502+
log.log("DDC read: invalid reply for VCP 0x\(String(command, radix: 16)): \(replyData.map { String($0, radix: 16) })")
361503
return nil
362504
}
363505

Glint/Sources/Services/DisplayManager.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,8 @@ final class DisplayManager: ObservableObject, @unchecked Sendable {
7575

7676
func refresh() {
7777
let log = DebugLogger.shared
78+
// Displays may have moved to a different physical port — re-discover which port answers.
79+
ddc.invalidateServiceCache()
7880
var displayIDs = [CGDirectDisplayID](repeating: 0, count: 16)
7981
var displayCount: UInt32 = 0
8082
CGGetActiveDisplayList(16, &displayIDs, &displayCount)

project.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,8 @@ targets:
4343
properties:
4444
CFBundleName: Glint
4545
CFBundleIconName: AppIcon
46-
CFBundleShortVersionString: "1.4.3"
47-
CFBundleVersion: "20"
46+
CFBundleShortVersionString: "1.4.4"
47+
CFBundleVersion: "21"
4848
LSUIElement: true
4949
NSHumanReadableCopyright: "Copyright © 2026 Blaine Miller. MIT License."
5050
LSMinimumSystemVersion: $(MACOSX_DEPLOYMENT_TARGET)

0 commit comments

Comments
 (0)