-
-
Notifications
You must be signed in to change notification settings - Fork 179
Expand file tree
/
Copy pathSourcePIDCache.swift
More file actions
635 lines (573 loc) · 29.3 KB
/
Copy pathSourcePIDCache.swift
File metadata and controls
635 lines (573 loc) · 29.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
//
// SourcePIDCache.swift
// Project: Thaw
//
// Copyright (Ice) © 2023–2025 Jordan Baird
// Copyright (Thaw) © 2026 Toni Förster
// Licensed under the GNU GPLv3
@preconcurrency import AXSwift
import Cocoa
import Combine
import os
/// A cache for the source process identifiers for menu bar item windows.
///
/// We use the term "source process" to refer to the process that created
/// a menu bar item. Originally, we used the CGWindowList API to get the
/// window's owning process (`kCGWindowOwnerPID`), which was always the
/// source process. However, as of macOS 26, item windows are owned by
/// the Control Center.
///
/// We can find what we need using the Accessibility API, but doing it
/// efficiently ends up being a fairly complex process. Since calls to
/// Accessibility are thread blocking, we do most of the heavy lifting
/// in a dedicated XPC service, which we then call asynchronously from
/// the main app.
final class SourcePIDCache {
private static let diagLog = DiagLog(category: "SourcePIDCache")
/// An object that contains a running application and provides an
/// interface to access relevant information, such as its process
/// identifier and extras menu bar.
private final class CachedApplication: @unchecked Sendable {
private let runningApp: NSRunningApplication
private struct State {
var extrasMenuBar: UIElement?
var checkedWithNoResult = false
}
private let lock = OSAllocatedUnfairLock(initialState: State())
/// The app's process identifier.
var processIdentifier: pid_t {
runningApp.processIdentifier
}
/// The app's bundle identifier, if any. Used by diagnostic
/// logging to identify which app's AX extras a frame came from.
var bundleIdentifier: String? {
runningApp.bundleIdentifier
}
/// A localized, human-readable name for the app. Used by
/// diagnostic logging when the bundle identifier is absent.
var localizedName: String? {
runningApp.localizedName
}
/// A Boolean value indicating whether the app's extras menu
/// bar has been successfully created and stored.
var hasExtrasMenuBar: Bool {
lock.withLock { $0.extrasMenuBar != nil }
}
/// A Boolean value indicating whether the app is in a valid
/// state for making accessibility calls.
private var isValidForAccessibility: Bool {
// These checks help prevent blocking that can occur when
// calling AX APIs while the app is an invalid state.
runningApp.isFinishedLaunching &&
!runningApp.isTerminated &&
!Bridging.isProcessUnresponsive(processIdentifier)
}
/// Creates a `CachedApplication` instance with the given running
/// application.
init(_ runningApp: NSRunningApplication) {
self.runningApp = runningApp
}
/// Returns the accessibility element representing the app's extras
/// menu bar, creating it if necessary.
///
/// When the element is first created, it gets stored for efficient
/// access on subsequent calls.
func getOrCreateExtrasMenuBar() -> UIElement? {
// Fast path: check cached state under the lock first.
let (hasCached, isNegative) = lock.withLock {
($0.extrasMenuBar, $0.checkedWithNoResult)
}
if let bar = hasCached {
return bar
}
if isNegative {
return nil
}
guard isValidForAccessibility else {
// Transient condition (still launching, unresponsive, or
// terminated). Do NOT set negative cache — retry next scan.
return nil
}
// Slow path: AX API calls performed outside the lock to
// avoid holding it during blocking IPC.
guard
let app = AXHelpers.application(for: runningApp),
let bar = AXHelpers.extrasMenuBar(for: app)
else {
// App is reachable but has no extras menu bar.
lock.withLock {
if $0.extrasMenuBar == nil {
$0.checkedWithNoResult = true
}
}
return nil
}
lock.withLock { $0.extrasMenuBar = bar }
return bar
}
/// Resets the negative cache so the app will be re-checked
/// on the next scan. Called during cleanup to discover apps
/// that register status items after launch. Preserves a
/// valid `extrasMenuBar` to avoid unnecessary AX re-queries.
func resetNegativeCache() {
lock.withLock {
if $0.extrasMenuBar == nil {
$0.checkedWithNoResult = false
}
}
}
}
/// State for the cache.
private struct State {
var apps = [CachedApplication]()
var pids = [CGWindowID: pid_t]()
/// Reorders the cached apps so that those that are confirmed
/// to have an extras menu bar are first in the array.
mutating func partitionApps() {
var lhs = [CachedApplication]()
var rhs = [CachedApplication]()
for app in apps {
if app.hasExtrasMenuBar {
lhs.append(app)
} else {
rhs.append(app)
}
}
apps = lhs + rhs
}
}
/// The shared cache.
static nonisolated(unsafe) let shared = SourcePIDCache()
/// The cache's protected state.
private let state = OSAllocatedUnfairLock(initialState: State())
/// Lock to prevent multiple concurrent full scans of all applications.
private let scanLock = OSAllocatedUnfairLock(initialState: ())
/// Observer for running applications.
private lazy var cancellable: AnyCancellable = {
let runningAppsPublisher = NSWorkspace.shared.publisher(for: \.runningApplications)
.map { _ in () }
let timerPublisher = Timer.publish(every: 300, on: .main, in: .default)
.autoconnect()
.map { _ in () }
return Publishers.Merge(runningAppsPublisher, timerPublisher)
.sink { [weak self] in
self?.performCleanup()
}
}()
/// Creates the shared cache.
private init() {
Bridging.setProcessUnresponsiveTimeout(3)
}
/// Performs cleanup of the cache state.
private func performCleanup() {
autoreleasepool {
performCleanupBody()
}
}
private func performCleanupBody() {
let runningApps = NSWorkspace.shared.runningApplications
SourcePIDCache.diagLog.debug("Performing PID cache cleanup")
let windowIDs = Bridging.getMenuBarWindowList(option: .itemsOnly)
let currentAppPids = Set(runningApps.map(\.processIdentifier))
let reusedApps = state.withLock { state -> [CachedApplication] in
// Clean up entries for terminated apps to prevent memory leaks
let oldAppPids = Set(state.apps.map(\.processIdentifier))
let terminatedPids = oldAppPids.subtracting(currentAppPids)
// Remove PID mappings for terminated apps
for terminatedPid in terminatedPids {
state.pids = state.pids.filter { $0.value != terminatedPid }
}
// Convert the cached state to dictionaries keyed by pid to
// allow for efficient repeated access.
let appMappings = state.apps.reduce(into: [:]) { result, app in
result[app.processIdentifier] = app
}
let pidMappings: [pid_t: [CGWindowID: pid_t]] = windowIDs.reduce(into: [:]) { result, windowID in
if let pid = state.pids[windowID] {
result[pid, default: [:]][windowID] = pid
}
}
// Collect reused apps to reset their negative caches after
// releasing the lock.
var reused = [CachedApplication]()
// Create a new state that matches the current running apps.
state = runningApps.reduce(into: State()) { result, app in
let pid = app.processIdentifier
if let app = appMappings[pid] {
// Prefer the cached app, as it may have already done
// the work to initialize its extras menu bar.
reused.append(app)
result.apps.append(app)
} else {
// App wasn't in the cache, so it must be new.
result.apps.append(CachedApplication(app))
}
if let pids = pidMappings[pid] {
for (windowID, pid) in pids {
result.pids[windowID] = pid
}
}
}
// Log cleanup activity
if !terminatedPids.isEmpty {
SourcePIDCache.diagLog.info("Cleaned up PID cache entries for terminated processes: \(terminatedPids)")
}
return reused
}
// Reset negative caches outside the state lock so we don't
// hold the unfair lock while acquiring per-app locks.
for app in reusedApps {
app.resetNegativeCache()
}
}
/// Starts the observers for the cache.
func start() {
SourcePIDCache.diagLog.debug("Starting observers for source PID cache")
_ = cancellable
}
/// Returns the cached process identifier for the given window,
/// updating the cache if needed.
func pid(for window: WindowInfo) -> pid_t? {
// Wrap the entire request in an autoreleasepool. This XPC service
// has no NSApplication, so autoreleased ObjC/CF objects from
// WindowInfo creation, AX API calls, and CGS bridging would
// otherwise accumulate on the GCD thread until process exit.
autoreleasepool {
pidBody(for: window)
}
}
/// Returns the cached process identifiers for the given windows,
/// performing a single batch resolution if any are missing.
///
/// `pidBody` already caches **all** matched windows during its full
/// AX scan, so after one call all resolvable PIDs are available.
func pids(for windows: [WindowInfo]) -> [pid_t?] {
autoreleasepool {
pidsBody(for: windows)
}
}
private func pidsBody(for windows: [WindowInfo]) -> [pid_t?] {
// Drive the scan via an unresolved window in the batch, not via
// `windows.first`. pidBody returns early on a cache hit (line 292),
// so passing a cached window skips the AX traversal entirely.
// Once macOS 26 began routing some widgets through the marker-pair
// fallback that lives in pidBody's scan body, mid-session arrivals
// (new app launches that introduce a fresh nil-PID windowID) were
// never getting a scan: the first window in their batch was always
// an already-cached resolved one, and the scan only ever ran at
// session start.
if let unresolved = windows.first(where: { window in
state.withLock { $0.pids[window.windowID] == nil }
}) {
_ = pidBody(for: unresolved)
}
return windows.map { window in
state.withLock { $0.pids[window.windowID] }
}
}
private func pidBody(for window: WindowInfo) -> pid_t? {
if let pid = state.withLock({ $0.pids[window.windowID] }) {
SourcePIDCache.diagLog.debug("SourcePIDCache.pid: cache hit for windowID \(window.windowID) -> PID \(pid)")
return pid
}
SourcePIDCache.diagLog.debug("SourcePIDCache.pid: cache miss for windowID \(window.windowID) title=\(window.title ?? "nil"), acquiring scan lock")
// Use a lock to ensure that only one thread performs the full AX traversal.
// This is critical when resolving many windows (e.g. 64) concurrently.
scanLock.lock()
defer { scanLock.unlock() }
// Re-check cache after acquiring the scan lock, as it may have been populated
// by another thread that just finished a full scan.
if let pid = state.withLock({ $0.pids[window.windowID] }) {
SourcePIDCache.diagLog.debug("SourcePIDCache.pid: cache hit after scan lock for windowID \(window.windowID) -> PID \(pid)")
return pid
}
let isTrusted = AXHelpers.isProcessTrusted()
guard isTrusted else {
SourcePIDCache.diagLog.warning("SourcePIDCache.pid: AXHelpers.isProcessTrusted() returned false — accessibility permission missing in XPC service")
return nil
}
SourcePIDCache.diagLog.debug("SourcePIDCache.pid: performing batch resolution via AX API")
// Fetch all current menu bar item windows to perform a single batch resolution.
// This avoids doing the O(W*A*C) work (Windows * Apps * Children) for every request.
let allWindows = WindowInfo.createMenuBarWindows(option: .itemsOnly)
SourcePIDCache.diagLog.debug("SourcePIDCache.pid: batch resolving for \(allWindows.count) windows")
// Get a copy of the apps list to iterate over without holding the state lock.
let apps = state.withLock { state -> [CachedApplication] in
state.partitionApps()
return state.apps
}
let ccBundleID = "com.apple.controlcenter"
var appsChecked = 0
var appsWithBar = 0
var totalChildrenChecked = 0
var totalMatchesFound = 0
var unresolvedWindows = Set(allWindows.map(\.windowID))
for app in apps {
if unresolvedWindows.isEmpty {
break
}
appsChecked += 1
autoreleasepool {
guard let bar = app.getOrCreateExtrasMenuBar() else {
return
}
appsWithBar += 1
let children = AXHelpers.children(for: bar)
for child in children {
totalChildrenChecked += 1
// Skip only children the app marks explicitly disabled. A
// missing AXEnabled attribute (nil) is treated as enabled:
// some status items hosted by Control Center (The Clock's
// among them) never publish AXEnabled, and treating absent as
// disabled would drop an otherwise exact positional match and
// leave the item unresolved.
guard AXHelpers.enabledAttribute(child) != false,
let childFrame = AXHelpers.frame(for: child)
else {
continue
}
let childCenter = childFrame.center
// Match this child to ANY window in our list, but skip
// Control-Center-hosted generic slots. Control Center is the
// CG owner for every CC-hosted NSStatusItem. When the matched
// app is Control Center and the window title is a generic
// Item-N slot, the spatial match only confirms the window is
// CC-hosted; it does not identify the owning app. Writing
// Control Center's PID would tag the item as a transient CC
// widget (isTransientControlCenterItem true, canBeHidden
// false), hiding it from profile management and the
// virtual-display provoke's orphan scan. Leaving it
// unresolved lets the marker-pair pass below supply the real
// owner PID; named CC items (BentoBox-0, Clock, WiFi,
// NowPlaying) carry non-generic titles and resolve to Control
// Center normally.
if let matchedWindow = allWindows.first(where: {
$0.bounds.center.distance(to: childCenter) <= 1
}), !MarkerPairResolver.isCCHostedGenericSlot(
appBundleID: app.bundleIdentifier,
windowTitle: matchedWindow.title,
ccBundleID: ccBundleID
) {
totalMatchesFound += 1
unresolvedWindows.remove(matchedWindow.windowID)
let pid = app.processIdentifier
state.withLock { $0.pids[matchedWindow.windowID] = pid }
}
}
}
}
// Corroborated spatial fallback for Control-Center-hosted items
// whose own app DOES publish an extras-bar AX child, but offset from
// the CG window center by more than the strict 1pt pass tolerates.
// The hosting CG slot is wider than the real icon, so their centers
// diverge: AirBuddy's by ~2pt, SpamSieve's by up to ~8pt. Accept the
// nearest such child within a generous radius ONLY when the window's
// reverse-DNS title is in an owner relationship with the app's bundle
// identifier (HostedItemOwnership). The title corroboration, not the
// distance, is what makes this safe: a nearby unrelated neighbor
// (WireGuard's slot beside Updatest at ~2pt) fails the owner check and
// is left for later passes. Runs BEFORE marker-pair so items that have
// their own AX child are claimed here and never reach that fallback.
// Empirically the furthest correct owner-corroborated match across
// captured logs is ~15pt; 20 leaves margin while staying well inside
// a neighbor's slot. The owner check is the real guard.
let hostedExtrasMatchRadius: CGFloat = 20
for app in apps {
if unresolvedWindows.isEmpty { break }
guard let appBundleID = app.bundleIdentifier else { continue }
let candidateWindows = allWindows.filter {
unresolvedWindows.contains($0.windowID)
&& HostedItemOwnership.titleIndicatesOwner($0.title, bundleID: appBundleID)
}
guard !candidateWindows.isEmpty else { continue }
autoreleasepool {
guard let bar = app.getOrCreateExtrasMenuBar() else { return }
let childCenters = AXHelpers.children(for: bar).compactMap { child -> CGPoint? in
guard AXHelpers.enabledAttribute(child) != false,
let frame = AXHelpers.frame(for: child)
else {
return nil
}
return frame.center
}
guard !childCenters.isEmpty else { return }
for window in candidateWindows {
let target = window.bounds.center
let nearest = childCenters.lazy.map { $0.distance(to: target) }.min()
?? .greatestFiniteMagnitude
guard nearest <= hostedExtrasMatchRadius else { continue }
totalMatchesFound += 1
unresolvedWindows.remove(window.windowID)
state.withLock { $0.pids[window.windowID] = app.processIdentifier }
}
}
}
// Marker-pair PID resolution.
//
// On macOS 26 some widgets (Little Snitch's agent observed in
// the wild) have their NSStatusItem hosted by Control Center
// at the AX layer and do not publish an AXExtrasMenuBar of
// their own. The spatial CG-to-AX pass above cannot find a
// per-app extras child for them, so the icon stays unresolved
// and the namespace falls back to com.apple.controlcenter.
//
// Structurally, every NSStatusItem-style widget also publishes
// a SECOND CG window in the items-only list whose title is
// the widget's bundle identifier (verified empirically for
// at.obdev.littlesnitch.agent, com.rogueamoeba.soundsource,
// com.wireguard.macos, org.eduvpn.app, com.lighting.huesync,
// pl.maketheweb.cleanshotx, and others). This marker window
// has the same (width, height) as the on-screen icon but
// its position is non-deterministic across launches and can
// even sit on a different display, which is why this pass
// runs here in the XPC where allWindows spans every display
// rather than in the main app's per-call list.
//
// For each unresolved on-screen icon whose title is NOT
// bundle-ID-shaped (generic names like "Item-0", or empty),
// looks for the unique marker window with matching size and
// synthesizes the sourcePID by either using the marker's
// CG-layer owning PID (when it is neither Thaw itself nor
// Control Center) or by looking up the running app named by
// the marker's bundle-ID title. Multi-match cases are skipped
// to prevent misattribution. Thaw's own control items and
// self-registration windows are excluded so Thaw's PID can
// never be attributed to a third-party widget.
if !unresolvedWindows.isEmpty {
let thawBundleID = "com.stonerl.Thaw"
let markers = MarkerPairResolver.extractMarkers(
from: allWindows.map { win in
(
windowID: win.windowID,
title: win.title,
size: win.bounds.size,
owningPID: win.owningApplication?.processIdentifier
)
},
thawControlItemPrefix: "Thaw.ControlItem.",
thawBundleID: thawBundleID
)
let unresolvedInfos = allWindows.filter { unresolvedWindows.contains($0.windowID) }
let icons = unresolvedInfos.map { win in
MarkerPairResolver.UnresolvedIcon(
windowID: win.windowID,
title: win.title,
size: win.bounds.size
)
}
let resolutions = MarkerPairResolver.resolve(
unresolvedIcons: icons,
markers: markers,
thawBundleID: thawBundleID,
ccBundleID: ccBundleID,
pidToBundleID: { pid in
NSRunningApplication(processIdentifier: pid)?.bundleIdentifier
},
bundleIDToPID: { bundleID in
NSRunningApplication
.runningApplications(withBundleIdentifier: bundleID)
.first?
.processIdentifier
}
)
for resolution in resolutions {
SourcePIDCache.diagLog.info(
"SourcePIDCache marker-pair resolution: windowID=\(resolution.iconWindowID) → PID \(resolution.resolvedPID) via marker windowID=\(resolution.markerWindowID) (title=\(resolution.markerTitle))"
)
state.withLock { $0.pids[resolution.iconWindowID] = resolution.resolvedPID }
unresolvedWindows.remove(resolution.iconWindowID)
}
}
let finalPID = state.withLock { $0.pids[window.windowID] }
SourcePIDCache.diagLog.debug("SourcePIDCache.pid: batch resolution finished. Found \(totalMatchesFound) matches. Requested windowID \(window.windowID) -> PID \(finalPID.map { "\($0)" } ?? "nil") (checked \(appsChecked) apps, \(appsWithBar) with extras bar, \(totalChildrenChecked) children)")
// Diagnostic dump for unresolved windows.
//
// When at least one window remains unresolved after the batch
// loop, log enough state to determine which of three failure
// modes is hitting: (a) the suspect app is absent from
// NSWorkspace runningApplications, (b) the app is present but
// does not expose AXExtrasMenuBar (the per-app menu extras
// attribute is unset on macOS 26 for some widgets), or (c)
// the app exposes extras but their frames are more than 1pt
// off-center from the unresolved CG window bounds (a HiDPI,
// multi-display, or coord-system mismatch).
//
// Quiet path on normal cycles where every window resolves.
// The diagnostic re-walks AX children, which can be expensive,
// so it only fires when there is actual unresolved state.
if !unresolvedWindows.isEmpty {
SourcePIDCache.diagLog.debug(
"SourcePIDCache diag: \(unresolvedWindows.count) window(s) unresolved after batch, dumping details"
)
// Ad-hoc probe for specific bundles under investigation.
// Leave empty in normal builds; populate with bundle IDs
// when diagnosing a particular widget's resolution failure
// to see whether NSWorkspace sees it and whether it claims
// an extras menu bar of its own.
let probeBundleIDs: Set<String> = []
for bundleID in probeBundleIDs {
if let app = apps.first(where: { $0.bundleIdentifier == bundleID }) {
SourcePIDCache.diagLog.debug(
"SourcePIDCache diag probe: \(bundleID) PRESENT pid=\(app.processIdentifier) hasExtrasBar=\(app.hasExtrasMenuBar)"
)
} else {
SourcePIDCache.diagLog.debug(
"SourcePIDCache diag probe: \(bundleID) ABSENT from runningApplications"
)
}
}
let unresolvedWindowInfos = allWindows.filter { unresolvedWindows.contains($0.windowID) }
for window in unresolvedWindowInfos {
let target = window.bounds.center
// Collect every extras-bar child across all apps as a candidate,
// not just the single closest, so the diagnostic shows whether the
// nearest match is unique or whether a competing child sits within
// the match radius. Paired with each candidate's enabled state and
// distance, this is usually enough to see why an item failed to
// resolve (wrong distance, missing AXEnabled, or ambiguity).
var candidates: [(distance: CGFloat, label: String, frame: CGRect, enabled: Bool?)] = []
for app in apps {
guard let bar = app.getOrCreateExtrasMenuBar() else { continue }
let label = app.bundleIdentifier ?? app.localizedName ?? "pid=\(app.processIdentifier)"
for child in AXHelpers.children(for: bar) {
guard let frame = AXHelpers.frame(for: child) else { continue }
candidates.append((frame.center.distance(to: target), label, frame, AXHelpers.enabledAttribute(child)))
}
}
let nearest = candidates.sorted { $0.distance < $1.distance }
let best = nearest.first
let cgOwner = window.owningApplication.map { app in
"\(app.bundleIdentifier ?? app.localizedName ?? "?"):pid=\(app.processIdentifier)"
} ?? "nil"
// closestAXEnabled distinguishes a missing AXEnabled attribute (nil)
// from an explicitly disabled child, and nearest lists the top
// candidates with their owning app and enabled state, so a future
// unresolved item can be diagnosed from a single log line.
let nearestDesc = nearest.prefix(3).map {
"\($0.label)@\(String(format: "%.1f", $0.distance))(enabled=\($0.enabled.map { "\($0)" } ?? "nil"))"
}.joined(separator: ", ")
SourcePIDCache.diagLog.debug(
"SourcePIDCache diag unresolved: windowID=\(window.windowID) title=\(window.title ?? "nil") bounds=\(window.bounds) center=\(target) | cgOwner=\(cgOwner) ownerName=\(window.ownerName ?? "nil") | closestAXFrame=\(best.map { "\($0.frame)" } ?? "nil") in app=\(best?.label ?? "(none)") distance=\(best?.distance ?? .greatestFiniteMagnitude) closestAXEnabled=\(best?.enabled.map { "\($0)" } ?? "nil") | nearest=[\(nearestDesc)]"
)
}
for app in apps {
guard let bar = app.getOrCreateExtrasMenuBar() else { continue }
let children = AXHelpers.children(for: bar)
// Include each child's raw enabled value (nil = attribute absent)
// next to its frame, so a child the matching pass excluded as
// explicitly disabled is visible here.
let childDescs = children.compactMap { child -> String? in
guard let frame = AXHelpers.frame(for: child) else { return nil }
let enabled = AXHelpers.enabledAttribute(child).map { "\($0)" } ?? "nil"
return "(x=\(frame.minX),y=\(frame.minY),w=\(frame.width),h=\(frame.height),enabled=\(enabled))"
}
guard !childDescs.isEmpty else { continue }
let label = app.bundleIdentifier ?? app.localizedName ?? "pid=\(app.processIdentifier)"
SourcePIDCache.diagLog.debug(
"SourcePIDCache diag app=\(label) extrasBar children=\(children.count) frames=\(childDescs.joined(separator: " "))"
)
}
}
return finalPID
}
}