@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,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
|
+
}
|