@npaw/forge-react-native 9.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/LICENSE +10 -0
  2. package/NPAWForgeReactNative.podspec +44 -0
  3. package/README.md +51 -0
  4. package/android/build.gradle +77 -0
  5. package/android/src/main/AndroidManifest.xml +14 -0
  6. package/android/src/main/java/com/npaw/forge/reactnative/ForgeDtoMapper.java +295 -0
  7. package/android/src/main/java/com/npaw/forge/reactnative/ForgeInstanceRegistry.java +61 -0
  8. package/android/src/main/java/com/npaw/forge/reactnative/NPAWForgeModule.java +735 -0
  9. package/android/src/main/java/com/npaw/forge/reactnative/NPAWForgePackage.java +53 -0
  10. package/android/src/test/java/com/npaw/forge/reactnative/ForgeDtoMapperTest.java +133 -0
  11. package/ios/ForgeApple/ForgeKernelFFI.xcframework/Info.plist +82 -0
  12. package/ios/ForgeApple/ForgeKernelFFI.xcframework/ios-arm64/Headers/ForgeKernelFFI.h +17 -0
  13. package/ios/ForgeApple/ForgeKernelFFI.xcframework/ios-arm64/Headers/forge_kernelFFI.h +873 -0
  14. package/ios/ForgeApple/ForgeKernelFFI.xcframework/ios-arm64/Headers/module.modulemap +7 -0
  15. package/ios/ForgeApple/ForgeKernelFFI.xcframework/ios-arm64/libForgeKernelFFI.a +0 -0
  16. package/ios/ForgeApple/ForgeKernelFFI.xcframework/ios-arm64_x86_64-simulator/Headers/ForgeKernelFFI.h +17 -0
  17. package/ios/ForgeApple/ForgeKernelFFI.xcframework/ios-arm64_x86_64-simulator/Headers/forge_kernelFFI.h +873 -0
  18. package/ios/ForgeApple/ForgeKernelFFI.xcframework/ios-arm64_x86_64-simulator/Headers/module.modulemap +7 -0
  19. package/ios/ForgeApple/ForgeKernelFFI.xcframework/ios-arm64_x86_64-simulator/libForgeKernelFFI.a +0 -0
  20. package/ios/ForgeApple/ForgeKernelFFI.xcframework/tvos-arm64/Headers/ForgeKernelFFI.h +17 -0
  21. package/ios/ForgeApple/ForgeKernelFFI.xcframework/tvos-arm64/Headers/forge_kernelFFI.h +873 -0
  22. package/ios/ForgeApple/ForgeKernelFFI.xcframework/tvos-arm64/Headers/module.modulemap +7 -0
  23. package/ios/ForgeApple/ForgeKernelFFI.xcframework/tvos-arm64/libForgeKernelFFI.a +0 -0
  24. package/ios/ForgeApple/ForgeKernelFFI.xcframework/tvos-arm64-simulator/Headers/ForgeKernelFFI.h +17 -0
  25. package/ios/ForgeApple/ForgeKernelFFI.xcframework/tvos-arm64-simulator/Headers/forge_kernelFFI.h +873 -0
  26. package/ios/ForgeApple/ForgeKernelFFI.xcframework/tvos-arm64-simulator/Headers/module.modulemap +7 -0
  27. package/ios/ForgeApple/ForgeKernelFFI.xcframework/tvos-arm64-simulator/libForgeKernelFFI.a +0 -0
  28. package/ios/ForgeApple/Sources/ForgeKernelUniFFI/forge_kernel.swift +3931 -0
  29. package/ios/ForgeApple/Sources/ForgeKernelUniFFI/forge_kernelFFI.h +873 -0
  30. package/ios/ForgeApple/Sources/ForgeKernelUniFFI/forge_kernelFFI.modulemap +7 -0
  31. package/ios/ForgeApple/Sources/NPAWForgeAVPlayerAdapter/AVPlayerAccessLogFacts.swift +152 -0
  32. package/ios/ForgeApple/Sources/NPAWForgeAVPlayerAdapter/AVPlayerAdapter.swift +877 -0
  33. package/ios/ForgeApple/Sources/NPAWForgeCapture/AttributionCapture.swift +158 -0
  34. package/ios/ForgeApple/Sources/NPAWForgeCapture/CapturePlanGate.swift +189 -0
  35. package/ios/ForgeApple/Sources/NPAWForgeCapture/CrashCapture.swift +120 -0
  36. package/ios/ForgeApple/Sources/NPAWForgeCapture/ForgeAutomaticCapture.swift +639 -0
  37. package/ios/ForgeApple/Sources/NPAWForgeCapture/SectionsCapture.swift +1176 -0
  38. package/ios/ForgeApple/Sources/NPAWForgeCapture/SectionsEngine.swift +674 -0
  39. package/ios/ForgeApple/Sources/NPAWForgeCapture/SectionsMarkers.swift +105 -0
  40. package/ios/ForgeApple/Sources/NPAWForgeCapture/SectionsSwiftUI.swift +284 -0
  41. package/ios/ForgeApple/Sources/NPAWForgeCapture/SwiftUICapture.swift +44 -0
  42. package/ios/ForgeApple/Sources/NPAWForgeCore/AdAdapter.swift +199 -0
  43. package/ios/ForgeApple/Sources/NPAWForgeCore/AdSemantics.swift +136 -0
  44. package/ios/ForgeApple/Sources/NPAWForgeCore/ForgeApple.swift +596 -0
  45. package/ios/ForgeApple/Sources/NPAWForgeCore/ForgeCapturePlan.swift +297 -0
  46. package/ios/ForgeApple/Sources/NPAWForgeCore/ForgeClock.swift +29 -0
  47. package/ios/ForgeApple/Sources/NPAWForgeCore/ForgeLogging.swift +109 -0
  48. package/ios/ForgeApple/Sources/NPAWForgeCore/ForgeOutcome.swift +89 -0
  49. package/ios/ForgeApple/Sources/NPAWForgeCore/ForgeRuntime.swift +291 -0
  50. package/ios/ForgeApple/Sources/NPAWForgeCore/ForgeStorage.swift +70 -0
  51. package/ios/ForgeApple/Sources/NPAWForgeCore/ManualAdReporter.swift +180 -0
  52. package/ios/ForgeApple/Sources/NPAWForgeCore/NPAWForge.swift +453 -0
  53. package/ios/ForgeApple/Sources/NPAWForgeCore/NativeUniFFIRuntime.swift +594 -0
  54. package/ios/ForgeApple/Sources/NPAWForgeCore/NetworkRequestRecord.swift +191 -0
  55. package/ios/ForgeApple/Sources/NPAWForgeCore/ObjectiveCWrappers.swift +191 -0
  56. package/ios/ForgeApple/Sources/NPAWForgeCore/PlayerAdapter.swift +299 -0
  57. package/ios/ForgeApple/Sources/NPAWForgeCore/PlayerSemantics.swift +266 -0
  58. package/ios/ForgeApple/Sources/NPAWForgeCore/PrivacySanitizer.swift +59 -0
  59. package/ios/ForgeApple/Sources/NPAWForgeCore/VideoAdapter.swift +478 -0
  60. package/ios/ForgeApple/Sources/NPAWForgeCore/VideoOptions.swift +431 -0
  61. package/ios/ForgeApple/Sources/NPAWForgeNetworkCapture/ForgeNetworkCapture.swift +116 -0
  62. package/ios/ForgeApple/Sources/NPAWForgeNetworkCaptureObjC/NPAWForgeNetworkSwizzler.m +293 -0
  63. package/ios/ForgeApple/Sources/NPAWForgeNetworkCaptureObjC/include/NPAWForgeNetworkSwizzler.h +33 -0
  64. package/ios/ForgeModuleImpl.swift +1131 -0
  65. package/ios/NPAWForgeTurboModule.mm +399 -0
  66. package/package.json +33 -0
  67. package/src/ForgeAdReporter.ts +101 -0
  68. package/src/ForgeVideo.ts +169 -0
  69. package/src/NPAWForge.ts +177 -0
  70. package/src/capture.ts +192 -0
  71. package/src/events.ts +40 -0
  72. package/src/index.ts +37 -0
  73. package/src/specs/NativeNPAWForge.ts +168 -0
  74. package/src/types.ts +114 -0
@@ -0,0 +1,1176 @@
1
+ /**
2
+ * Copyright (C) NPAW - All Rights Reserved
3
+ *
4
+ * This source code is protected under international copyright law. All rights
5
+ * reserved and protected by the copyright holders.
6
+ * This file is confidential and only available to authorized individuals with the
7
+ * permission of the copyright holders. If you encounter this file and do not have
8
+ * permission, please contact the copyright holders and delete this file.
9
+ *
10
+ *
11
+ * @author: Victor Soto <victor.soto@nicepeopleatwork.com>
12
+ * @description: F3 sections capture coordinator plus the UIKit registry/sampler.
13
+ * The platform-free coordinator owns the SectionsEngine lifecycle
14
+ * (installed while the plan SECTIONS bit is on, knobs hot-applied),
15
+ * screen-change flush ordering, SwiftUI snapshot feeds, dwell slot
16
+ * arming (tvOS focus and the C2 iPadOS hover seam) including the
17
+ * one-shot pending-arm retry consumed by the next full-set feed
18
+ * and wiped by dwell clears, and interaction enrichment. The
19
+ * UIKit-gated tracker owns weak view registrations
20
+ * (explicit Tier 3 API, Tier 1 accessibilityIdentifier markers,
21
+ * Tier 2 remote native-id lists), scroll KVO/orientation/foreground
22
+ * sampling signals with a 1 s safety timer and 100 ms throttled
23
+ * evaluation, per-registration iPadOS hover recognizers hit-testing
24
+ * the pointer to tracked items, and window-space visibility
25
+ * geometry behind an injectable reader seam.
26
+ */
27
+
28
+ import Foundation
29
+ #if canImport(NPAWForgeCore)
30
+ import NPAWForgeCore
31
+ #endif
32
+ #if canImport(UIKit)
33
+ import UIKit
34
+ #endif
35
+
36
+ /// Owns the sections engine for one automatic-capture handle. Thread-safe:
37
+ /// plan reconciliation and screen changes come from the main thread, feeds
38
+ /// come from samplers/modifiers, and enrichment can come from any caller.
39
+ public final class ForgeSectionsCoordinator {
40
+ private let lock = NSRecursiveLock()
41
+ private let emit: SectionsEngine.Emission
42
+ private let log: (String) -> Void
43
+ private var scheduler: SectionsScheduling
44
+ private var engine: SectionsEngine?
45
+ private var knobs: SectionsKnobs?
46
+ private var storedNativeSectionIds: Set<String> = []
47
+ private var storedNativeItemIds: Set<String> = []
48
+ /// One-shot pending-arm retry slot: a focus/hover arm can race the
49
+ /// throttled store refresh (the resolved key absent from the engine store
50
+ /// at arm time, making the engine arm a no-op), so the next full-set feed
51
+ /// re-issues the arm exactly once after the fresh feed lands. Cleared by
52
+ /// `clearDwell`/`hoverDidResolve(nil)` and on engine uninstall.
53
+ private var pendingDwellArmKey: String?
54
+ /// Discovery-order positions for marker/SwiftUI items that carry no
55
+ /// explicit position; reset on every screen change.
56
+ private var assignedPositions: [String: [String: Int]] = [:]
57
+ /// Persistent SwiftUI snapshot stores, independent of the engine
58
+ /// lifetime: modifiers feed them at any time (append order preserved for
59
+ /// first-seen ordering), a fresh engine install seeds from them so
60
+ /// cold-start feeds arriving before the first plan are not lost, and a
61
+ /// plan-off uninstall keeps them so a reinstall reseeds still-mounted
62
+ /// views without waiting for a re-mount.
63
+ private var swiftuiSectionStore: [SectionSnapshot] = []
64
+ private var swiftuiItemStore: [ItemSnapshot] = []
65
+
66
+ #if canImport(UIKit)
67
+ /// Public registration facade (Tier 3 explicit tracking). Registrations
68
+ /// are accepted at any time; sampling runs only while the plan keeps the
69
+ /// SECTIONS family installed.
70
+ public private(set) lazy var tracker = ForgeSectionsTracker(coordinator: self)
71
+ #endif
72
+
73
+ init(
74
+ emit: @escaping SectionsEngine.Emission,
75
+ log: @escaping (String) -> Void = { message in
76
+ NSLog("[NPAW-Forge-Apple] [Capture] %@", message)
77
+ },
78
+ scheduler: SectionsScheduling = ForgeMainQueueSectionsScheduler()
79
+ ) {
80
+ self.emit = emit
81
+ self.log = log
82
+ self.scheduler = scheduler
83
+ }
84
+
85
+ var isInstalled: Bool {
86
+ lock.lock()
87
+ defer { lock.unlock() }
88
+ return engine != nil
89
+ }
90
+
91
+ var nativeSectionIds: Set<String> {
92
+ lock.lock()
93
+ defer { lock.unlock() }
94
+ return storedNativeSectionIds
95
+ }
96
+
97
+ var nativeItemIds: Set<String> {
98
+ lock.lock()
99
+ defer { lock.unlock() }
100
+ return storedNativeItemIds
101
+ }
102
+
103
+ /// Test seam: swaps the scheduler used for the next engine install.
104
+ func setSchedulerForTesting(_ scheduler: SectionsScheduling) {
105
+ lock.lock()
106
+ self.scheduler = scheduler
107
+ lock.unlock()
108
+ }
109
+
110
+ /// Plan reconcile: SECTIONS bit on installs the engine (and the UIKit
111
+ /// sampler), knob changes hot-apply, and bit off uninstalls everything
112
+ /// and silently resets engine state (the persistent SwiftUI stores are
113
+ /// kept so a reinstall reseeds still-mounted views). Runs on the main
114
+ /// thread.
115
+ func reconcile(features: EffectiveCaptureFeatures) {
116
+ var activateSampler = false
117
+ var deactivateSampler = false
118
+ var engineToSeed: SectionsEngine?
119
+ var seedSections: [SectionSnapshot] = []
120
+ var seedItems: [ItemSnapshot] = []
121
+ lock.lock()
122
+ if features.sections, let plan = features.sectionsPlan {
123
+ let newKnobs = SectionsKnobs(plan: plan)
124
+ storedNativeSectionIds = Set(plan.sectionNativeIds)
125
+ storedNativeItemIds = Set(plan.itemNativeIds)
126
+ if let engine {
127
+ if knobs != newKnobs {
128
+ engine.updateKnobs(newKnobs)
129
+ }
130
+ } else {
131
+ let installed = SectionsEngine(knobs: newKnobs, scheduler: scheduler, emit: emit, log: log)
132
+ engine = installed
133
+ engineToSeed = installed
134
+ seedSections = swiftuiSectionStore
135
+ seedItems = swiftuiItemStore
136
+ activateSampler = true
137
+ }
138
+ knobs = newKnobs
139
+ } else if engine != nil {
140
+ engine?.reset()
141
+ engine = nil
142
+ knobs = nil
143
+ assignedPositions.removeAll()
144
+ pendingDwellArmKey = nil
145
+ deactivateSampler = true
146
+ }
147
+ lock.unlock()
148
+ // Seed the fresh engine from the persistent SwiftUI stores (outside
149
+ // the coordinator lock: emissions leave synchronously). Feeds that
150
+ // arrived while no engine was installed start the visible/impression
151
+ // flow here instead of waiting for a re-mount.
152
+ engineToSeed?.apply(source: .swiftui, sections: seedSections, items: seedItems)
153
+ #if canImport(UIKit)
154
+ if activateSampler {
155
+ tracker.activate()
156
+ }
157
+ if deactivateSampler {
158
+ tracker.deactivate()
159
+ }
160
+ #endif
161
+ }
162
+
163
+ /// Full teardown for handle uninstall: sampler off, engine silently
164
+ /// reset, persistent SwiftUI stores dropped.
165
+ func shutdown() {
166
+ lock.lock()
167
+ engine?.reset()
168
+ engine = nil
169
+ knobs = nil
170
+ assignedPositions.removeAll()
171
+ pendingDwellArmKey = nil
172
+ swiftuiSectionStore.removeAll()
173
+ swiftuiItemStore.removeAll()
174
+ lock.unlock()
175
+ #if canImport(UIKit)
176
+ tracker.deactivate()
177
+ #endif
178
+ }
179
+
180
+ // MARK: - Screen lifecycle
181
+
182
+ /// Called before the new screen context lands: flushes
183
+ /// `raw_section_hidden` for still-visible sections under the OLD screen
184
+ /// context first, then resets per-screen dedupe/order state.
185
+ func onScreenWillChange() {
186
+ lock.lock()
187
+ assignedPositions.removeAll()
188
+ let engineRef = engine
189
+ lock.unlock()
190
+ engineRef?.onScreenChanged()
191
+ }
192
+
193
+ /// Called after the new screen context is current: re-adopts markers and
194
+ /// re-emits whatever is visible under the new screen. The engine
195
+ /// revalidate stays synchronous on the calling thread (it emits from the
196
+ /// engine stores and preserves the flush-before-re-emit ordering); the
197
+ /// marker rescan walks UIApplication windows, so off-main callers hop it
198
+ /// to the main queue instead of doing UIKit work here.
199
+ func onScreenDidChange() {
200
+ guard let engineRef = currentEngine() else { return }
201
+ #if canImport(UIKit)
202
+ if Thread.isMainThread {
203
+ tracker.rescanWindowForMarkers()
204
+ tracker.scheduleEvaluateOnMain()
205
+ } else {
206
+ DispatchQueue.main.async { [weak self] in
207
+ guard let self, self.isInstalled else { return }
208
+ self.tracker.rescanWindowForMarkers()
209
+ self.tracker.scheduleEvaluateOnMain()
210
+ }
211
+ }
212
+ #endif
213
+ engineRef.revalidate()
214
+ }
215
+
216
+ // MARK: - Snapshot feeds
217
+
218
+ /// Full-set feed used by the UIKit sampler. After the fresh feed lands,
219
+ /// the one-shot pending dwell arm is consumed: a focus/hover arm that
220
+ /// raced the throttled refresh gets exactly one replay (re-arming an
221
+ /// already-armed key keeps its original deadline).
222
+ func applyFeed(source: SectionsFeedSource, sections: [SectionSnapshot], items: [ItemSnapshot]) {
223
+ guard let engineRef = currentEngine() else { return }
224
+ engineRef.apply(source: source, sections: sections, items: items)
225
+ lock.lock()
226
+ let pending = pendingDwellArmKey
227
+ pendingDwellArmKey = nil
228
+ lock.unlock()
229
+ if let pending {
230
+ engineRef.armDwell(itemKey: pending)
231
+ }
232
+ }
233
+
234
+ func updateSection(source: SectionsFeedSource, _ section: SectionSnapshot) {
235
+ if source == .swiftui {
236
+ mutateSwiftUIStores { sections, _ in
237
+ if let index = sections.firstIndex(where: { $0.key == section.key }) {
238
+ sections[index] = section
239
+ } else {
240
+ sections.append(section)
241
+ }
242
+ }
243
+ }
244
+ currentEngine()?.update(source: source, section: section)
245
+ }
246
+
247
+ func removeSection(source: SectionsFeedSource, key: String) {
248
+ if source == .swiftui {
249
+ mutateSwiftUIStores { sections, _ in
250
+ sections.removeAll { $0.key == key }
251
+ }
252
+ }
253
+ currentEngine()?.remove(source: source, sectionKey: key)
254
+ }
255
+
256
+ func updateItem(source: SectionsFeedSource, _ item: ItemSnapshot) {
257
+ if source == .swiftui {
258
+ mutateSwiftUIStores { _, items in
259
+ if let index = items.firstIndex(where: { $0.key == item.key }) {
260
+ items[index] = item
261
+ } else {
262
+ items.append(item)
263
+ }
264
+ }
265
+ }
266
+ currentEngine()?.update(source: source, item: item)
267
+ }
268
+
269
+ func removeItem(source: SectionsFeedSource, key: String) {
270
+ if source == .swiftui {
271
+ mutateSwiftUIStores { _, items in
272
+ items.removeAll { $0.key == key }
273
+ }
274
+ }
275
+ currentEngine()?.remove(source: source, itemKey: key)
276
+ }
277
+
278
+ /// Mutates the persistent SwiftUI stores under the coordinator lock. The
279
+ /// stores always track the latest reported SwiftUI set — with or without
280
+ /// an installed engine — so they are ready to seed the next install.
281
+ private func mutateSwiftUIStores(
282
+ _ body: (inout [SectionSnapshot], inout [ItemSnapshot]) -> Void
283
+ ) {
284
+ lock.lock()
285
+ body(&swiftuiSectionStore, &swiftuiItemStore)
286
+ lock.unlock()
287
+ }
288
+
289
+ /// Stable discovery-order position for items without an explicit one.
290
+ func assignedPosition(sectionKey: String, itemKey: String) -> Int {
291
+ lock.lock()
292
+ defer { lock.unlock() }
293
+ var positions = assignedPositions[sectionKey] ?? [:]
294
+ if let existing = positions[itemKey] {
295
+ return existing
296
+ }
297
+ let next = positions.count
298
+ positions[itemKey] = next
299
+ assignedPositions[sectionKey] = positions
300
+ return next
301
+ }
302
+
303
+ // MARK: - Dwell + enrichment
304
+
305
+ /// Arms the single-slot dwell and records the key in the one-shot
306
+ /// pending-arm slot so the next full-set feed replays an arm that raced
307
+ /// the throttled store refresh. No-op while the engine is uninstalled.
308
+ func armDwell(itemKey: String) {
309
+ lock.lock()
310
+ let engineRef = engine
311
+ if engineRef != nil {
312
+ pendingDwellArmKey = itemKey
313
+ }
314
+ lock.unlock()
315
+ engineRef?.armDwell(itemKey: itemKey)
316
+ }
317
+
318
+ /// Clears the dwell slot AND the pending-arm retry slot (a cleared focus/
319
+ /// hover must never be resurrected by a later feed).
320
+ func clearDwell() {
321
+ lock.lock()
322
+ pendingDwellArmKey = nil
323
+ let engineRef = engine
324
+ lock.unlock()
325
+ engineRef?.clearDwell()
326
+ }
327
+
328
+ /// C2 iPadOS hover seam: pointer-hover resolution outcome → dwell-slot
329
+ /// transition. A resolved item key arms the single-slot dwell (the same
330
+ /// engine path the tvOS focus observer drives); `nil` — hover ended,
331
+ /// cancelled, or nothing resolvable under the pointer — clears it. The
332
+ /// engine's supersede + re-verify-at-fire semantics are untouched.
333
+ /// Platform-free so the decision logic stays testable without UIKit.
334
+ func hoverDidResolve(itemKey: String?) {
335
+ if let itemKey {
336
+ armDwell(itemKey: itemKey)
337
+ } else {
338
+ clearDwell()
339
+ }
340
+ }
341
+
342
+ /// Merges the armed dwell-slot context (section, sectionOrder, contentId,
343
+ /// position) into interaction data without overwriting caller keys.
344
+ func enrichFromDwellSlot(into data: inout [String: String]) {
345
+ guard let fields = currentEngine()?.dwellEnrichment() else { return }
346
+ for (key, value) in fields where data[key] == nil {
347
+ data[key] = value
348
+ }
349
+ }
350
+
351
+ func itemEnrichment(itemKey: String) -> [String: String]? {
352
+ currentEngine()?.itemEnrichment(itemKey: itemKey)
353
+ }
354
+
355
+ func sectionEnrichment(sectionKey: String) -> [String: String]? {
356
+ currentEngine()?.sectionEnrichment(sectionKey: sectionKey)
357
+ }
358
+
359
+ #if canImport(UIKit)
360
+ /// Resolves a view through the registry and merges {section, sectionOrder,
361
+ /// contentId, position} into interaction data without overwriting caller
362
+ /// keys. No-op while the sections family is uninstalled.
363
+ func enrich(view: UIView, into data: inout [String: String]) {
364
+ guard isInstalled, let fields = tracker.enrichmentFields(for: view) else { return }
365
+ for (key, value) in fields where data[key] == nil {
366
+ data[key] = value
367
+ }
368
+ }
369
+ #endif
370
+
371
+ private func currentEngine() -> SectionsEngine? {
372
+ lock.lock()
373
+ defer { lock.unlock() }
374
+ return engine
375
+ }
376
+ }
377
+
378
+ #if canImport(UIKit)
379
+
380
+ /// Optional per-index-path metadata for tracked collection/table sections.
381
+ public struct ForgeSectionItemMetadata {
382
+ public var contentId: String?
383
+ public var title: String?
384
+ public var position: Int?
385
+
386
+ public init(contentId: String? = nil, title: String? = nil, position: Int? = nil) {
387
+ self.contentId = contentId
388
+ self.title = title
389
+ self.position = position
390
+ }
391
+ }
392
+
393
+ /// Injectable window-space visibility seam so tests (and exotic hosts) can
394
+ /// replace the geometry math.
395
+ public protocol SectionsGeometryReading: AnyObject {
396
+ /// Visible-area ratio 0-100 for a view inside its window; 0 when the view
397
+ /// is hidden, effectively transparent, zero-sized, or not in a window.
398
+ func visibilityPct(of view: UIView) -> Int
399
+ }
400
+
401
+ /// Default geometry: converts bounds to window coordinates, intersects with
402
+ /// the window bounds and every `clipsToBounds` ancestor (visible-area
403
+ /// approximation), and reports 0 for hidden/alpha~0/detached views.
404
+ final class WindowSectionsGeometryReader: SectionsGeometryReading {
405
+ func visibilityPct(of view: UIView) -> Int {
406
+ guard let window = view.window, !view.isHidden, view.alpha > 0.05 else { return 0 }
407
+ let frame = view.convert(view.bounds, to: nil)
408
+ guard frame.width > 0, frame.height > 0 else { return 0 }
409
+ var visible = frame.intersection(window.bounds)
410
+ var ancestor = view.superview
411
+ while let current = ancestor, current !== window {
412
+ if current.isHidden || current.alpha <= 0.05 { return 0 }
413
+ if current.clipsToBounds {
414
+ visible = visible.intersection(current.convert(current.bounds, to: nil))
415
+ }
416
+ if visible.isNull || visible.isEmpty { return 0 }
417
+ ancestor = current.superview
418
+ }
419
+ guard !visible.isNull, !visible.isEmpty else { return 0 }
420
+ let ratio = (visible.width * visible.height) / (frame.width * frame.height)
421
+ return max(0, min(100, Int((ratio * 100).rounded())))
422
+ }
423
+ }
424
+
425
+ /// Bounded traversal limits for marker adoption and per-section item scans.
426
+ /// Every scan visits at most ``maxNodes`` views and descends at most
427
+ /// ``maxDepth`` levels, so pathological hierarchies cannot stall the sampler.
428
+ enum SectionsScanBounds {
429
+ static let maxDepth = 20
430
+ static let maxNodes = 1500
431
+ }
432
+
433
+ /// UIKit registry + sampler for the sections engine. Main-thread affine like
434
+ /// the rest of the UIKit capture surface: call the public API on main.
435
+ /// Registrations are weak (`NSMapTable.weakToStrongObjects`), sampling reads
436
+ /// `indexPathsForVisibleItems`/`indexPathsForVisibleRows` at evaluate time
437
+ /// (no delegate proxying, no swizzling), and all observation tokens are owned
438
+ /// by the registration/sampler so deactivation is deallocate-safe.
439
+ public final class ForgeSectionsTracker {
440
+ private static let evaluateThrottleMs: Int64 = 100
441
+
442
+ private final class Registration {
443
+ let key: String
444
+ weak var view: UIView?
445
+ var name: String
446
+ var order: Int?
447
+ var itemMetadata: ((IndexPath) -> ForgeSectionItemMetadata?)?
448
+ var offsetObservation: NSKeyValueObservation?
449
+ /// C2 iPadOS hover recognizer owned by the registration (installed on
450
+ /// the section container while the sampler is active; removed on
451
+ /// untrack/deactivate). Stored as the base class so tvOS — where
452
+ /// `UIHoverGestureRecognizer` does not exist — compiles unchanged.
453
+ var hoverRecognizer: UIGestureRecognizer?
454
+ let adopted: Bool
455
+
456
+ init(
457
+ key: String,
458
+ view: UIView,
459
+ name: String,
460
+ order: Int?,
461
+ itemMetadata: ((IndexPath) -> ForgeSectionItemMetadata?)?,
462
+ adopted: Bool
463
+ ) {
464
+ self.key = key
465
+ self.view = view
466
+ self.name = name
467
+ self.order = order
468
+ self.itemMetadata = itemMetadata
469
+ self.adopted = adopted
470
+ }
471
+ }
472
+
473
+ private final class OrphanItem {
474
+ let key: String
475
+ weak var view: UIView?
476
+ let contentId: String
477
+ let markerPosition: Int?
478
+
479
+ init(key: String, view: UIView, contentId: String, markerPosition: Int?) {
480
+ self.key = key
481
+ self.view = view
482
+ self.contentId = contentId
483
+ self.markerPosition = markerPosition
484
+ }
485
+ }
486
+
487
+ struct ResolvedItem {
488
+ let itemKey: String
489
+ let sectionKey: String?
490
+ let sectionName: String?
491
+ let sectionOrder: Int?
492
+ let contentId: String?
493
+ let position: Int?
494
+ }
495
+
496
+ private weak var coordinator: ForgeSectionsCoordinator?
497
+ /// Weak-keyed registration lookup: dead views drop their entries.
498
+ private let registrationMap = NSMapTable<UIView, Registration>.weakToStrongObjects()
499
+ /// Deterministic sampling order; compacted when weak views die.
500
+ private var orderedRegistrations: [Registration] = []
501
+ private var orphanItems: [OrphanItem] = []
502
+ private var notificationTokens: [NSObjectProtocol] = []
503
+ private var safetyTimer: DispatchSourceTimer?
504
+ private var lastEvaluateAtMs: Int64 = 0
505
+ private var evaluatePending = false
506
+ private var appActive = true
507
+ private(set) var active = false
508
+
509
+ /// Injectable geometry seam; replace before activation for custom hosts.
510
+ public var geometryReader: SectionsGeometryReading = WindowSectionsGeometryReader()
511
+
512
+ #if os(iOS)
513
+ /// NSObject action target relaying hover gestures into the non-NSObject
514
+ /// tracker. The recognizer retains the relay while the relay holds the
515
+ /// tracker weakly, so no retain cycle can form through the view.
516
+ private final class HoverRelay: NSObject {
517
+ private weak var tracker: ForgeSectionsTracker?
518
+
519
+ init(tracker: ForgeSectionsTracker) {
520
+ self.tracker = tracker
521
+ }
522
+
523
+ @objc func handleHover(_ recognizer: UIHoverGestureRecognizer) {
524
+ tracker?.handleHover(recognizer)
525
+ }
526
+ }
527
+
528
+ private lazy var hoverRelay = HoverRelay(tracker: self)
529
+ #endif
530
+
531
+ init(coordinator: ForgeSectionsCoordinator) {
532
+ self.coordinator = coordinator
533
+ }
534
+
535
+ deinit {
536
+ // Deallocate-safe teardown: KVO invalidation, dispatch-source cancel,
537
+ // and NotificationCenter removal are all thread-safe.
538
+ orderedRegistrations.forEach { $0.offsetObservation?.invalidate() }
539
+ safetyTimer?.cancel()
540
+ notificationTokens.forEach(NotificationCenter.default.removeObserver)
541
+ }
542
+
543
+ // MARK: - Public registration API (Tier 3)
544
+
545
+ /// Tracks any container view as a section.
546
+ public func track(view: UIView, name: String, order: Int? = nil) {
547
+ register(view: view, name: name, order: order, itemMetadata: nil, adopted: false)
548
+ }
549
+
550
+ /// Tracks a collection view; visible cells become items at sample time.
551
+ public func track(
552
+ collectionView: UICollectionView,
553
+ name: String,
554
+ order: Int? = nil,
555
+ itemMetadata: ((IndexPath) -> ForgeSectionItemMetadata?)? = nil
556
+ ) {
557
+ register(view: collectionView, name: name, order: order, itemMetadata: itemMetadata, adopted: false)
558
+ }
559
+
560
+ /// Tracks a table view; visible rows become items at sample time.
561
+ public func track(
562
+ tableView: UITableView,
563
+ name: String,
564
+ order: Int? = nil,
565
+ itemMetadata: ((IndexPath) -> ForgeSectionItemMetadata?)? = nil
566
+ ) {
567
+ register(view: tableView, name: name, order: order, itemMetadata: itemMetadata, adopted: false)
568
+ }
569
+
570
+ /// Stops tracking a previously registered section view.
571
+ public func untrack(view: UIView) {
572
+ if let registration = registrationMap.object(forKey: view) {
573
+ registration.offsetObservation?.invalidate()
574
+ registration.offsetObservation = nil
575
+ removeHoverRecognizer(for: registration)
576
+ registrationMap.removeObject(forKey: view)
577
+ orderedRegistrations.removeAll { $0 === registration }
578
+ }
579
+ updateTimerState()
580
+ scheduleEvaluateOnMain()
581
+ }
582
+
583
+ // MARK: - Sampler lifecycle (driven by the coordinator)
584
+
585
+ func activate() {
586
+ guard !active else { return }
587
+ active = true
588
+ appActive = currentAppActive()
589
+ installNotifications()
590
+ for registration in orderedRegistrations {
591
+ installScrollObservation(for: registration)
592
+ installHoverRecognizer(for: registration)
593
+ }
594
+ rescanWindowForMarkers()
595
+ updateTimerState()
596
+ scheduleEvaluateOnMain()
597
+ }
598
+
599
+ func deactivate() {
600
+ guard active else { return }
601
+ active = false
602
+ notificationTokens.forEach(NotificationCenter.default.removeObserver)
603
+ notificationTokens.removeAll()
604
+ for registration in orderedRegistrations {
605
+ registration.offsetObservation?.invalidate()
606
+ registration.offsetObservation = nil
607
+ removeHoverRecognizer(for: registration)
608
+ }
609
+ // Purge dead registrations now: this also releases their integrator
610
+ // metadata closures instead of leaving them reachable through the
611
+ // weak map until its next internal rehash. (The one-shot pending
612
+ // dwell arm lives in the coordinator and clears on engine uninstall.)
613
+ compactRegistrations()
614
+ stopTimer()
615
+ evaluatePending = false
616
+ }
617
+
618
+ // MARK: - Marker adoption (Tier 1 + Tier 2)
619
+
620
+ /// Bounded key-window scan (at install and screen change) adopting
621
+ /// standalone `forge-section:` containers, native-id sections, and orphan
622
+ /// `forge-item:` views living outside every registered section.
623
+ func rescanWindowForMarkers() {
624
+ guard active, let coordinator, coordinator.isInstalled else { return }
625
+ guard let window = keyWindow() else { return }
626
+ let sectionIds = coordinator.nativeSectionIds
627
+ let itemIds = coordinator.nativeItemIds
628
+ orphanItems.removeAll { $0.view == nil }
629
+ var visited = 0
630
+ var queue: [(view: UIView, depth: Int)] = [(window, 0)]
631
+ while !queue.isEmpty, visited < SectionsScanBounds.maxNodes {
632
+ let (view, depth) = queue.removeFirst()
633
+ visited += 1
634
+ switch ForgeSectionMarkerParser.classify(
635
+ identifier: view.accessibilityIdentifier,
636
+ sectionNativeIds: sectionIds,
637
+ itemNativeIds: itemIds
638
+ ) {
639
+ case let .section(name, order):
640
+ if registrationMap.object(forKey: view) == nil {
641
+ register(view: view, name: name, order: order, itemMetadata: nil, adopted: true)
642
+ }
643
+ case let .item(contentId, position):
644
+ if let contentId,
645
+ enclosingRegisteredSection(of: view) == nil,
646
+ !orphanItems.contains(where: { $0.view === view }) {
647
+ orphanItems.append(OrphanItem(
648
+ key: Self.orphanItemKey(contentId: contentId, view: view),
649
+ view: view,
650
+ contentId: contentId,
651
+ markerPosition: position
652
+ ))
653
+ }
654
+ case nil:
655
+ break
656
+ }
657
+ if depth < SectionsScanBounds.maxDepth {
658
+ for subview in view.subviews {
659
+ queue.append((subview, depth + 1))
660
+ }
661
+ }
662
+ }
663
+ updateTimerState()
664
+ }
665
+
666
+ // MARK: - Evaluation
667
+
668
+ func scheduleEvaluateOnMain() {
669
+ if Thread.isMainThread {
670
+ throttledEvaluate()
671
+ } else {
672
+ DispatchQueue.main.async { [weak self] in
673
+ self?.throttledEvaluate()
674
+ }
675
+ }
676
+ }
677
+
678
+ private func throttledEvaluate() {
679
+ guard active else { return }
680
+ let now = ForgeClock.elapsedRealtimeMs()
681
+ let elapsed = now - lastEvaluateAtMs
682
+ if elapsed >= Self.evaluateThrottleMs {
683
+ lastEvaluateAtMs = now
684
+ evaluate()
685
+ } else if !evaluatePending {
686
+ evaluatePending = true
687
+ let delayMs = Int(max(1, Self.evaluateThrottleMs - elapsed))
688
+ DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(delayMs)) { [weak self] in
689
+ guard let self, self.evaluatePending else { return }
690
+ self.evaluatePending = false
691
+ self.lastEvaluateAtMs = ForgeClock.elapsedRealtimeMs()
692
+ self.evaluate()
693
+ }
694
+ }
695
+ }
696
+
697
+ private func evaluate() {
698
+ guard active, let coordinator, coordinator.isInstalled else { return }
699
+ compactRegistrations()
700
+ var sections: [SectionSnapshot] = []
701
+ var items: [ItemSnapshot] = []
702
+ for registration in orderedRegistrations {
703
+ guard let view = registration.view else { continue }
704
+ if registration.offsetObservation == nil {
705
+ installScrollObservation(for: registration)
706
+ }
707
+ let pct = geometryReader.visibilityPct(of: view)
708
+ let itemCount = sampleItems(for: registration, view: view, into: &items)
709
+ sections.append(SectionSnapshot(
710
+ key: registration.key,
711
+ name: registration.name,
712
+ order: registration.order,
713
+ visibilityPct: pct,
714
+ itemCount: itemCount
715
+ ))
716
+ }
717
+ let hadOrphans = !orphanItems.isEmpty
718
+ orphanItems.removeAll { $0.view == nil }
719
+ if hadOrphans, orphanItems.isEmpty {
720
+ // The last orphan died: without this, a target-less tracker keeps
721
+ // the 1 s safety timer ticking until the next explicit signal.
722
+ updateTimerState()
723
+ }
724
+ for orphan in orphanItems {
725
+ guard let view = orphan.view else { continue }
726
+ items.append(ItemSnapshot(
727
+ key: orphan.key,
728
+ contentId: orphan.contentId,
729
+ position: orphan.markerPosition
730
+ ?? coordinator.assignedPosition(sectionKey: "orphan", itemKey: orphan.key),
731
+ sectionKey: nil,
732
+ visibilityPct: geometryReader.visibilityPct(of: view)
733
+ ))
734
+ }
735
+ // The coordinator applies the feed and then consumes its one-shot
736
+ // pending dwell arm, replaying a focus/hover arm that raced the
737
+ // throttled refresh exactly once.
738
+ coordinator.applyFeed(source: .uikit, sections: sections, items: items)
739
+ }
740
+
741
+ private func sampleItems(for registration: Registration, view: UIView, into items: inout [ItemSnapshot]) -> Int {
742
+ var count = 0
743
+ if let collectionView = view as? UICollectionView {
744
+ for indexPath in collectionView.indexPathsForVisibleItems.sorted() {
745
+ guard let cell = collectionView.cellForItem(at: indexPath) else { continue }
746
+ items.append(itemSnapshot(for: cell, indexPath: indexPath, registration: registration))
747
+ count += 1
748
+ }
749
+ return count
750
+ }
751
+ if let tableView = view as? UITableView {
752
+ for indexPath in tableView.indexPathsForVisibleRows ?? [] {
753
+ guard let cell = tableView.cellForRow(at: indexPath) else { continue }
754
+ items.append(itemSnapshot(for: cell, indexPath: indexPath, registration: registration))
755
+ count += 1
756
+ }
757
+ return count
758
+ }
759
+ // Plain container: bounded subtree scan for marker/native-id items.
760
+ guard let coordinator else { return 0 }
761
+ let itemIds = coordinator.nativeItemIds
762
+ var visited = 0
763
+ var queue: [(view: UIView, depth: Int)] = view.subviews.map { ($0, 1) }
764
+ while !queue.isEmpty, visited < SectionsScanBounds.maxNodes {
765
+ let (candidate, depth) = queue.removeFirst()
766
+ visited += 1
767
+ if case let .item(contentId, markerPosition) = ForgeSectionMarkerParser.classify(
768
+ identifier: candidate.accessibilityIdentifier,
769
+ sectionNativeIds: [],
770
+ itemNativeIds: itemIds
771
+ ), let contentId {
772
+ let key = "\(registration.key)#\(contentId)"
773
+ items.append(ItemSnapshot(
774
+ key: key,
775
+ contentId: contentId,
776
+ position: markerPosition
777
+ ?? coordinator.assignedPosition(sectionKey: registration.key, itemKey: key),
778
+ sectionKey: registration.key,
779
+ visibilityPct: geometryReader.visibilityPct(of: candidate)
780
+ ))
781
+ count += 1
782
+ }
783
+ if depth < SectionsScanBounds.maxDepth {
784
+ for subview in candidate.subviews {
785
+ queue.append((subview, depth + 1))
786
+ }
787
+ }
788
+ }
789
+ return count
790
+ }
791
+
792
+ private func itemSnapshot(for cell: UIView, indexPath: IndexPath, registration: Registration) -> ItemSnapshot {
793
+ let metadata = registration.itemMetadata?(indexPath)
794
+ let marker = cellMarker(for: cell)
795
+ let contentId = metadata?.contentId ?? marker?.contentId
796
+ let position = metadata?.position ?? marker?.position ?? indexPath.item
797
+ return ItemSnapshot(
798
+ key: "\(registration.key)#\(indexPath.section).\(indexPath.item)",
799
+ contentId: contentId,
800
+ position: position,
801
+ sectionKey: registration.key,
802
+ visibilityPct: geometryReader.visibilityPct(of: cell),
803
+ title: metadata?.title
804
+ )
805
+ }
806
+
807
+ private func cellMarker(for cell: UIView) -> (contentId: String?, position: Int?)? {
808
+ guard let coordinator else { return nil }
809
+ let itemIds = coordinator.nativeItemIds
810
+ let identifiers = [cell.accessibilityIdentifier, cell.subviews.first?.accessibilityIdentifier]
811
+ for identifier in identifiers {
812
+ if case let .item(contentId, position) = ForgeSectionMarkerParser.classify(
813
+ identifier: identifier,
814
+ sectionNativeIds: [],
815
+ itemNativeIds: itemIds
816
+ ) {
817
+ return (contentId, position)
818
+ }
819
+ }
820
+ return nil
821
+ }
822
+
823
+ // MARK: - Focus dwell + enrichment resolution
824
+
825
+ private func handleFocusUpdate(_ note: Notification) {
826
+ guard active, let coordinator, coordinator.isInstalled else { return }
827
+ let context = note.userInfo?[UIFocusSystem.focusUpdateContextUserInfoKey] as? UIFocusUpdateContext
828
+ let focusedView = (context?.nextFocusedItem as? UIView) ?? context?.nextFocusedView
829
+ guard let focusedView, let resolved = resolveItem(for: focusedView) else {
830
+ coordinator.clearDwell()
831
+ return
832
+ }
833
+ // Arm first (the coordinator records the one-shot pending arm), then
834
+ // refresh the store so the dwell re-verify at fire sees the item; if
835
+ // the key was still absent at arm time, the next evaluate's feed
836
+ // replays the arm once from the coordinator's pending slot.
837
+ coordinator.armDwell(itemKey: resolved.itemKey)
838
+ scheduleEvaluateOnMain()
839
+ }
840
+
841
+ /// {section, sectionOrder, contentId, position} for a resolved view, from
842
+ /// the engine store when available and directly from the registry as a
843
+ /// fallback before the first evaluate.
844
+ func enrichmentFields(for view: UIView) -> [String: String]? {
845
+ guard let coordinator else { return nil }
846
+ if let resolved = resolveItem(for: view) {
847
+ if let fields = coordinator.itemEnrichment(itemKey: resolved.itemKey) {
848
+ return fields
849
+ }
850
+ var fields: [String: String] = [:]
851
+ if let sectionKey = resolved.sectionKey {
852
+ if let sectionFields = coordinator.sectionEnrichment(sectionKey: sectionKey) {
853
+ fields.merge(sectionFields) { current, _ in current }
854
+ } else if let name = resolved.sectionName {
855
+ fields["section"] = name
856
+ if let order = resolved.sectionOrder {
857
+ fields["sectionOrder"] = String(order)
858
+ }
859
+ }
860
+ }
861
+ if let contentId = resolved.contentId {
862
+ fields["contentId"] = contentId
863
+ }
864
+ fields["position"] = String(resolved.position ?? 0)
865
+ return fields
866
+ }
867
+ if let registration = enclosingRegisteredSection(of: view) {
868
+ if let fields = coordinator.sectionEnrichment(sectionKey: registration.key) {
869
+ return fields
870
+ }
871
+ var fields = ["section": registration.name]
872
+ if let order = registration.order {
873
+ fields["sectionOrder"] = String(order)
874
+ }
875
+ return fields
876
+ }
877
+ return nil
878
+ }
879
+
880
+ private func resolveItem(for view: UIView) -> ResolvedItem? {
881
+ guard let coordinator else { return nil }
882
+ let itemIds = coordinator.nativeItemIds
883
+ var current: UIView? = view
884
+ while let candidate = current {
885
+ if let cell = candidate as? UICollectionViewCell,
886
+ let collectionView = enclosingView(of: cell, as: UICollectionView.self),
887
+ let registration = registrationMap.object(forKey: collectionView),
888
+ let indexPath = collectionView.indexPath(for: cell) {
889
+ let metadata = registration.itemMetadata?(indexPath)
890
+ return ResolvedItem(
891
+ itemKey: "\(registration.key)#\(indexPath.section).\(indexPath.item)",
892
+ sectionKey: registration.key,
893
+ sectionName: registration.name,
894
+ sectionOrder: registration.order,
895
+ contentId: metadata?.contentId ?? cellMarker(for: cell)?.contentId,
896
+ position: metadata?.position ?? indexPath.item
897
+ )
898
+ }
899
+ if let cell = candidate as? UITableViewCell,
900
+ let tableView = enclosingView(of: cell, as: UITableView.self),
901
+ let registration = registrationMap.object(forKey: tableView),
902
+ let indexPath = tableView.indexPath(for: cell) {
903
+ let metadata = registration.itemMetadata?(indexPath)
904
+ return ResolvedItem(
905
+ itemKey: "\(registration.key)#\(indexPath.section).\(indexPath.item)",
906
+ sectionKey: registration.key,
907
+ sectionName: registration.name,
908
+ sectionOrder: registration.order,
909
+ contentId: metadata?.contentId ?? cellMarker(for: cell)?.contentId,
910
+ position: metadata?.position ?? indexPath.row
911
+ )
912
+ }
913
+ if case let .item(contentId, markerPosition) = ForgeSectionMarkerParser.classify(
914
+ identifier: candidate.accessibilityIdentifier,
915
+ sectionNativeIds: [],
916
+ itemNativeIds: itemIds
917
+ ), let contentId {
918
+ if let registration = enclosingRegisteredSection(of: candidate) {
919
+ let key = "\(registration.key)#\(contentId)"
920
+ return ResolvedItem(
921
+ itemKey: key,
922
+ sectionKey: registration.key,
923
+ sectionName: registration.name,
924
+ sectionOrder: registration.order,
925
+ contentId: contentId,
926
+ position: markerPosition
927
+ )
928
+ }
929
+ return ResolvedItem(
930
+ itemKey: Self.orphanItemKey(contentId: contentId, view: candidate),
931
+ sectionKey: nil,
932
+ sectionName: nil,
933
+ sectionOrder: nil,
934
+ contentId: contentId,
935
+ position: markerPosition
936
+ )
937
+ }
938
+ current = candidate.superview
939
+ }
940
+ return nil
941
+ }
942
+
943
+ private func enclosingRegisteredSection(of view: UIView) -> Registration? {
944
+ var current: UIView? = view.superview
945
+ while let candidate = current {
946
+ if let registration = registrationMap.object(forKey: candidate) {
947
+ return registration
948
+ }
949
+ current = candidate.superview
950
+ }
951
+ return nil
952
+ }
953
+
954
+ /// Orphan item keys carry the marked view's identity so two orphan views
955
+ /// sharing a contentId never collide on the same engine key. The suffix
956
+ /// is process-stable, and the scan and resolve paths derive it from the
957
+ /// same view instance, so enrichment lookups keep matching the feed.
958
+ private static func orphanItemKey(contentId: String, view: UIView) -> String {
959
+ "orphan#\(contentId)#\(String(UInt(bitPattern: ObjectIdentifier(view)), radix: 16))"
960
+ }
961
+
962
+ private func enclosingView<T: UIView>(of view: UIView, as type: T.Type) -> T? {
963
+ var current: UIView? = view.superview
964
+ while let candidate = current {
965
+ if let match = candidate as? T {
966
+ return match
967
+ }
968
+ current = candidate.superview
969
+ }
970
+ return nil
971
+ }
972
+
973
+ // MARK: - Registration internals
974
+
975
+ private func register(
976
+ view: UIView,
977
+ name: String,
978
+ order: Int?,
979
+ itemMetadata: ((IndexPath) -> ForgeSectionItemMetadata?)?,
980
+ adopted: Bool
981
+ ) {
982
+ if let existing = registrationMap.object(forKey: view) {
983
+ existing.name = name
984
+ existing.order = order ?? existing.order
985
+ if let itemMetadata {
986
+ existing.itemMetadata = itemMetadata
987
+ }
988
+ } else {
989
+ let registration = Registration(
990
+ key: "section-\(UUID().uuidString)",
991
+ view: view,
992
+ name: name,
993
+ order: order,
994
+ itemMetadata: itemMetadata,
995
+ adopted: adopted
996
+ )
997
+ registrationMap.setObject(registration, forKey: view)
998
+ orderedRegistrations.append(registration)
999
+ if active {
1000
+ installScrollObservation(for: registration)
1001
+ installHoverRecognizer(for: registration)
1002
+ }
1003
+ }
1004
+ updateTimerState()
1005
+ scheduleEvaluateOnMain()
1006
+ }
1007
+
1008
+ private func compactRegistrations() {
1009
+ let dead = orderedRegistrations.filter { $0.view == nil }
1010
+ guard !dead.isEmpty else { return }
1011
+ dead.forEach {
1012
+ $0.offsetObservation?.invalidate()
1013
+ $0.offsetObservation = nil
1014
+ // The recognizer died with its weak view; drop the reference.
1015
+ $0.hoverRecognizer = nil
1016
+ // NSMapTable can keep the dead registration shell alive until its
1017
+ // next internal rehash: release the integrator's metadata closure
1018
+ // now so a dead view-controller graph does not stay reachable.
1019
+ $0.itemMetadata = nil
1020
+ }
1021
+ orderedRegistrations.removeAll { $0.view == nil }
1022
+ updateTimerState()
1023
+ }
1024
+
1025
+ private func installScrollObservation(for registration: Registration) {
1026
+ guard active, registration.offsetObservation == nil, let view = registration.view else { return }
1027
+ let scrollView = (view as? UIScrollView) ?? enclosingView(of: view, as: UIScrollView.self)
1028
+ guard let scrollView else { return }
1029
+ registration.offsetObservation = scrollView.observe(\.contentOffset, options: [.new]) { [weak self] _, _ in
1030
+ self?.scheduleEvaluateOnMain()
1031
+ }
1032
+ }
1033
+
1034
+ // MARK: - Hover dwell (C2, iPadOS pointer/trackpad/pencil)
1035
+
1036
+ #if os(iOS)
1037
+ /// Attaches one `UIHoverGestureRecognizer` to the registered section
1038
+ /// container. The recognizer only fires for hover-capable pointers
1039
+ /// (trackpad/pencil), so no capability check is needed; on touch-only
1040
+ /// devices it simply never fires. tvOS keeps the focus-engine path
1041
+ /// (`UIHoverGestureRecognizer` is iOS-only).
1042
+ private func installHoverRecognizer(for registration: Registration) {
1043
+ guard active, registration.hoverRecognizer == nil, let view = registration.view else { return }
1044
+ let recognizer = UIHoverGestureRecognizer(
1045
+ target: hoverRelay,
1046
+ action: #selector(HoverRelay.handleHover(_:))
1047
+ )
1048
+ // Hover never involves touches; stay inert for the app's gestures.
1049
+ recognizer.cancelsTouchesInView = false
1050
+ view.addGestureRecognizer(recognizer)
1051
+ registration.hoverRecognizer = recognizer
1052
+ }
1053
+
1054
+ /// C2 hover handler: hit-tests the pointer location inside the section
1055
+ /// container to the deepest tracked item (the same registry resolution
1056
+ /// the click/dwell enrichment uses) and drives the coordinator's hover
1057
+ /// seam — arm on a resolved item, clear on hover end or when nothing
1058
+ /// tracked is under the pointer. Engine semantics (single-slot supersede,
1059
+ /// re-verify at fire, per-screen dedupe) are reused unchanged.
1060
+ fileprivate func handleHover(_ recognizer: UIHoverGestureRecognizer) {
1061
+ guard active, let coordinator, coordinator.isInstalled else { return }
1062
+ guard let containerView = recognizer.view else {
1063
+ coordinator.hoverDidResolve(itemKey: nil)
1064
+ return
1065
+ }
1066
+ switch recognizer.state {
1067
+ case .began, .changed:
1068
+ let location = recognizer.location(in: containerView)
1069
+ let hitView = containerView.hitTest(location, with: nil)
1070
+ let resolved = hitView.flatMap { resolveItem(for: $0) }
1071
+ // Arm/clear first (the coordinator owns the one-shot pending-arm
1072
+ // slot), then refresh the store so the dwell re-verify at fire
1073
+ // sees the item (same ordering as the tvOS focus observer); the
1074
+ // next evaluate's feed replays the arm once if the key was still
1075
+ // absent at arm time.
1076
+ coordinator.hoverDidResolve(itemKey: resolved?.itemKey)
1077
+ if resolved != nil {
1078
+ scheduleEvaluateOnMain()
1079
+ }
1080
+ default:
1081
+ coordinator.hoverDidResolve(itemKey: nil)
1082
+ }
1083
+ }
1084
+ #else
1085
+ /// tvOS (and any other non-iOS UIKit host): hover dwell does not exist;
1086
+ /// the focus observer drives the dwell slot instead.
1087
+ private func installHoverRecognizer(for registration: Registration) {}
1088
+ #endif
1089
+
1090
+ /// Detaches a registration's hover recognizer. Compiles on tvOS as a
1091
+ /// data-only cleanup (the recognizer is never installed there).
1092
+ private func removeHoverRecognizer(for registration: Registration) {
1093
+ guard let recognizer = registration.hoverRecognizer else { return }
1094
+ registration.view?.removeGestureRecognizer(recognizer)
1095
+ registration.hoverRecognizer = nil
1096
+ }
1097
+
1098
+ // MARK: - Signals
1099
+
1100
+ private func installNotifications() {
1101
+ let center = NotificationCenter.default
1102
+ notificationTokens.append(center.addObserver(
1103
+ forName: UIApplication.didBecomeActiveNotification,
1104
+ object: nil,
1105
+ queue: .main
1106
+ ) { [weak self] _ in
1107
+ self?.appActive = true
1108
+ self?.updateTimerState()
1109
+ self?.scheduleEvaluateOnMain()
1110
+ })
1111
+ notificationTokens.append(center.addObserver(
1112
+ forName: UIApplication.willResignActiveNotification,
1113
+ object: nil,
1114
+ queue: .main
1115
+ ) { [weak self] _ in
1116
+ self?.appActive = false
1117
+ self?.updateTimerState()
1118
+ })
1119
+ #if os(iOS)
1120
+ notificationTokens.append(center.addObserver(
1121
+ forName: UIDevice.orientationDidChangeNotification,
1122
+ object: nil,
1123
+ queue: .main
1124
+ ) { [weak self] _ in
1125
+ self?.scheduleEvaluateOnMain()
1126
+ })
1127
+ #endif
1128
+ notificationTokens.append(center.addObserver(
1129
+ forName: UIFocusSystem.didUpdateNotification,
1130
+ object: nil,
1131
+ queue: .main
1132
+ ) { [weak self] note in
1133
+ self?.handleFocusUpdate(note)
1134
+ })
1135
+ }
1136
+
1137
+ /// 1 s repeating safety timer covering layout changes without scroll;
1138
+ /// runs only while at least one section/orphan is tracked, the app is
1139
+ /// active, and the sections family is installed.
1140
+ private func updateTimerState() {
1141
+ let hasTargets = !orderedRegistrations.isEmpty || !orphanItems.isEmpty
1142
+ let shouldRun = active && appActive && hasTargets && (coordinator?.isInstalled ?? false)
1143
+ if shouldRun, safetyTimer == nil {
1144
+ let timer = DispatchSource.makeTimerSource(queue: .main)
1145
+ timer.schedule(deadline: .now() + 1.0, repeating: 1.0, leeway: .milliseconds(200))
1146
+ timer.setEventHandler { [weak self] in
1147
+ self?.scheduleEvaluateOnMain()
1148
+ }
1149
+ timer.resume()
1150
+ safetyTimer = timer
1151
+ } else if !shouldRun {
1152
+ stopTimer()
1153
+ }
1154
+ }
1155
+
1156
+ private func stopTimer() {
1157
+ safetyTimer?.cancel()
1158
+ safetyTimer = nil
1159
+ }
1160
+
1161
+ private func currentAppActive() -> Bool {
1162
+ if Thread.isMainThread {
1163
+ return UIApplication.shared.applicationState == .active
1164
+ }
1165
+ return true
1166
+ }
1167
+
1168
+ private func keyWindow() -> UIWindow? {
1169
+ let windows = UIApplication.shared.connectedScenes
1170
+ .compactMap { $0 as? UIWindowScene }
1171
+ .flatMap(\.windows)
1172
+ return windows.first(where: \.isKeyWindow) ?? windows.first
1173
+ }
1174
+ }
1175
+
1176
+ #endif