@scalebun/react-native 1.0.3 → 1.1.0

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 (244) hide show
  1. package/android/src/main/java/com/scalebun/core/crash/CrashStorage.kt +115 -1
  2. package/android/src/main/java/com/scalebun/core/crash/NativeCrashHandler.kt +14 -0
  3. package/android/src/main/java/com/scalebun/replaysdk/ReplaySdkModule.kt +25 -9
  4. package/android/src/main/java/com/scalebun/replaysdk/voice/VoiceTranscriber.kt +68 -2
  5. package/android/src/main/java/com/scalebun/rn/crash/ScaleBunCrashModule.kt +66 -2
  6. package/android/src/main/java/com/scalebun/rn/ota/SlotManager.kt +70 -4
  7. package/ios/Crash/ScaleBunCrashBridge.mm +6 -0
  8. package/ios/Crash/ScaleBunCrashHandler.swift +14 -1
  9. package/ios/Crash/ScaleBunCrashModule.swift +14 -0
  10. package/ios/Crash/ScaleBunCrashSignalBridge.h +32 -1
  11. package/ios/Crash/ScaleBunCrashSignalBridge.m +172 -7
  12. package/ios/Crash/ScaleBunCrashSignalStress.c +137 -0
  13. package/ios/Crash/ScaleBunCrashStorage.swift +75 -0
  14. package/lib/commonjs/analytics/EventTracker.js +30 -2
  15. package/lib/commonjs/analytics/EventTracker.js.map +1 -1
  16. package/lib/commonjs/bootstrap/SDKBootstrapper.js +31 -23
  17. package/lib/commonjs/bootstrap/SDKBootstrapper.js.map +1 -1
  18. package/lib/commonjs/compat/codepush.js +78 -15
  19. package/lib/commonjs/compat/codepush.js.map +1 -1
  20. package/lib/commonjs/core/config/schema.js +26 -5
  21. package/lib/commonjs/core/config/schema.js.map +1 -1
  22. package/lib/commonjs/core/constants/endpoints.js +9 -6
  23. package/lib/commonjs/core/constants/endpoints.js.map +1 -1
  24. package/lib/commonjs/core/context/device.js +130 -0
  25. package/lib/commonjs/core/context/device.js.map +1 -1
  26. package/lib/commonjs/debug/replayWiring.js +2 -1
  27. package/lib/commonjs/debug/replayWiring.js.map +1 -1
  28. package/lib/commonjs/features/bugreport/BugReportFeature.js +112 -0
  29. package/lib/commonjs/features/bugreport/BugReportFeature.js.map +1 -0
  30. package/lib/commonjs/features/bugreport/buildPayload.js +97 -0
  31. package/lib/commonjs/features/bugreport/buildPayload.js.map +1 -0
  32. package/lib/commonjs/features/crash/CrashReporter.js +25 -1
  33. package/lib/commonjs/features/crash/CrashReporter.js.map +1 -1
  34. package/lib/commonjs/features/crash/capture.js +27 -9
  35. package/lib/commonjs/features/crash/capture.js.map +1 -1
  36. package/lib/commonjs/features/crash/nativeCrashBridge.js +163 -28
  37. package/lib/commonjs/features/crash/nativeCrashBridge.js.map +1 -1
  38. package/lib/commonjs/features/engage/EngagePromptProvider.js +36 -2
  39. package/lib/commonjs/features/engage/EngagePromptProvider.js.map +1 -1
  40. package/lib/commonjs/features/engage/EngagePromptView.js +47 -14
  41. package/lib/commonjs/features/engage/EngagePromptView.js.map +1 -1
  42. package/lib/commonjs/features/engage/engageTriggerEngine.js +10 -0
  43. package/lib/commonjs/features/engage/engageTriggerEngine.js.map +1 -1
  44. package/lib/commonjs/features/engage/engageTypes.js.map +1 -1
  45. package/lib/commonjs/features/journey/ScaleBunDebugRoot.js +2 -1
  46. package/lib/commonjs/features/journey/ScaleBunDebugRoot.js.map +1 -1
  47. package/lib/commonjs/features/ota/OtaOrchestrator.js +72 -5
  48. package/lib/commonjs/features/ota/OtaOrchestrator.js.map +1 -1
  49. package/lib/commonjs/features/ota/deviceAttributes.js +48 -0
  50. package/lib/commonjs/features/ota/deviceAttributes.js.map +1 -0
  51. package/lib/commonjs/features/ota/geoCountry.js +93 -0
  52. package/lib/commonjs/features/ota/geoCountry.js.map +1 -0
  53. package/lib/commonjs/features/ota/useOtaUpdate.js +4 -1
  54. package/lib/commonjs/features/ota/useOtaUpdate.js.map +1 -1
  55. package/lib/commonjs/features/performance/PerformanceFeature.js +23 -9
  56. package/lib/commonjs/features/performance/PerformanceFeature.js.map +1 -1
  57. package/lib/commonjs/features/performance/collectors/FrameMetricsCollector.js +69 -9
  58. package/lib/commonjs/features/performance/collectors/FrameMetricsCollector.js.map +1 -1
  59. package/lib/commonjs/features/performance/transport/PerformanceTransport.js +25 -0
  60. package/lib/commonjs/features/performance/transport/PerformanceTransport.js.map +1 -1
  61. package/lib/commonjs/features/session/BackendSessionAdapter.js +128 -11
  62. package/lib/commonjs/features/session/BackendSessionAdapter.js.map +1 -1
  63. package/lib/commonjs/features/session/JourneyEventPipeline.js +1 -0
  64. package/lib/commonjs/features/session/JourneyEventPipeline.js.map +1 -1
  65. package/lib/commonjs/features/session/ReplayCaptureManager.js +46 -0
  66. package/lib/commonjs/features/session/ReplayCaptureManager.js.map +1 -1
  67. package/lib/commonjs/features/session/SessionManager.js +42 -12
  68. package/lib/commonjs/features/session/SessionManager.js.map +1 -1
  69. package/lib/commonjs/features/session/nativeCapture.js +4 -4
  70. package/lib/commonjs/features/session/nativeCapture.js.map +1 -1
  71. package/lib/commonjs/features/session/outboxFrameCapture.js +3 -2
  72. package/lib/commonjs/features/session/outboxFrameCapture.js.map +1 -1
  73. package/lib/commonjs/features/session/sessionTypes.js.map +1 -1
  74. package/lib/commonjs/index.js +13 -0
  75. package/lib/commonjs/index.js.map +1 -1
  76. package/lib/commonjs/public/ScaleBunFacade.js +199 -2
  77. package/lib/commonjs/public/ScaleBunFacade.js.map +1 -1
  78. package/lib/commonjs/specs/NativeScaleBunCrash.js.map +1 -1
  79. package/lib/module/analytics/EventTracker.js +29 -1
  80. package/lib/module/analytics/EventTracker.js.map +1 -1
  81. package/lib/module/bootstrap/SDKBootstrapper.js +29 -21
  82. package/lib/module/bootstrap/SDKBootstrapper.js.map +1 -1
  83. package/lib/module/compat/codepush.js +77 -14
  84. package/lib/module/compat/codepush.js.map +1 -1
  85. package/lib/module/core/config/schema.js +26 -5
  86. package/lib/module/core/config/schema.js.map +1 -1
  87. package/lib/module/core/constants/endpoints.js +9 -6
  88. package/lib/module/core/constants/endpoints.js.map +1 -1
  89. package/lib/module/core/context/device.js +119 -0
  90. package/lib/module/core/context/device.js.map +1 -1
  91. package/lib/module/debug/replayWiring.js +2 -1
  92. package/lib/module/debug/replayWiring.js.map +1 -1
  93. package/lib/module/features/bugreport/BugReportFeature.js +105 -0
  94. package/lib/module/features/bugreport/BugReportFeature.js.map +1 -0
  95. package/lib/module/features/bugreport/buildPayload.js +91 -0
  96. package/lib/module/features/bugreport/buildPayload.js.map +1 -0
  97. package/lib/module/features/crash/CrashReporter.js +25 -1
  98. package/lib/module/features/crash/CrashReporter.js.map +1 -1
  99. package/lib/module/features/crash/capture.js +28 -9
  100. package/lib/module/features/crash/capture.js.map +1 -1
  101. package/lib/module/features/crash/nativeCrashBridge.js +161 -28
  102. package/lib/module/features/crash/nativeCrashBridge.js.map +1 -1
  103. package/lib/module/features/engage/EngagePromptProvider.js +36 -2
  104. package/lib/module/features/engage/EngagePromptProvider.js.map +1 -1
  105. package/lib/module/features/engage/EngagePromptView.js +47 -14
  106. package/lib/module/features/engage/EngagePromptView.js.map +1 -1
  107. package/lib/module/features/engage/engageTriggerEngine.js +10 -0
  108. package/lib/module/features/engage/engageTriggerEngine.js.map +1 -1
  109. package/lib/module/features/engage/engageTypes.js.map +1 -1
  110. package/lib/module/features/journey/ScaleBunDebugRoot.js +2 -1
  111. package/lib/module/features/journey/ScaleBunDebugRoot.js.map +1 -1
  112. package/lib/module/features/ota/OtaOrchestrator.js +72 -5
  113. package/lib/module/features/ota/OtaOrchestrator.js.map +1 -1
  114. package/lib/module/features/ota/deviceAttributes.js +43 -0
  115. package/lib/module/features/ota/deviceAttributes.js.map +1 -0
  116. package/lib/module/features/ota/geoCountry.js +86 -0
  117. package/lib/module/features/ota/geoCountry.js.map +1 -0
  118. package/lib/module/features/ota/useOtaUpdate.js +4 -1
  119. package/lib/module/features/ota/useOtaUpdate.js.map +1 -1
  120. package/lib/module/features/performance/PerformanceFeature.js +23 -9
  121. package/lib/module/features/performance/PerformanceFeature.js.map +1 -1
  122. package/lib/module/features/performance/collectors/FrameMetricsCollector.js +70 -10
  123. package/lib/module/features/performance/collectors/FrameMetricsCollector.js.map +1 -1
  124. package/lib/module/features/performance/transport/PerformanceTransport.js +25 -0
  125. package/lib/module/features/performance/transport/PerformanceTransport.js.map +1 -1
  126. package/lib/module/features/session/BackendSessionAdapter.js +128 -11
  127. package/lib/module/features/session/BackendSessionAdapter.js.map +1 -1
  128. package/lib/module/features/session/JourneyEventPipeline.js +1 -0
  129. package/lib/module/features/session/JourneyEventPipeline.js.map +1 -1
  130. package/lib/module/features/session/ReplayCaptureManager.js +46 -0
  131. package/lib/module/features/session/ReplayCaptureManager.js.map +1 -1
  132. package/lib/module/features/session/SessionManager.js +42 -12
  133. package/lib/module/features/session/SessionManager.js.map +1 -1
  134. package/lib/module/features/session/nativeCapture.js +4 -5
  135. package/lib/module/features/session/nativeCapture.js.map +1 -1
  136. package/lib/module/features/session/outboxFrameCapture.js +3 -2
  137. package/lib/module/features/session/outboxFrameCapture.js.map +1 -1
  138. package/lib/module/features/session/sessionTypes.js.map +1 -1
  139. package/lib/module/index.js +4 -1
  140. package/lib/module/index.js.map +1 -1
  141. package/lib/module/public/ScaleBunFacade.js +199 -2
  142. package/lib/module/public/ScaleBunFacade.js.map +1 -1
  143. package/lib/module/specs/NativeScaleBunCrash.js.map +1 -1
  144. package/lib/typescript/analytics/EventTracker.d.ts +20 -0
  145. package/lib/typescript/analytics/EventTracker.d.ts.map +1 -1
  146. package/lib/typescript/bootstrap/SDKBootstrapper.d.ts.map +1 -1
  147. package/lib/typescript/compat/codepush.d.ts +27 -4
  148. package/lib/typescript/compat/codepush.d.ts.map +1 -1
  149. package/lib/typescript/core/config/schema.d.ts +4 -1
  150. package/lib/typescript/core/config/schema.d.ts.map +1 -1
  151. package/lib/typescript/core/constants/endpoints.d.ts +9 -6
  152. package/lib/typescript/core/constants/endpoints.d.ts.map +1 -1
  153. package/lib/typescript/core/context/device.d.ts +46 -0
  154. package/lib/typescript/core/context/device.d.ts.map +1 -1
  155. package/lib/typescript/debug/replayWiring.d.ts.map +1 -1
  156. package/lib/typescript/features/bugreport/BugReportFeature.d.ts +59 -0
  157. package/lib/typescript/features/bugreport/BugReportFeature.d.ts.map +1 -0
  158. package/lib/typescript/features/bugreport/buildPayload.d.ts +51 -0
  159. package/lib/typescript/features/bugreport/buildPayload.d.ts.map +1 -0
  160. package/lib/typescript/features/crash/CrashReporter.d.ts +20 -1
  161. package/lib/typescript/features/crash/CrashReporter.d.ts.map +1 -1
  162. package/lib/typescript/features/crash/capture.d.ts +21 -2
  163. package/lib/typescript/features/crash/capture.d.ts.map +1 -1
  164. package/lib/typescript/features/crash/nativeCrashBridge.d.ts +16 -3
  165. package/lib/typescript/features/crash/nativeCrashBridge.d.ts.map +1 -1
  166. package/lib/typescript/features/engage/EngagePromptProvider.d.ts.map +1 -1
  167. package/lib/typescript/features/engage/EngagePromptView.d.ts.map +1 -1
  168. package/lib/typescript/features/engage/EngageTransport.d.ts +1 -1
  169. package/lib/typescript/features/engage/EngageTransport.d.ts.map +1 -1
  170. package/lib/typescript/features/engage/engageTriggerEngine.d.ts.map +1 -1
  171. package/lib/typescript/features/engage/engageTypes.d.ts +15 -0
  172. package/lib/typescript/features/engage/engageTypes.d.ts.map +1 -1
  173. package/lib/typescript/features/journey/ScaleBunDebugRoot.d.ts.map +1 -1
  174. package/lib/typescript/features/ota/OtaOrchestrator.d.ts +14 -2
  175. package/lib/typescript/features/ota/OtaOrchestrator.d.ts.map +1 -1
  176. package/lib/typescript/features/ota/deviceAttributes.d.ts +18 -0
  177. package/lib/typescript/features/ota/deviceAttributes.d.ts.map +1 -0
  178. package/lib/typescript/features/ota/geoCountry.d.ts +14 -0
  179. package/lib/typescript/features/ota/geoCountry.d.ts.map +1 -0
  180. package/lib/typescript/features/ota/useOtaUpdate.d.ts +20 -2
  181. package/lib/typescript/features/ota/useOtaUpdate.d.ts.map +1 -1
  182. package/lib/typescript/features/performance/PerformanceFeature.d.ts +0 -1
  183. package/lib/typescript/features/performance/PerformanceFeature.d.ts.map +1 -1
  184. package/lib/typescript/features/performance/collectors/FrameMetricsCollector.d.ts +33 -0
  185. package/lib/typescript/features/performance/collectors/FrameMetricsCollector.d.ts.map +1 -1
  186. package/lib/typescript/features/performance/transport/PerformanceTransport.d.ts.map +1 -1
  187. package/lib/typescript/features/session/BackendSessionAdapter.d.ts +42 -2
  188. package/lib/typescript/features/session/BackendSessionAdapter.d.ts.map +1 -1
  189. package/lib/typescript/features/session/JourneyEventPipeline.d.ts +2 -0
  190. package/lib/typescript/features/session/JourneyEventPipeline.d.ts.map +1 -1
  191. package/lib/typescript/features/session/ReplayCaptureManager.d.ts +38 -1
  192. package/lib/typescript/features/session/ReplayCaptureManager.d.ts.map +1 -1
  193. package/lib/typescript/features/session/SessionManager.d.ts.map +1 -1
  194. package/lib/typescript/features/session/nativeCapture.d.ts +2 -1
  195. package/lib/typescript/features/session/nativeCapture.d.ts.map +1 -1
  196. package/lib/typescript/features/session/outboxFrameCapture.d.ts.map +1 -1
  197. package/lib/typescript/features/session/sessionTypes.d.ts +20 -3
  198. package/lib/typescript/features/session/sessionTypes.d.ts.map +1 -1
  199. package/lib/typescript/index.d.ts +1 -0
  200. package/lib/typescript/index.d.ts.map +1 -1
  201. package/lib/typescript/public/ScaleBunFacade.d.ts +69 -0
  202. package/lib/typescript/public/ScaleBunFacade.d.ts.map +1 -1
  203. package/lib/typescript/public/types.d.ts +34 -8
  204. package/lib/typescript/public/types.d.ts.map +1 -1
  205. package/lib/typescript/specs/NativeScaleBunCrash.d.ts +14 -0
  206. package/lib/typescript/specs/NativeScaleBunCrash.d.ts.map +1 -1
  207. package/lib/typescript/vendor/protocol/index.d.ts +17 -17
  208. package/package.json +1 -1
  209. package/src/analytics/EventTracker.ts +28 -1
  210. package/src/bootstrap/SDKBootstrapper.ts +27 -23
  211. package/src/compat/codepush.ts +87 -16
  212. package/src/core/config/schema.ts +26 -5
  213. package/src/core/constants/endpoints.ts +9 -6
  214. package/src/core/context/device.ts +146 -0
  215. package/src/debug/replayWiring.ts +2 -1
  216. package/src/features/bugreport/BugReportFeature.ts +128 -0
  217. package/src/features/bugreport/buildPayload.ts +116 -0
  218. package/src/features/crash/CrashReporter.ts +40 -1
  219. package/src/features/crash/capture.ts +36 -9
  220. package/src/features/crash/nativeCrashBridge.ts +156 -22
  221. package/src/features/engage/EngagePromptProvider.tsx +48 -2
  222. package/src/features/engage/EngagePromptView.tsx +51 -15
  223. package/src/features/engage/EngageTransport.ts +1 -1
  224. package/src/features/engage/engageTriggerEngine.ts +15 -0
  225. package/src/features/engage/engageTypes.ts +15 -0
  226. package/src/features/journey/ScaleBunDebugRoot.tsx +2 -1
  227. package/src/features/ota/OtaOrchestrator.ts +84 -6
  228. package/src/features/ota/deviceAttributes.ts +46 -0
  229. package/src/features/ota/geoCountry.ts +85 -0
  230. package/src/features/ota/useOtaUpdate.ts +20 -2
  231. package/src/features/performance/PerformanceFeature.ts +22 -9
  232. package/src/features/performance/collectors/FrameMetricsCollector.ts +72 -10
  233. package/src/features/performance/transport/PerformanceTransport.ts +18 -0
  234. package/src/features/session/BackendSessionAdapter.ts +137 -10
  235. package/src/features/session/JourneyEventPipeline.ts +3 -0
  236. package/src/features/session/ReplayCaptureManager.ts +53 -1
  237. package/src/features/session/SessionManager.ts +46 -7
  238. package/src/features/session/nativeCapture.ts +3 -2
  239. package/src/features/session/outboxFrameCapture.ts +3 -2
  240. package/src/features/session/sessionTypes.ts +26 -3
  241. package/src/index.ts +4 -1
  242. package/src/public/ScaleBunFacade.ts +198 -2
  243. package/src/public/types.ts +35 -8
  244. package/src/specs/NativeScaleBunCrash.ts +16 -0
@@ -21,6 +21,7 @@
21
21
  import { createStorageBackend, StorageBackend } from '../storage/StorageBackend';
22
22
  import { getDeviceId } from '../core/id/deviceId';
23
23
  import { logger } from '../core/logger/internalLogger';
24
+ import { setNativeActiveSession } from '../features/crash/nativeCrashBridge';
24
25
  import { hmacSha256Hex } from '../crypto/hmacSha256';
25
26
  import { MAX_BATCH_EVENTS, clampBatchSize, batchIdempotencyKey } from './batching';
26
27
  import { subscribeAutomaticEvents } from './automaticEvents';
@@ -29,7 +30,14 @@ import { subscribeAutomaticEvents } from './automaticEvents';
29
30
  // treats an unsigned/invalid batch from a signing-capable SDK as a fraud signal.
30
31
  const SDK_SIGNING_VERSION = '1';
31
32
 
32
- const K_INSTALLATION_ID = 'scalebun_installation_id';
33
+ /**
34
+ * Exported because this is THE canonical per-install identity key for the whole
35
+ * SDK. The OTA lane used to demand a caller-supplied installationId instead of
36
+ * reading this one, which put OTA telemetry in its own id namespace — every
37
+ * join from OTA to CDP (blast radius, MRR-at-risk, churn) matched by luck.
38
+ * One install, one id, every lane.
39
+ */
40
+ export const K_INSTALLATION_ID = 'scalebun_installation_id';
33
41
  const K_ANONYMOUS_ID = 'scalebun_anonymous_id';
34
42
  const K_FIRST_OPEN = 'scalebun_first_open_done';
35
43
  /** Last app version seen on this device — the baseline app_update compares against. */
@@ -143,6 +151,9 @@ export class EventTracker {
143
151
  this.installationId = this.loadOrCreate(K_INSTALLATION_ID);
144
152
  this.anonymousId = this.loadOrCreate(K_ANONYMOUS_ID);
145
153
  this.sessionId = `sess-${uuid()}`;
154
+ // Published immediately, before anything else runs — a crash can happen on the very next
155
+ // line, and native must already know which session owns it.
156
+ this.publishSessionToNative();
146
157
  this.loadQueue();
147
158
  this.loadClickId();
148
159
  }
@@ -210,9 +221,25 @@ export class EventTracker {
210
221
  /** Start a fresh session (e.g. after the app returns from a long background). */
211
222
  newSession(): void {
212
223
  this.sessionId = `sess-${uuid()}`;
224
+ this.publishSessionToNative();
213
225
  this.track('session_start');
214
226
  }
215
227
 
228
+ /**
229
+ * Hand the live session id to the native crash layer.
230
+ *
231
+ * Called at EVERY point the id changes — construction and rollover — because a crash-safe copy
232
+ * that is one session stale is worse than none: it would confidently attribute a crash to a
233
+ * session that had already ended.
234
+ *
235
+ * Fire-and-forget. Native persists it synchronously on its own side; a failure here degrades a
236
+ * recovered crash to unattributed, which is exactly the behaviour before this existed, and must
237
+ * never be able to fail a session start.
238
+ */
239
+ private publishSessionToNative(): void {
240
+ void setNativeActiveSession(this.sessionId);
241
+ }
242
+
216
243
  // ─── identity ────────────────────────────────────────────────────────────
217
244
 
218
245
  identify(userId: string, traits?: Record<string, any>): void {
@@ -17,6 +17,7 @@
17
17
 
18
18
  import { Linking, Platform } from 'react-native';
19
19
  import { ConfigSchema, type ScaleBunConfig } from '../core/config/schema';
20
+ import { DEFAULT_API_BASE_URL } from '../core/constants/endpoints';
20
21
  import { logger, LogLevel } from '../core/logger/internalLogger';
21
22
  import { coreContainer } from '../core/di/container';
22
23
  import { HttpClient } from '../transport/http/httpClient';
@@ -85,6 +86,10 @@ export class SDKBootstrapper {
85
86
  }
86
87
 
87
88
  this.config = result.data;
89
+ // apiBaseUrl is LOCKED: whatever an integrator passed (or the schema default) is
90
+ // discarded here so the SDK can never be repointed away from the ScaleBun cloud.
91
+ // Change DEFAULT_API_BASE_URL at build time to target a different backend.
92
+ this.config.apiBaseUrl = DEFAULT_API_BASE_URL;
88
93
  // Explicit logLevel wins; otherwise debug:true implies verbose 'debug'.
89
94
  if (this.config.logLevel) {
90
95
  logger.setLevelByName(this.config.logLevel);
@@ -121,20 +126,16 @@ export class SDKBootstrapper {
121
126
  new HttpClient(this.config.apiBaseUrl),
122
127
  );
123
128
 
124
- const clientKey = this.config.clientKey;
125
- coreContainer.register(
126
- 'Dispatcher',
127
- new Dispatcher(async (batch) => {
128
- const headers: Record<string, string> = {};
129
- if (clientKey) {
130
- headers['x-scalebun-client-key'] = clientKey;
131
- }
132
- await httpClient.post(ENDPOINTS.EVENTS, {
133
- deviceId: getDeviceId(),
134
- events: batch,
135
- }, headers);
136
- }),
137
- );
129
+ // D1: a `Dispatcher` used to be registered here, draining `memoryQueue`
130
+ // to ENDPOINTS.EVENTS ('/sdk/events'). Removed on two grounds, both
131
+ // verified: nothing anywhere resolved 'Dispatcher' from the container
132
+ // (so its flush never ran and the queue was a black hole), and no
133
+ // backend controller has ever served '/sdk/events' — even a wired
134
+ // dispatcher would have 404'd forever. Every producer that wrote into
135
+ // that pipeline now rides a live lane instead (captureException →
136
+ // CrashReporter, native-crash detail → report metadata, metrics →
137
+ // queuePerformanceMetric). A decoy pipeline is worse than none: it
138
+ // makes the real path look redundant.
138
139
 
139
140
  const scheduler = coreContainer.register(
140
141
  'FlushScheduler',
@@ -394,15 +395,18 @@ export class SDKBootstrapper {
394
395
  const analyticsStarted = backendAdapter
395
396
  .startAnalyticsSession(this._buildAnalyticsSession());
396
397
 
397
- // Cold-launch frame recording. The AppState listener only
398
- // starts a recording session on a foreground TRANSITION, which
399
- // never fires on the initial launch — so without this the first
400
- // session uploads events but zero frames ("No screen recordings").
401
- // Start it once the analytics session id exists so startSession
402
- // adopts it (one-row merge) instead of minting an orphan row.
403
- // Gated by the same finalizeOnBackground flag as the per-foreground
404
- // recording path above.
405
- if (this.config.finalizeOnBackground) {
398
+ // Cold-launch frame recording. The AppState listener only starts a
399
+ // recording session on a foreground TRANSITION, which never fires on the
400
+ // initial launch — so without this the first session uploads events but zero
401
+ // frames ("No screen recordings"). Auto-start recording by DEFAULT in SaaS mode
402
+ // so replays appear out of the box; hosts opt out with `replay: { record: false }`
403
+ // for events-only sessions (they can still record on demand via startSession()).
404
+ // Deliberately NOT gated by finalizeOnBackground (that flag only governs
405
+ // per-foreground session boundaries, not whether replay records at all).
406
+ // Started once the analytics session id exists so startSession adopts it
407
+ // (one-row merge) instead of minting an orphan row.
408
+ const shouldAutoRecord = this.config.replay?.record !== false;
409
+ if (shouldAutoRecord) {
406
410
  analyticsStarted
407
411
  .then(() => {
408
412
  if (!sessionManager.isActive) {
@@ -1,4 +1,5 @@
1
1
  import { otaOrchestrator } from '../features/ota/OtaOrchestrator';
2
+ import { logger } from '../core/logger/internalLogger';
2
3
 
3
4
  export enum CodePushInstallMode {
4
5
  IMMEDIATE = 0,
@@ -38,12 +39,57 @@ export interface CodePushPackage {
38
39
  }
39
40
 
40
41
  /**
41
- * Microsoft CodePush Drop-in Compatibility Shim — Sprint 10 (S10-CLI-1).
42
+ * Microsoft CodePush compatibility shim.
42
43
  *
43
- * Allows developers to migrate from CodePush to Scalebun OTA by simply changing:
44
- * - import codePush from 'react-native-code-push'
45
- * + import codePush from '@scalebun/react-native/compat/codepush'
44
+ * Maps the CodePush surface (`sync`, `checkForUpdate`, the HOC) onto the
45
+ * ScaleBun orchestrator so a migrating app keeps compiling while its call sites
46
+ * are ported.
47
+ *
48
+ * CONFIGURE IT FIRST. This shim used to carry hardcoded defaults —
49
+ * `http://localhost:3001/api/v1` with a dev client key — so a migrated
50
+ * production app silently checked localhost forever and reported UP_TO_DATE on
51
+ * every sync. There is no correct default for someone else's backend, so there
52
+ * is now none: call `configureCodePushShim()` (or `ScaleBun.init({ ota })`,
53
+ * which the facade wires for you) before the first sync, and an unconfigured
54
+ * shim says so loudly instead of pretending to be up to date.
55
+ *
56
+ * import codePush, { configureCodePushShim } from '@scalebun/react-native/compat/codepush';
57
+ * configureCodePushShim({ apiUrl, clientKey, appVersion, installationId });
58
+ */
59
+
60
+ export interface CodePushShimConfig {
61
+ /** Backend base URL, e.g. https://api.yourbackend.com/api/v1 */
62
+ apiUrl: string;
63
+ /** SDK client key for this app/environment. */
64
+ clientKey: string;
65
+ /** Native app version this binary reports (e.g. '1.4.0'). */
66
+ appVersion: string;
67
+ /** Stable per-install id. Defaults to the SDK's canonical installation id. */
68
+ installationId?: string;
69
+ }
70
+
71
+ let shimConfig: CodePushShimConfig | null = null;
72
+
73
+ /** Point the shim at a real backend. Required before sync/checkForUpdate. */
74
+ export function configureCodePushShim(config: CodePushShimConfig): void {
75
+ shimConfig = config;
76
+ }
77
+
78
+ /**
79
+ * Resolve the sync params, or null when unconfigured. Returning null (rather
80
+ * than substituting a localhost default) is what turns a silent no-update into
81
+ * a visible, fixable error.
46
82
  */
83
+ function shimParams(): CodePushShimConfig | null {
84
+ if (!shimConfig) {
85
+ logger.warn(
86
+ '[ScaleBun] CodePush shim is not configured — no update check was performed. ' +
87
+ 'Call configureCodePushShim({ apiUrl, clientKey, appVersion }) during startup.',
88
+ );
89
+ return null;
90
+ }
91
+ return shimConfig;
92
+ }
47
93
  const codePushShim = function <P>(rootComponent: React.ComponentType<P>) {
48
94
  return rootComponent;
49
95
  };
@@ -53,14 +99,21 @@ codePushShim.sync = async (
53
99
  syncStatusCallback?: (status: CodePushSyncStatus) => void,
54
100
  downloadProgressCallback?: (progress: { receivedBytes: number; totalBytes: number }) => void,
55
101
  ): Promise<CodePushSyncStatus> => {
102
+ const params = shimParams();
103
+ if (!params) {
104
+ // Unconfigured is an ERROR, not "up to date": reporting UP_TO_DATE here is
105
+ // exactly how a broken migration stays invisible for months.
106
+ syncStatusCallback?.(CodePushSyncStatus.UNKNOWN_ERROR);
107
+ return CodePushSyncStatus.UNKNOWN_ERROR;
108
+ }
56
109
  try {
57
110
  syncStatusCallback?.(CodePushSyncStatus.CHECKING_FOR_UPDATE);
58
111
  const result = await otaOrchestrator.sync({
59
- apiUrl: 'http://localhost:3001/api/v1',
60
- clientKey: 'scalebun_ck_dev_default_0000000000000000000000',
61
- appVersion: '1.0.0',
62
- installationId: 'codepush_shim',
63
- });
112
+ ...params,
113
+ // Omitted → the orchestrator falls back to the SDK's canonical
114
+ // installation id, which is the right identity anyway.
115
+ ...(params.installationId ? { installationId: params.installationId } : {}),
116
+ } as any);
64
117
 
65
118
  if (result.status === 'UP_TO_DATE') {
66
119
  syncStatusCallback?.(CodePushSyncStatus.UP_TO_DATE);
@@ -81,16 +134,16 @@ codePushShim.sync = async (
81
134
  };
82
135
 
83
136
  codePushShim.checkForUpdate = async (): Promise<CodePushPackage | null> => {
137
+ const params = shimParams();
138
+ if (!params) return null;
84
139
  const check = await otaOrchestrator.checkForUpdate({
85
- apiUrl: 'http://localhost:3001/api/v1',
86
- clientKey: 'scalebun_ck_dev_default_0000000000000000000000',
87
- appVersion: '1.0.0',
88
- installationId: 'codepush_shim',
89
- });
140
+ ...params,
141
+ ...(params.installationId ? { installationId: params.installationId } : {}),
142
+ } as any);
90
143
  if (check.action === 'NONE' || !check.bundle) return null;
91
144
 
92
145
  return {
93
- appVersion: '1.0.0',
146
+ appVersion: params.appVersion,
94
147
  deploymentKey: 'scalebun_ota',
95
148
  description: check.bundle.releaseNote ?? '',
96
149
  failedInstall: false,
@@ -111,8 +164,26 @@ codePushShim.allowRestart = () => {
111
164
  // Scalebun handles restart safety automatically via boot guard
112
165
  };
113
166
 
167
+ /**
168
+ * Metadata for the bundle currently RUNNING. CodePush apps use this to show a
169
+ * version or decide whether a pending update landed; returning a flat null
170
+ * (as this did) makes every such check read as "no OTA bundle installed".
171
+ */
114
172
  codePushShim.getUpdateMetadata = async (): Promise<CodePushPackage | null> => {
115
- return null;
173
+ const current = otaOrchestrator.getCurrentBundle();
174
+ if (!current) return null;
175
+ return {
176
+ appVersion: shimConfig?.appVersion ?? '',
177
+ deploymentKey: 'scalebun_ota',
178
+ description: current.releaseNote ?? '',
179
+ failedInstall: false,
180
+ isFirstRun: false,
181
+ isMandatory: current.isMandatory ?? false,
182
+ isPending: false,
183
+ label: `v${current.version}`,
184
+ packageHash: current.sha256,
185
+ packageSize: current.size,
186
+ };
116
187
  };
117
188
 
118
189
  export default codePushShim;
@@ -6,11 +6,18 @@ export const ConfigSchema = z.object({
6
6
  projectId: z.string().min(1).optional(),
7
7
  publishableKey: z.string().min(1).optional(),
8
8
  /**
9
- * Backend API base URL. Optional defaults to the ScaleBun cloud
10
- * ({@link DEFAULT_API_BASE_URL}). Override only for staging or a
11
- * self-hosted backend. Still validated as a URL when supplied.
12
- */
13
- apiBaseUrl: z.string().url('apiBaseUrl must be a valid URL').default(DEFAULT_API_BASE_URL),
9
+ * Backend API base URL. LOCKED to {@link DEFAULT_API_BASE_URL} the ScaleBun
10
+ * cloud endpoint is fixed and cannot be overridden by integrators. Any value
11
+ * supplied here is accepted (no validation error, for backward compatibility)
12
+ * but ignored: the bootstrapper force-overwrites it with DEFAULT_API_BASE_URL
13
+ * immediately after parse. To point the SDK at a different backend, change the
14
+ * constant at build time.
15
+ */
16
+ // NB: `.default()` on the vendored `z` types its arg as `Partial<T> | (() => T)`, so a bare
17
+ // string arg fails `tsc` (.d.ts generation via bob) with TS2345. The factory form satisfies the
18
+ // `(() => any)` overload. Behaviour is identical — omitted apiBaseUrl resolves to the default,
19
+ // which the bootstrapper overwrites anyway (the endpoint is locked).
20
+ apiBaseUrl: z.any().default(() => DEFAULT_API_BASE_URL),
14
21
  debug: z.boolean().default(false),
15
22
  /**
16
23
  * SDK internal log verbosity. Recoverable transport failures are logged at
@@ -161,6 +168,11 @@ export const ConfigSchema = z.object({
161
168
  * Plumbed into SessionManager via SDKBootstrapper.
162
169
  */
163
170
  replay: z.object({
171
+ // Auto-start frame recording in SaaS mode (clientKey set) at cold launch, so replay
172
+ // sessions carry frames out of the box. Default: true (undefined ⇒ record). Set false for
173
+ // events-only sessions — the host can still record on demand via ScaleBun.startSession().
174
+ // Decoupled from finalizeOnBackground (which only governs per-foreground session boundaries).
175
+ record: z.boolean().optional(),
164
176
  captureMode: z.enum(['navigation', 'interactions', 'frequency']).optional(),
165
177
  intervalMs: z.number().min(500).optional(),
166
178
  quality: z.enum(['high', 'normal', 'low', 'grayscale']).optional(),
@@ -206,6 +218,15 @@ export const ConfigSchema = z.object({
206
218
  channelOverride: z.string().optional(),
207
219
  /** Ed25519 public key for bundle signature verification (base64). */
208
220
  publicSigningKey: z.string().optional(),
221
+ /**
222
+ * Ed25519 verifier supplied by the host — the SDK ships no crypto
223
+ * implementation. Required whenever `publicSigningKey` is set: pinning a
224
+ * key without a verifier is fail-closed, so updates stop installing.
225
+ * `(message, signature, publicKey) => boolean | Promise<boolean>`.
226
+ */
227
+ verifySignature: z.any().optional(),
228
+ /** Override how long a new bundle has to prove healthy (ms). */
229
+ healthyTimeoutMs: z.number().optional(),
209
230
  /** Block UI interaction while a mandatory update is pending. Default: false. */
210
231
  mandatoryBlocksUi: z.boolean().default(false),
211
232
  }).optional(),
@@ -1,9 +1,12 @@
1
1
  /**
2
- * ScaleBun cloud API base URL.
2
+ * ScaleBun cloud API base URL — the single, LOCKED endpoint for the SDK.
3
3
  *
4
- * Used as the default for `apiBaseUrl` when an integrator does not supply one,
5
- * so the common "just give me a project key" integration works without knowing
6
- * the server address. Override `apiBaseUrl` in init config to point at a
7
- * staging environment or a self-hosted backend.
4
+ * `SDKBootstrapper` ignores any `apiBaseUrl` passed in init config and always
5
+ * uses this value, so an integrator cannot repoint the SDK at another backend.
6
+ * Includes the `/api/v1` path segment because every SDK route is built relative
7
+ * to this base (e.g. `${apiBaseUrl}/batch`, `${apiBaseUrl}/ingestion/sessions`,
8
+ * `${apiBaseUrl}/config`). Without `/api/v1` the ingestion routes return 404 and
9
+ * events are silently lost. To target a staging or self-hosted backend, change
10
+ * this value at build time.
8
11
  */
9
- export const DEFAULT_API_BASE_URL = 'https://api.scalebun.com';
12
+ export const DEFAULT_API_BASE_URL = 'https://api.scalebun.com/api/v1';
@@ -0,0 +1,146 @@
1
+ /**
2
+ * The ONE place that answers "what device is this?".
3
+ *
4
+ * It was previously answered three incompatible ways at once:
5
+ *
6
+ * 1. The replay lane asked the native bridge and got the truth.
7
+ * 2. The analytics lane took `ScaleBun.init({ context })` verbatim and shipped whatever the
8
+ * integrator typed. Production carries `device_model: "android"`, `manufacturer: "android"`
9
+ * and `sdk_version: "qa-app-1.0"` because an app hand-wrote them — none of those are a model,
10
+ * a manufacturer, or an SDK version.
11
+ * 3. Seven call sites wrote `Platform.OS === 'ios' ? 'ios' : 'android'`, which reports every
12
+ * non-iOS platform — windows, macos, web — as Android.
13
+ *
14
+ * Detection belongs to the SDK. An integrator cannot be expected to know the device better than the
15
+ * process running on it, and asking them to means the field is wrong whenever they guess.
16
+ *
17
+ * Every value here is measured or absent. Nothing is defaulted to a plausible-looking constant: a
18
+ * fabricated device model is indistinguishable from a real one to whoever reads the dashboard, which
19
+ * is strictly worse than an empty field.
20
+ */
21
+ import { SDK_VERSION } from '../constants/version';
22
+
23
+ /**
24
+ * The real `Platform.OS`, never narrowed.
25
+ *
26
+ * React Native's own union is `'ios' | 'android' | 'windows' | 'macos' | 'web'`, and the protocol's
27
+ * platform field is an open enum precisely so new ones travel without a release. Collapsing that to
28
+ * a two-way ternary is what made every macOS and Windows session read as Android.
29
+ */
30
+ export type PlatformOS = 'ios' | 'android' | 'windows' | 'macos' | 'web' | 'unknown';
31
+
32
+ interface RNPlatform {
33
+ OS?: string;
34
+ Version?: string | number;
35
+ constants?: {
36
+ Manufacturer?: string;
37
+ Model?: string;
38
+ Brand?: string;
39
+ Release?: string;
40
+ osVersion?: string;
41
+ systemName?: string;
42
+ };
43
+ }
44
+
45
+ function rnPlatform(): RNPlatform | null {
46
+ try {
47
+ return (require('react-native') as { Platform?: RNPlatform }).Platform ?? null;
48
+ } catch {
49
+ return null;
50
+ }
51
+ }
52
+
53
+ /**
54
+ * `Platform.OS`, verbatim.
55
+ *
56
+ * `'unknown'` when React Native itself cannot be loaded — the previous `catch { return 'android' }`
57
+ * turned "we could not tell" into a confident, wrong answer.
58
+ */
59
+ export function resolvePlatformOS(): PlatformOS {
60
+ const os = rnPlatform()?.OS;
61
+ switch (os) {
62
+ case 'ios':
63
+ case 'android':
64
+ case 'windows':
65
+ case 'macos':
66
+ case 'web':
67
+ return os;
68
+ default:
69
+ // A platform React Native gained after this build. Pass it through rather than guess —
70
+ // the wire's platform field is an open enum for exactly this case.
71
+ return (os as PlatformOS) ?? 'unknown';
72
+ }
73
+ }
74
+
75
+ /**
76
+ * `'ios' | 'android'` for the few call sites whose payload genuinely only has those two, keeping the
77
+ * narrowing HONEST: `null` where it used to silently say Android.
78
+ */
79
+ export function resolveMobileOS(): 'ios' | 'android' | null {
80
+ const os = resolvePlatformOS();
81
+ return os === 'ios' || os === 'android' ? os : null;
82
+ }
83
+
84
+ /** The keys this module owns. An integrator-supplied value for any of them is overridden. */
85
+ export const DETECTED_CONTEXT_KEYS = [
86
+ 'sdk_version',
87
+ 'platform_os',
88
+ 'os_version',
89
+ 'device_model',
90
+ 'manufacturer',
91
+ ] as const;
92
+
93
+ /**
94
+ * Everything derivable WITHOUT the native bridge, so the very first event already carries it.
95
+ *
96
+ * The bridge is async and may not be installed at all (Expo Go, an older native binary); waiting on
97
+ * it would leave the opening events of every session unattributed. `Platform.constants` is populated
98
+ * synchronously at startup and covers Android fully — iOS exposes no model there, which is why
99
+ * `device_model` stays absent on iOS until {@link nativeDeviceContext} fills it in.
100
+ */
101
+ export function staticDeviceContext(): Record<string, string> {
102
+ const platform = rnPlatform();
103
+ const out: Record<string, string> = {
104
+ sdk_version: SDK_VERSION,
105
+ platform_os: resolvePlatformOS(),
106
+ };
107
+ if (!platform) return out;
108
+
109
+ const c = platform.constants ?? {};
110
+ // Android: Release is the marketing version ("14"); Version is the API level (34).
111
+ // iOS: osVersion is the real thing; Platform.Version mirrors it.
112
+ const osVersion = c.Release ?? c.osVersion ?? (platform.Version != null ? String(platform.Version) : undefined);
113
+ if (osVersion) out.os_version = String(osVersion);
114
+ if (c.Model) out.device_model = String(c.Model);
115
+ if (c.Manufacturer) out.manufacturer = String(c.Manufacturer);
116
+ else if (c.Brand) out.manufacturer = String(c.Brand);
117
+ return out;
118
+ }
119
+
120
+ /** Bridge values, once the native module answers. Absent keys stay absent. */
121
+ export function nativeDeviceContext(info: {
122
+ platform?: string;
123
+ osVersion?: string;
124
+ deviceModel?: string;
125
+ } | null): Record<string, string> {
126
+ const out: Record<string, string> = {};
127
+ if (!info) return out;
128
+ if (info.osVersion) out.os_version = info.osVersion;
129
+ if (info.deviceModel) out.device_model = info.deviceModel;
130
+ return out;
131
+ }
132
+
133
+ /**
134
+ * Merge the integrator's context with what we detected — DETECTED WINS on the keys above.
135
+ *
136
+ * The inversion is deliberate. Config normally wins over defaults, but these are not defaults: they
137
+ * are measurements, and a measurement must not be overridable by a guess. Everything outside
138
+ * {@link DETECTED_CONTEXT_KEYS} — `language`, `timezone`, `network_type`, anything app-specific —
139
+ * is still the integrator's to set, untouched.
140
+ */
141
+ export function mergeDeviceContext(
142
+ configured: Record<string, unknown> | undefined,
143
+ detected: Record<string, string> = staticDeviceContext(),
144
+ ): Record<string, unknown> {
145
+ return { ...(configured ?? {}), ...detected };
146
+ }
@@ -10,6 +10,7 @@ import type { ReplayConfig } from '../features/replay/public/types';
10
10
  import type { DebugTransport } from './transport';
11
11
  import type { SessionManager as SessionManagerType } from '../features/session/SessionManager';
12
12
  import { logger } from '../core/logger/internalLogger';
13
+ import { resolvePlatformOS } from '../core/context/device';
13
14
 
14
15
  // ─── State ──────────────────────────────────────────────────────────────────
15
16
 
@@ -90,7 +91,7 @@ export function wireNativeCaptureDeferred(
90
91
  byteSize: result.sizeBytes || Math.round(result.base64.length * 0.75),
91
92
  format: (result.format ?? 'jpeg') as string,
92
93
  frameHash: (result as any).frameHash as string | undefined,
93
- platform: (Platform.OS === 'ios' ? 'ios' : 'android') as 'android' | 'ios',
94
+ platform: resolvePlatformOS() as 'android' | 'ios',
94
95
  };
95
96
  } catch {
96
97
  return null;
@@ -0,0 +1,128 @@
1
+ /**
2
+ * F-BUG (React Native) — bug-report feature.
3
+ *
4
+ * Ported from the web SDK's `features/bugreport/BugReportFeature.ts`, deliberately keeping its
5
+ * split: this class is THIN. It raises the capture signal and holds a `submitFn` injected by the
6
+ * facade, which owns session + transport and therefore owns the HTTP. Duplicating the submit here
7
+ * would give the two SDKs two contracts to drift apart.
8
+ *
9
+ * WHAT DOES NOT PORT
10
+ * The web feature lazy-imports a DOM launcher widget (`widget.ts`, 185 lines of document/CSS). RN
11
+ * has no DOM: the form is rendered by `EngagePromptView`, which already exists, already handles the
12
+ * native modal/keyboard behaviour, and is already what `shake_device` puts on screen. Building a
13
+ * second prompt system for this would be the same mistake in a different repo.
14
+ *
15
+ * TRIGGERING
16
+ * `shake_device` is already wired end to end — native CoreMotion/SensorManager →
17
+ * `engageShakeBridge` → `gestureTriggerDetector.reportShake()` → the `$gesture` signal lane → the
18
+ * engage trigger engine. This feature is a submit target for that flow, not a second detector.
19
+ */
20
+ import { logger } from '../../core/logger/internalLogger';
21
+ import type { EngageAttachmentDraft } from '../engage/engageTypes';
22
+
23
+ /** Why a submit did not produce a report id. Mirrors the web SDK's discriminated failure union. */
24
+ export type BugFailure = 'init' | 'consent' | 'send' | 'empty';
25
+
26
+ /** Resolves to the report id, or to `{ e }` naming WHICH branch failed. */
27
+ export type BugSubmitResult = string | { e: BugFailure };
28
+
29
+ export interface BugReportInput {
30
+ /** The issue text. Required — a report with no description is not a report. */
31
+ message: string;
32
+ title?: string;
33
+ /**
34
+ * Reporter's email. Optional on purpose: the session usually already resolves to an end user, so
35
+ * the form should PREFILL this rather than demand it from someone who just shook their phone in
36
+ * frustration. `BugReport.reporterEmail` is nullable server-side.
37
+ */
38
+ email?: string;
39
+ /**
40
+ * An approved voice note, produced by `features/engage/captureVoiceAttachment`.
41
+ *
42
+ * That module returns a transcript as a PREFILL and only marks `transcriptApproved` once the user
43
+ * has explicitly accepted the wording. This feature must never submit an unapproved draft — a
44
+ * machine transcription of someone's voice, sent without them confirming it, is a different
45
+ * product decision than the one that pipeline was built for.
46
+ */
47
+ voice?: EngageAttachmentDraft;
48
+ }
49
+
50
+ type SubmitFn = (input: BugReportInput) => Promise<BugSubmitResult>;
51
+
52
+ export class BugReportFeature {
53
+ readonly name = 'bugReport';
54
+
55
+ private active = false;
56
+ private submitFn: SubmitFn | null = null;
57
+ /** Raises the 'report' capture signal so replay flushes its buffer into the current session. */
58
+ private signalFn: (() => void) | null = null;
59
+
60
+ get isActive(): boolean {
61
+ return this.active;
62
+ }
63
+
64
+ setup(hooks: { signal?: () => void } = {}): void {
65
+ this.signalFn = hooks.signal ?? null;
66
+ this.active = true;
67
+ }
68
+
69
+ /**
70
+ * Flush replay into the session so the report has its visual context.
71
+ *
72
+ * Fail-soft: replay may be disabled, paused, or unsupported on this build. A report without a
73
+ * replay anchor is still a useful report, so this must never block a submit.
74
+ */
75
+ signalReport(): void {
76
+ try {
77
+ this.signalFn?.();
78
+ } catch {
79
+ /* never let a capture-signal failure break a bug report */
80
+ }
81
+ }
82
+
83
+ /** Wired by the facade after init — the full submit path (transport + session + context). */
84
+ setSubmit(fn: SubmitFn): void {
85
+ this.submitFn = fn;
86
+ }
87
+
88
+ /**
89
+ * Submit a report.
90
+ *
91
+ * Returns rather than throws, always. This is called from a UI the host app renders, often from a
92
+ * user gesture; an exception escaping into a React Native render tree is a redbox for the
93
+ * customer's user, over a bug report they were trying to send us.
94
+ */
95
+ async submit(input: BugReportInput): Promise<BugSubmitResult> {
96
+ if (!input || !input.message || input.message.trim().length === 0) {
97
+ logger.warn('[scalebun] reportBug: a message is required');
98
+ return { e: 'empty' };
99
+ }
100
+ // No submitFn means the facade never finished wiring. That is 'init', not a send failure —
101
+ // the distinction is what tells an integrator to check their setup rather than their network.
102
+ if (!this.submitFn) return { e: 'init' };
103
+
104
+ if (input.voice && !input.voice.transcriptApproved) {
105
+ // Refused rather than silently stripped: dropping the attachment would send a report the user
106
+ // believes contains their voice note, and they would never learn it did not.
107
+ logger.warn('[scalebun] reportBug: voice note has no approved transcript — not sending');
108
+ return { e: 'empty' };
109
+ }
110
+
111
+ try {
112
+ // Order matters and mirrors the web path: signal BEFORE submit so the replay bytes are already
113
+ // in flight, and so the report's replay coordinate refers to a frame that exists. A flush
114
+ // cannot retroactively manufacture a frame, so a missing anchor must stay missing rather than
115
+ // become a misleading "start of session" link in the dashboard.
116
+ this.signalReport();
117
+ return await this.submitFn(input);
118
+ } catch {
119
+ return { e: 'send' };
120
+ }
121
+ }
122
+
123
+ teardown(): void {
124
+ this.submitFn = null;
125
+ this.signalFn = null;
126
+ this.active = false;
127
+ }
128
+ }