@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.
- package/LICENSE +10 -0
- package/NPAWForgeReactNative.podspec +44 -0
- package/README.md +51 -0
- package/android/build.gradle +77 -0
- package/android/src/main/AndroidManifest.xml +14 -0
- package/android/src/main/java/com/npaw/forge/reactnative/ForgeDtoMapper.java +295 -0
- package/android/src/main/java/com/npaw/forge/reactnative/ForgeInstanceRegistry.java +61 -0
- package/android/src/main/java/com/npaw/forge/reactnative/NPAWForgeModule.java +735 -0
- package/android/src/main/java/com/npaw/forge/reactnative/NPAWForgePackage.java +53 -0
- package/android/src/test/java/com/npaw/forge/reactnative/ForgeDtoMapperTest.java +133 -0
- package/ios/ForgeApple/ForgeKernelFFI.xcframework/Info.plist +82 -0
- package/ios/ForgeApple/ForgeKernelFFI.xcframework/ios-arm64/Headers/ForgeKernelFFI.h +17 -0
- package/ios/ForgeApple/ForgeKernelFFI.xcframework/ios-arm64/Headers/forge_kernelFFI.h +873 -0
- package/ios/ForgeApple/ForgeKernelFFI.xcframework/ios-arm64/Headers/module.modulemap +7 -0
- package/ios/ForgeApple/ForgeKernelFFI.xcframework/ios-arm64/libForgeKernelFFI.a +0 -0
- package/ios/ForgeApple/ForgeKernelFFI.xcframework/ios-arm64_x86_64-simulator/Headers/ForgeKernelFFI.h +17 -0
- package/ios/ForgeApple/ForgeKernelFFI.xcframework/ios-arm64_x86_64-simulator/Headers/forge_kernelFFI.h +873 -0
- package/ios/ForgeApple/ForgeKernelFFI.xcframework/ios-arm64_x86_64-simulator/Headers/module.modulemap +7 -0
- package/ios/ForgeApple/ForgeKernelFFI.xcframework/ios-arm64_x86_64-simulator/libForgeKernelFFI.a +0 -0
- package/ios/ForgeApple/ForgeKernelFFI.xcframework/tvos-arm64/Headers/ForgeKernelFFI.h +17 -0
- package/ios/ForgeApple/ForgeKernelFFI.xcframework/tvos-arm64/Headers/forge_kernelFFI.h +873 -0
- package/ios/ForgeApple/ForgeKernelFFI.xcframework/tvos-arm64/Headers/module.modulemap +7 -0
- package/ios/ForgeApple/ForgeKernelFFI.xcframework/tvos-arm64/libForgeKernelFFI.a +0 -0
- package/ios/ForgeApple/ForgeKernelFFI.xcframework/tvos-arm64-simulator/Headers/ForgeKernelFFI.h +17 -0
- package/ios/ForgeApple/ForgeKernelFFI.xcframework/tvos-arm64-simulator/Headers/forge_kernelFFI.h +873 -0
- package/ios/ForgeApple/ForgeKernelFFI.xcframework/tvos-arm64-simulator/Headers/module.modulemap +7 -0
- package/ios/ForgeApple/ForgeKernelFFI.xcframework/tvos-arm64-simulator/libForgeKernelFFI.a +0 -0
- package/ios/ForgeApple/Sources/ForgeKernelUniFFI/forge_kernel.swift +3931 -0
- package/ios/ForgeApple/Sources/ForgeKernelUniFFI/forge_kernelFFI.h +873 -0
- package/ios/ForgeApple/Sources/ForgeKernelUniFFI/forge_kernelFFI.modulemap +7 -0
- package/ios/ForgeApple/Sources/NPAWForgeAVPlayerAdapter/AVPlayerAccessLogFacts.swift +152 -0
- package/ios/ForgeApple/Sources/NPAWForgeAVPlayerAdapter/AVPlayerAdapter.swift +877 -0
- package/ios/ForgeApple/Sources/NPAWForgeCapture/AttributionCapture.swift +158 -0
- package/ios/ForgeApple/Sources/NPAWForgeCapture/CapturePlanGate.swift +189 -0
- package/ios/ForgeApple/Sources/NPAWForgeCapture/CrashCapture.swift +120 -0
- package/ios/ForgeApple/Sources/NPAWForgeCapture/ForgeAutomaticCapture.swift +639 -0
- package/ios/ForgeApple/Sources/NPAWForgeCapture/SectionsCapture.swift +1176 -0
- package/ios/ForgeApple/Sources/NPAWForgeCapture/SectionsEngine.swift +674 -0
- package/ios/ForgeApple/Sources/NPAWForgeCapture/SectionsMarkers.swift +105 -0
- package/ios/ForgeApple/Sources/NPAWForgeCapture/SectionsSwiftUI.swift +284 -0
- package/ios/ForgeApple/Sources/NPAWForgeCapture/SwiftUICapture.swift +44 -0
- package/ios/ForgeApple/Sources/NPAWForgeCore/AdAdapter.swift +199 -0
- package/ios/ForgeApple/Sources/NPAWForgeCore/AdSemantics.swift +136 -0
- package/ios/ForgeApple/Sources/NPAWForgeCore/ForgeApple.swift +596 -0
- package/ios/ForgeApple/Sources/NPAWForgeCore/ForgeCapturePlan.swift +297 -0
- package/ios/ForgeApple/Sources/NPAWForgeCore/ForgeClock.swift +29 -0
- package/ios/ForgeApple/Sources/NPAWForgeCore/ForgeLogging.swift +109 -0
- package/ios/ForgeApple/Sources/NPAWForgeCore/ForgeOutcome.swift +89 -0
- package/ios/ForgeApple/Sources/NPAWForgeCore/ForgeRuntime.swift +291 -0
- package/ios/ForgeApple/Sources/NPAWForgeCore/ForgeStorage.swift +70 -0
- package/ios/ForgeApple/Sources/NPAWForgeCore/ManualAdReporter.swift +180 -0
- package/ios/ForgeApple/Sources/NPAWForgeCore/NPAWForge.swift +453 -0
- package/ios/ForgeApple/Sources/NPAWForgeCore/NativeUniFFIRuntime.swift +594 -0
- package/ios/ForgeApple/Sources/NPAWForgeCore/NetworkRequestRecord.swift +191 -0
- package/ios/ForgeApple/Sources/NPAWForgeCore/ObjectiveCWrappers.swift +191 -0
- package/ios/ForgeApple/Sources/NPAWForgeCore/PlayerAdapter.swift +299 -0
- package/ios/ForgeApple/Sources/NPAWForgeCore/PlayerSemantics.swift +266 -0
- package/ios/ForgeApple/Sources/NPAWForgeCore/PrivacySanitizer.swift +59 -0
- package/ios/ForgeApple/Sources/NPAWForgeCore/VideoAdapter.swift +478 -0
- package/ios/ForgeApple/Sources/NPAWForgeCore/VideoOptions.swift +431 -0
- package/ios/ForgeApple/Sources/NPAWForgeNetworkCapture/ForgeNetworkCapture.swift +116 -0
- package/ios/ForgeApple/Sources/NPAWForgeNetworkCaptureObjC/NPAWForgeNetworkSwizzler.m +293 -0
- package/ios/ForgeApple/Sources/NPAWForgeNetworkCaptureObjC/include/NPAWForgeNetworkSwizzler.h +33 -0
- package/ios/ForgeModuleImpl.swift +1131 -0
- package/ios/NPAWForgeTurboModule.mm +399 -0
- package/package.json +33 -0
- package/src/ForgeAdReporter.ts +101 -0
- package/src/ForgeVideo.ts +169 -0
- package/src/NPAWForge.ts +177 -0
- package/src/capture.ts +192 -0
- package/src/events.ts +40 -0
- package/src/index.ts +37 -0
- package/src/specs/NativeNPAWForge.ts +168 -0
- 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
|