@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,158 @@
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: F4/B4 deep-link attribution derivation. Pure query parser turning a
13
+ * deep-link URL into `raw_attribution` wire params (camelCase utm_*
14
+ * mapping, built-in click-id keys extended by the plan's
15
+ * extraQueryKeys, `attributionOrigin: "deep_link"`, and the optional
16
+ * redacted `utmUrl`), plus the bounded process-wide per-URL dedupe so
17
+ * multi-delegate deep-link delivery emits attribution once. No UIKit:
18
+ * fully unit-testable on macOS.
19
+ */
20
+
21
+ import Foundation
22
+ #if canImport(NPAWForgeCore)
23
+ import NPAWForgeCore
24
+ #endif
25
+
26
+ /// Pure deep-link → `raw_attribution` parameter derivation. Matching is
27
+ /// case-insensitive on the query-key name (marketing URLs vary casing) while
28
+ /// values pass through verbatim; the first occurrence of a duplicated query
29
+ /// key wins, mirroring the kernel's `copy_first` fact promotion.
30
+ public enum AttributionDeepLinkParser {
31
+ /// Built-in paid-click identifier query keys; mirrors the kernel's
32
+ /// `BUILT_IN_CLICK_ID_KEYS`. The plan's `extraQueryKeys` EXTENDS this
33
+ /// list, it never replaces it.
34
+ public static let builtInClickIdKeys = ["gclid", "fbclid", "ttclid", "msclkid", "dclid", "twclid"]
35
+
36
+ /// Wire param carrying the derivation origin; the kernel promotes it to
37
+ /// the `attribution.origin` fact.
38
+ public static let originParamKey = "attributionOrigin"
39
+ /// Canonical origin token for deep-link derivation.
40
+ public static let deepLinkOrigin = "deep_link"
41
+ /// Optional wire param carrying the redacted landing URL. Deliberately
42
+ /// NOT an attribution hit by itself and excluded from PPE preserved keys
43
+ /// kernel-side, so a large URL can never crowd identity fields.
44
+ public static let landingUrlParamKey = "utmUrl"
45
+
46
+ /// `utm_*` query keys mapped to the camelCase wire params the kernel
47
+ /// promotes into `attribution.source/medium/campaign/term/content`.
48
+ private static let utmParamsByQueryKey: [String: String] = [
49
+ "utm_source": "utmSource",
50
+ "utm_medium": "utmMedium",
51
+ "utm_campaign": "utmCampaign",
52
+ "utm_term": "utmTerm",
53
+ "utm_content": "utmContent",
54
+ ]
55
+
56
+ /// Output params owned by the parser itself. A remote `extraQueryKeys`
57
+ /// entry naming one of these (any casing) is rejected so a config edge
58
+ /// can never let `?utmUrl=`/`?attributionOrigin=` count as attribution
59
+ /// hits or spoof the derived origin/landing fields.
60
+ private static let reservedOutputKeysLowercased: Set<String> = [
61
+ originParamKey.lowercased(),
62
+ landingUrlParamKey.lowercased(),
63
+ ]
64
+
65
+ /// Derives `raw_attribution` params from a deep-link URL, or `nil` when
66
+ /// the URL carries no utm/click-id signal. `attributionOrigin` and
67
+ /// `utmUrl` never count as hits on their own: only a matched utm or
68
+ /// click-id query key produces an event.
69
+ public static func parse(
70
+ url: URL,
71
+ extraQueryKeys: [String] = [],
72
+ includeLandingUrl: Bool = true
73
+ ) -> [String: String]? {
74
+ guard let queryItems = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems,
75
+ !queryItems.isEmpty else {
76
+ return nil
77
+ }
78
+ // Canonical output spelling per lowercased query key: utm params map
79
+ // to camelCase, built-ins keep their canonical lowercase form, and
80
+ // remote extra keys keep the configured spelling. Extras never
81
+ // override the utm/built-in mappings (a redundant remote "gclid"
82
+ // stays canonical).
83
+ var paramKeyByQueryKey = utmParamsByQueryKey
84
+ for key in builtInClickIdKeys {
85
+ paramKeyByQueryKey[key] = key
86
+ }
87
+ for key in extraQueryKeys {
88
+ let normalized = key.trimmingCharacters(in: .whitespaces)
89
+ guard !normalized.isEmpty else { continue }
90
+ let lookup = normalized.lowercased()
91
+ guard !reservedOutputKeysLowercased.contains(lookup) else { continue }
92
+ if paramKeyByQueryKey[lookup] == nil {
93
+ paramKeyByQueryKey[lookup] = normalized
94
+ }
95
+ }
96
+ var params: [String: String] = [:]
97
+ for item in queryItems {
98
+ // Valueless (`?gclid`) and empty (`?gclid=`) keys carry no
99
+ // signal: skip instead of emitting empty attribution facts.
100
+ guard let value = item.value, !value.isEmpty else { continue }
101
+ guard let paramKey = paramKeyByQueryKey[item.name.lowercased()] else { continue }
102
+ if params[paramKey] == nil {
103
+ params[paramKey] = value
104
+ }
105
+ }
106
+ guard !params.isEmpty else { return nil }
107
+ params[originParamKey] = deepLinkOrigin
108
+ if includeLandingUrl {
109
+ params[landingUrlParamKey] = PrivacySanitizer.redactUrl(url.absoluteString)
110
+ }
111
+ return params
112
+ }
113
+ }
114
+
115
+ /// Bounded process-wide dedupe: each exact deep-link URL derives attribution
116
+ /// at most once per process, so scene- and app-delegate double delivery (or
117
+ /// SDK rebuilds replaying the same link) cannot double-count campaigns. FIFO
118
+ /// eviction keeps the memory bound tiny; an evicted URL may emit again, which
119
+ /// is preferable to unbounded growth. The check and the record are split so
120
+ /// callers consume a URL's single slot only after the emission was actually
121
+ /// accepted (a rejected capture never burns the slot).
122
+ enum AttributionDeepLinkDedupe {
123
+ static let maxTrackedUrls = 16
124
+
125
+ private static let lock = NSLock()
126
+ private static var seenUrls: [String] = []
127
+
128
+ /// True when the URL already derived accepted attribution this process.
129
+ /// Callers must check this only after every other emission gate passed,
130
+ /// so a plan-disabled sighting never even consults the slot.
131
+ static func hasSeen(url: String) -> Bool {
132
+ lock.lock()
133
+ defer { lock.unlock() }
134
+ return seenUrls.contains(url)
135
+ }
136
+
137
+ /// Records an ACCEPTED derivation for the URL. Never call this for a
138
+ /// rejected/ignored capture outcome: the URL keeps its slot and a later
139
+ /// sighting may still derive attribution.
140
+ static func markSeen(url: String) {
141
+ lock.lock()
142
+ defer { lock.unlock() }
143
+ guard !seenUrls.contains(url) else { return }
144
+ seenUrls.append(url)
145
+ if seenUrls.count > maxTrackedUrls {
146
+ seenUrls.removeFirst(seenUrls.count - maxTrackedUrls)
147
+ }
148
+ }
149
+
150
+ /// Test hook: clears the process-wide seen set so regression tests can
151
+ /// exercise the dedupe deterministically (mirrors
152
+ /// `ForgeCrashExceptionHandler.resetForTesting`).
153
+ static func resetForTesting() {
154
+ lock.lock()
155
+ seenUrls.removeAll()
156
+ lock.unlock()
157
+ }
158
+ }
@@ -0,0 +1,189 @@
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: Effective per-family capture switches combining the local
13
+ * CaptureConfiguration flags with the kernel-derived Capture Plan bits.
14
+ * Before the first plan arrives every family follows the local flags only
15
+ * (today's behavior); once a plan lands, effective(feature) = local flag
16
+ * AND plan bit. The gate is the single family chokepoint consulted by
17
+ * RawEventCapture and the observer reconciliation.
18
+ */
19
+
20
+ import Foundation
21
+ #if canImport(NPAWForgeCore)
22
+ import NPAWForgeCore
23
+ #endif
24
+
25
+ /// Immutable effective feature snapshot. The focus knobs stay `nil` until a
26
+ /// plan arrives so pre-plan behavior remains byte-identical to the plan-less
27
+ /// capture pipeline; Apple has no dwell machinery yet, so the knobs are
28
+ /// carried for future focus observers and for parity with Android.
29
+ public struct EffectiveCaptureFeatures: Equatable {
30
+ public let navigation: Bool
31
+ public let pageView: Bool
32
+ public let clicks: Bool
33
+ public let scrolls: Bool
34
+ public let lifecycle: Bool
35
+ public let deepLink: Bool
36
+ public let focus: Bool
37
+ public let remoteKey: Bool
38
+ public let focusDwellThresholdMs: Int64?
39
+ public let focusDedupeMs: Int64?
40
+ public let repeatUnchangedDwell: Bool
41
+ /// F3 sections family. Unlike the families above, sections stay OFF until
42
+ /// the first plan arrives: the sections observation engine is plan-driven
43
+ /// machinery (install on bit on, uninstall on bit off), so pre-plan
44
+ /// behavior remains byte-identical to the sections-less pipeline.
45
+ public let sections: Bool
46
+ /// Sections knobs from the latest plan; `nil` before the first plan.
47
+ public let sectionsPlan: ForgeCapturePlan.Sections?
48
+ /// F4 attribution family (`raw_attribution`, kernel-gated by `rc.at`).
49
+ /// Mirrors sections: OFF until the first plan arrives, because deep-link
50
+ /// derivation is plan-driven machinery that needs the plan knobs, so
51
+ /// pre-plan behavior remains byte-identical to the attribution-less
52
+ /// pipeline.
53
+ public let attribution: Bool
54
+ /// Attribution knobs from the latest plan; `nil` before the first plan.
55
+ public let attributionPlan: ForgeCapturePlan.Attribution?
56
+
57
+ public init(
58
+ navigation: Bool,
59
+ pageView: Bool,
60
+ clicks: Bool,
61
+ scrolls: Bool,
62
+ lifecycle: Bool,
63
+ deepLink: Bool,
64
+ focus: Bool,
65
+ remoteKey: Bool,
66
+ focusDwellThresholdMs: Int64?,
67
+ focusDedupeMs: Int64?,
68
+ repeatUnchangedDwell: Bool,
69
+ sections: Bool = false,
70
+ sectionsPlan: ForgeCapturePlan.Sections? = nil,
71
+ attribution: Bool = false,
72
+ attributionPlan: ForgeCapturePlan.Attribution? = nil
73
+ ) {
74
+ self.navigation = navigation
75
+ self.pageView = pageView
76
+ self.clicks = clicks
77
+ self.scrolls = scrolls
78
+ self.lifecycle = lifecycle
79
+ self.deepLink = deepLink
80
+ self.focus = focus
81
+ self.remoteKey = remoteKey
82
+ self.focusDwellThresholdMs = focusDwellThresholdMs
83
+ self.focusDedupeMs = focusDedupeMs
84
+ self.repeatUnchangedDwell = repeatUnchangedDwell
85
+ self.sections = sections
86
+ self.sectionsPlan = sectionsPlan
87
+ self.attribution = attribution
88
+ self.attributionPlan = attributionPlan
89
+ }
90
+ }
91
+
92
+ /// Combines the local configuration flags with an optional kernel plan.
93
+ /// Pre-plan defaults mirror the historical routing exactly: deep links follow
94
+ /// the navigation switch, scrolls/swipes follow the click switch, and the
95
+ /// focus/remote-key families (which have no local Apple switch) stay open.
96
+ public func effectiveCaptureFeatures(
97
+ configuration: CaptureConfiguration,
98
+ plan: ForgeCapturePlan?
99
+ ) -> EffectiveCaptureFeatures {
100
+ func bit(_ bit: UInt32) -> Bool { plan?.isEnabled(bit) ?? true }
101
+ return EffectiveCaptureFeatures(
102
+ navigation: configuration.navigationEnabled && bit(ForgeCapturePlan.Bits.navigation),
103
+ pageView: configuration.navigationEnabled && bit(ForgeCapturePlan.Bits.pageView),
104
+ clicks: configuration.clickEnabled && bit(ForgeCapturePlan.Bits.clicks),
105
+ scrolls: configuration.clickEnabled && bit(ForgeCapturePlan.Bits.scrolls),
106
+ lifecycle: configuration.lifecycleEnabled && bit(ForgeCapturePlan.Bits.lifecycle),
107
+ deepLink: configuration.navigationEnabled && bit(ForgeCapturePlan.Bits.deepLink),
108
+ focus: bit(ForgeCapturePlan.Bits.focus),
109
+ remoteKey: bit(ForgeCapturePlan.Bits.remoteKey),
110
+ focusDwellThresholdMs: plan?.focus.dwellThresholdMs,
111
+ focusDedupeMs: plan?.focus.dedupeMs,
112
+ repeatUnchangedDwell: plan?.focus.repeatUnchangedDwell ?? false,
113
+ // Sections are plan-driven machinery: closed before the first plan.
114
+ sections: plan.map { $0.isEnabled(ForgeCapturePlan.Bits.sections) } ?? false,
115
+ sectionsPlan: plan?.sections,
116
+ // Attribution derivation is plan-driven machinery too: closed before
117
+ // the first plan (no local Apple switch; the plan bit decides).
118
+ attribution: plan.map { $0.isEnabled(ForgeCapturePlan.Bits.attribution) } ?? false,
119
+ attributionPlan: plan?.attribution
120
+ )
121
+ }
122
+
123
+ /// Thread-safe holder for the current effective features. `apply` is safe to
124
+ /// call from the runtime callback thread; readers observe a consistent
125
+ /// immutable snapshot.
126
+ public final class CapturePlanGate {
127
+ private let configuration: CaptureConfiguration
128
+ private let lock = NSLock()
129
+ private var currentFeatures: EffectiveCaptureFeatures
130
+ private var currentPlan: ForgeCapturePlan?
131
+
132
+ public init(configuration: CaptureConfiguration) {
133
+ self.configuration = configuration
134
+ self.currentFeatures = effectiveCaptureFeatures(configuration: configuration, plan: nil)
135
+ }
136
+
137
+ public var features: EffectiveCaptureFeatures {
138
+ lock.lock()
139
+ defer { lock.unlock() }
140
+ return currentFeatures
141
+ }
142
+
143
+ public var plan: ForgeCapturePlan? {
144
+ lock.lock()
145
+ defer { lock.unlock() }
146
+ return currentPlan
147
+ }
148
+
149
+ public func apply(_ plan: ForgeCapturePlan) {
150
+ let features = effectiveCaptureFeatures(configuration: configuration, plan: plan)
151
+ lock.lock()
152
+ currentPlan = plan
153
+ currentFeatures = features
154
+ lock.unlock()
155
+ }
156
+
157
+ /// Family gate for normalized `raw_*` event names. Mirrors the historical
158
+ /// CaptureConfiguration routing with the plan bits ANDed in; scroll/swipe
159
+ /// events separate from tap/click through the dedicated scrolls bit while
160
+ /// both keep the local `clickEnabled` switch.
161
+ public func isEventFamilyEnabled(_ eventName: String) -> Bool {
162
+ let features = features
163
+ switch eventName {
164
+ case RawEventName.screenView, RawEventName.fragmentView, RawEventName.composeView:
165
+ return features.navigation
166
+ case RawEventName.pageView:
167
+ return features.pageView
168
+ case RawEventName.deepLinkReceived:
169
+ return features.deepLink
170
+ case RawEventName.tap, RawEventName.click:
171
+ return features.clicks
172
+ case RawEventName.scroll, RawEventName.swipe:
173
+ return features.scrolls
174
+ case RawEventName.focusChange, RawEventName.focusDwell:
175
+ return features.focus
176
+ case RawEventName.remoteKey:
177
+ return features.remoteKey
178
+ case RawEventName.sectionVisible, RawEventName.sectionHidden,
179
+ RawEventName.contentImpression, RawEventName.contentDwell:
180
+ return features.sections
181
+ case RawEventName.attribution:
182
+ return features.attribution
183
+ case RawEventName.applicationForeground, RawEventName.applicationBackground:
184
+ return features.lifecycle
185
+ default:
186
+ return eventName.hasPrefix("raw_")
187
+ }
188
+ }
189
+ }
@@ -0,0 +1,120 @@
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: Bounded safe crash record persistence and next-launch retry support for Apple capture.
13
+ */
14
+
15
+ import Foundation
16
+ #if canImport(NPAWForgeCore)
17
+ import NPAWForgeCore
18
+ #endif
19
+
20
+ struct CrashRecord: Codable, Equatable {
21
+ let id: String
22
+ let timestampMs: Int64
23
+ let name: String
24
+ let reason: String
25
+ let stack: String
26
+
27
+ init(exception: NSException) {
28
+ let timestamp = ForgeClock.nowMs()
29
+ self.timestampMs = timestamp
30
+ self.name = exception.name.rawValue
31
+ self.reason = exception.reason ?? "unknown"
32
+ self.stack = exception.callStackSymbols.joined(separator: "\n")
33
+ self.id = Self.stableId(name: self.name, reason: self.reason, timestampMs: timestamp, stack: self.stack)
34
+ }
35
+
36
+ func payload(deliveryMode: String) -> [String: String] {
37
+ [
38
+ "data.crashId": id,
39
+ "data.deliveryMode": deliveryMode,
40
+ "data.crashTimestampMs": String(timestampMs),
41
+ "data.exception.name": name,
42
+ "data.exception.reason": reason,
43
+ "data.exception.stack": String(stack.prefix(16_384)),
44
+ "data.runtimeFamily": "apple",
45
+ ]
46
+ }
47
+
48
+ private static func stableId(name: String, reason: String, timestampMs: Int64, stack: String) -> String {
49
+ let bucket = timestampMs / 10_000
50
+ let raw = "\(name)|\(reason.prefix(256))|\(stack.components(separatedBy: "\n").first ?? "")|\(bucket)"
51
+ return String(raw.hashValue)
52
+ }
53
+ }
54
+
55
+ final class CrashRecordStore {
56
+ static let defaultStore = CrashRecordStore(directory: FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first?
57
+ .appendingPathComponent("forge-crashes", isDirectory: true))
58
+
59
+ private let directory: URL?
60
+ private let maxPending = 5
61
+ private let lock = NSLock()
62
+
63
+ init(directory: URL?) {
64
+ self.directory = directory
65
+ }
66
+
67
+ func store(_ record: CrashRecord) {
68
+ lock.lock()
69
+ defer { lock.unlock() }
70
+ guard let directory else { return }
71
+ try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
72
+ var records = loadPendingUnlocked().filter { $0.id != record.id }
73
+ records.append(record)
74
+ records.sort { $0.timestampMs > $1.timestampMs }
75
+ records = Array(records.prefix(maxPending))
76
+ clearUnlocked()
77
+ let encoder = JSONEncoder()
78
+ for item in records {
79
+ if let data = try? encoder.encode(item) {
80
+ try? data.write(to: path(for: item.id), options: [.atomic])
81
+ }
82
+ }
83
+ }
84
+
85
+ func loadPending() -> [CrashRecord] {
86
+ lock.lock()
87
+ defer { lock.unlock() }
88
+ return loadPendingUnlocked()
89
+ }
90
+
91
+ func markSent(_ id: String) {
92
+ lock.lock()
93
+ defer { lock.unlock() }
94
+ guard let directory else { return }
95
+ let files = (try? FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil)) ?? []
96
+ for file in files where file.lastPathComponent.contains(id) {
97
+ try? FileManager.default.removeItem(at: file)
98
+ }
99
+ }
100
+
101
+ private func loadPendingUnlocked() -> [CrashRecord] {
102
+ guard let directory else { return [] }
103
+ let files = (try? FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil)) ?? []
104
+ let decoder = JSONDecoder()
105
+ return files.compactMap { file in
106
+ guard let data = try? Data(contentsOf: file) else { return nil }
107
+ return try? decoder.decode(CrashRecord.self, from: data)
108
+ }.sorted { $0.timestampMs > $1.timestampMs }
109
+ }
110
+
111
+ private func clearUnlocked() {
112
+ guard let directory else { return }
113
+ let files = (try? FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil)) ?? []
114
+ files.forEach { try? FileManager.default.removeItem(at: $0) }
115
+ }
116
+
117
+ private func path(for id: String) -> URL {
118
+ directory?.appendingPathComponent("\(id).crash") ?? URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("\(id).crash")
119
+ }
120
+ }