@scalebun/react-native 1.4.0 → 1.5.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 (48) hide show
  1. package/android/src/main/java/com/scalebun/rn/ota/BsPatch.kt +182 -0
  2. package/android/src/main/java/com/scalebun/rn/ota/ScaleBunOtaModule.kt +88 -13
  3. package/android/src/oldarch/java/com/scalebun/rn/crash/ScaleBunCrashSpec.kt +16 -0
  4. package/bin/scalebun.js +55 -3
  5. package/dist/scalebun.full.js +129 -97
  6. package/dist/scalebun.full.js.map +1 -1
  7. package/dist/scalebun.slim.js +129 -97
  8. package/dist/scalebun.slim.js.map +1 -1
  9. package/ios/Ota/BsPatch.swift +180 -0
  10. package/ios/Ota/OtaSlotManager.swift +59 -2
  11. package/ios/Ota/ScaleBunOtaModule.swift +89 -10
  12. package/lib/commonjs/analytics/EventTracker.js +21 -1
  13. package/lib/commonjs/analytics/EventTracker.js.map +1 -1
  14. package/lib/commonjs/core/constants/version.js +1 -1
  15. package/lib/commonjs/core/id/installationId.js +55 -0
  16. package/lib/commonjs/core/id/installationId.js.map +1 -0
  17. package/lib/commonjs/debug/bootstrap.js +12 -0
  18. package/lib/commonjs/debug/bootstrap.js.map +1 -1
  19. package/lib/commonjs/features/ota/OtaOrchestrator.js +2 -30
  20. package/lib/commonjs/features/ota/OtaOrchestrator.js.map +1 -1
  21. package/lib/commonjs/features/session/BackendSessionAdapter.js +8 -0
  22. package/lib/commonjs/features/session/BackendSessionAdapter.js.map +1 -1
  23. package/lib/module/analytics/EventTracker.js +21 -1
  24. package/lib/module/analytics/EventTracker.js.map +1 -1
  25. package/lib/module/core/constants/version.js +1 -1
  26. package/lib/module/core/id/installationId.js +50 -0
  27. package/lib/module/core/id/installationId.js.map +1 -0
  28. package/lib/module/debug/bootstrap.js +12 -0
  29. package/lib/module/debug/bootstrap.js.map +1 -1
  30. package/lib/module/features/ota/OtaOrchestrator.js +1 -29
  31. package/lib/module/features/ota/OtaOrchestrator.js.map +1 -1
  32. package/lib/module/features/session/BackendSessionAdapter.js +8 -0
  33. package/lib/module/features/session/BackendSessionAdapter.js.map +1 -1
  34. package/lib/typescript/analytics/EventTracker.d.ts.map +1 -1
  35. package/lib/typescript/core/constants/version.d.ts +1 -1
  36. package/lib/typescript/core/id/installationId.d.ts +20 -0
  37. package/lib/typescript/core/id/installationId.d.ts.map +1 -0
  38. package/lib/typescript/debug/bootstrap.d.ts +18 -0
  39. package/lib/typescript/debug/bootstrap.d.ts.map +1 -1
  40. package/lib/typescript/features/ota/OtaOrchestrator.d.ts.map +1 -1
  41. package/lib/typescript/features/session/BackendSessionAdapter.d.ts.map +1 -1
  42. package/package.json +3 -2
  43. package/src/analytics/EventTracker.ts +21 -1
  44. package/src/core/constants/version.ts +1 -1
  45. package/src/core/id/installationId.ts +52 -0
  46. package/src/debug/bootstrap.ts +36 -0
  47. package/src/features/ota/OtaOrchestrator.ts +1 -30
  48. package/src/features/session/BackendSessionAdapter.ts +8 -0
@@ -0,0 +1,182 @@
1
+ package com.scalebun.rn.ota
2
+
3
+ import java.io.ByteArrayOutputStream
4
+ import java.util.zip.Inflater
5
+
6
+ /**
7
+ * bspatch — reconstructs a bundle from a previous bundle plus a delta.
8
+ *
9
+ * WHAT WAS WRONG. `stagePatch` resolved `false` unconditionally on both
10
+ * platforms, so the whole delta pipeline was decorative: the server generated
11
+ * patches, the check response advertised them, the orchestrator asked for
12
+ * them, and the device always fell back to a full download. Nobody ever
13
+ * downloaded less than the entire bundle.
14
+ *
15
+ * FORMAT (produced by the backend's `bsdiff.ts`; all integers little-endian):
16
+ *
17
+ * 0 8 magic "SBDIFF01"
18
+ * 8 4 target size — the exact number of bytes this must produce
19
+ * 12 4 deflated control-block length
20
+ * 16 4 inflated control-block length
21
+ * 20 4 deflated diff-block length
22
+ * 24 4 inflated diff-block length
23
+ * 28 4 deflated extra-block length
24
+ * 32 4 inflated extra-block length
25
+ * 36 . deflate(control) ++ deflate(diff) ++ deflate(extra)
26
+ *
27
+ * Control is a run of (addLen, extraLen, seek) int32 triples. `addLen` bytes
28
+ * are read from the diff block and added, bytewise mod 256, to the source at
29
+ * the current read cursor; `extraLen` bytes are copied verbatim from the extra
30
+ * block; then the source cursor moves by the signed `seek`.
31
+ *
32
+ * WHY RAW DEFLATE. Android has `java.util.zip` and iOS has the Compression
33
+ * framework; neither ships zstd. On real Hermes bundles zstd-19 beat deflate-9
34
+ * by 0.3 percentage points (95.79% vs 95.51%) — not worth a native dependency
35
+ * on two platforms forever. See the backend module for the measurements.
36
+ *
37
+ * EVERY FIELD HERE IS UNTRUSTED. The patch arrives over the network, so a
38
+ * malformed or hostile one must fail cleanly rather than index out of bounds.
39
+ * Each run is bounds-checked against all four buffers before it is used, and
40
+ * the caller still verifies the SHA-256 of the result before anything is
41
+ * promoted — this routine producing the wrong bytes can never install them.
42
+ */
43
+ internal object BsPatch {
44
+
45
+ private const val MAGIC = "SBDIFF01"
46
+ private const val HEADER_BYTES = 36
47
+
48
+ /** Refuse absurd allocations from a corrupt header (bundles are single-digit MB). */
49
+ private const val MAX_TARGET_BYTES = 256 * 1024 * 1024
50
+
51
+ class PatchFormatException(message: String) : Exception(message)
52
+
53
+ private fun u32(b: ByteArray, off: Int): Long =
54
+ (b[off].toLong() and 0xff) or
55
+ ((b[off + 1].toLong() and 0xff) shl 8) or
56
+ ((b[off + 2].toLong() and 0xff) shl 16) or
57
+ ((b[off + 3].toLong() and 0xff) shl 24)
58
+
59
+ private fun i32(b: ByteArray, off: Int): Int =
60
+ (b[off].toInt() and 0xff) or
61
+ ((b[off + 1].toInt() and 0xff) shl 8) or
62
+ ((b[off + 2].toInt() and 0xff) shl 16) or
63
+ ((b[off + 3].toInt() and 0xff) shl 24)
64
+
65
+ /** Raw DEFLATE (no zlib header) — matches Node's `deflateRawSync`. */
66
+ private fun inflateRaw(data: ByteArray, offset: Int, length: Int): ByteArray {
67
+ val inflater = Inflater(true)
68
+ try {
69
+ inflater.setInput(data, offset, length)
70
+ val out = ByteArrayOutputStream(maxOf(length * 4, 1024))
71
+ val buf = ByteArray(64 * 1024)
72
+ while (true) {
73
+ val n = inflater.inflate(buf)
74
+ if (n > 0) {
75
+ out.write(buf, 0, n)
76
+ continue
77
+ }
78
+ // ORDER MATTERS. A block that legitimately decodes to zero bytes
79
+ // — an empty extra block, which happens whenever a patch is one
80
+ // unbroken run — also reports needsInput() once the stream is
81
+ // consumed. Testing needsInput() first therefore rejected valid
82
+ // patches as truncated; `finished()` has to be asked first.
83
+ if (inflater.finished()) break
84
+ if (inflater.needsInput() || inflater.needsDictionary()) {
85
+ throw PatchFormatException("compressed block is truncated")
86
+ }
87
+ }
88
+ return out.toByteArray()
89
+ } finally {
90
+ inflater.end()
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Apply [patch] to [source], returning the reconstructed bundle.
96
+ *
97
+ * @throws PatchFormatException if the patch is malformed, truncated, or
98
+ * inconsistent with [source]. Callers treat this as "fall back to a full
99
+ * download", never as a fatal error.
100
+ */
101
+ fun apply(source: ByteArray, patch: ByteArray): ByteArray {
102
+ if (patch.size < HEADER_BYTES) throw PatchFormatException("patch too short")
103
+ val magic = String(patch, 0, 8, Charsets.ISO_8859_1)
104
+ if (magic != MAGIC) throw PatchFormatException("bad patch magic '$magic'")
105
+
106
+ val targetSizeL = u32(patch, 8)
107
+ val cLen = u32(patch, 12)
108
+ val cRaw = u32(patch, 16)
109
+ val dLen = u32(patch, 20)
110
+ val dRaw = u32(patch, 24)
111
+ val eLen = u32(patch, 28)
112
+ val eRaw = u32(patch, 32)
113
+
114
+ if (targetSizeL > MAX_TARGET_BYTES) {
115
+ throw PatchFormatException("declared target size $targetSizeL is implausible")
116
+ }
117
+ if (cRaw > MAX_TARGET_BYTES || dRaw > MAX_TARGET_BYTES || eRaw > MAX_TARGET_BYTES) {
118
+ throw PatchFormatException("declared block size is implausible")
119
+ }
120
+ if (HEADER_BYTES + cLen + dLen + eLen != patch.size.toLong()) {
121
+ throw PatchFormatException("block lengths do not match patch size")
122
+ }
123
+ val targetSize = targetSizeL.toInt()
124
+
125
+ var off = HEADER_BYTES
126
+ val control = inflateRaw(patch, off, cLen.toInt()); off += cLen.toInt()
127
+ val diff = inflateRaw(patch, off, dLen.toInt()); off += dLen.toInt()
128
+ val extra = inflateRaw(patch, off, eLen.toInt())
129
+
130
+ // A block that inflates to a size other than the declared one is
131
+ // corrupt; iOS sizes its buffers from these numbers, so Android holds
132
+ // the format to the same contract rather than trusting the stream.
133
+ if (control.size.toLong() != cRaw) throw PatchFormatException("control block size mismatch")
134
+ if (diff.size.toLong() != dRaw) throw PatchFormatException("diff block size mismatch")
135
+ if (extra.size.toLong() != eRaw) throw PatchFormatException("extra block size mismatch")
136
+
137
+ if (control.size % 12 != 0) {
138
+ throw PatchFormatException("control block is not a whole number of triples")
139
+ }
140
+
141
+ val out = ByteArray(targetSize)
142
+ var newpos = 0
143
+ var oldpos = 0
144
+ var dpos = 0
145
+ var epos = 0
146
+
147
+ var c = 0
148
+ while (c < control.size) {
149
+ val addLen = i32(control, c)
150
+ val extraLen = i32(control, c + 4)
151
+ val seek = i32(control, c + 8)
152
+ c += 12
153
+
154
+ if (addLen < 0 || extraLen < 0) throw PatchFormatException("negative run length")
155
+ if (newpos + addLen > targetSize) throw PatchFormatException("add run overruns target")
156
+ if (dpos + addLen > diff.size) throw PatchFormatException("add run overruns diff block")
157
+ if (oldpos < 0 || oldpos + addLen > source.size) {
158
+ throw PatchFormatException("add run overruns source")
159
+ }
160
+
161
+ for (i in 0 until addLen) {
162
+ out[newpos + i] = ((diff[dpos + i].toInt() + source[oldpos + i].toInt()) and 0xff).toByte()
163
+ }
164
+ newpos += addLen
165
+ oldpos += addLen
166
+ dpos += addLen
167
+
168
+ if (newpos + extraLen > targetSize) throw PatchFormatException("extra run overruns target")
169
+ if (epos + extraLen > extra.size) throw PatchFormatException("extra run overruns extra block")
170
+ System.arraycopy(extra, epos, out, newpos, extraLen)
171
+ newpos += extraLen
172
+ epos += extraLen
173
+
174
+ oldpos += seek
175
+ }
176
+
177
+ if (newpos != targetSize) {
178
+ throw PatchFormatException("patch produced $newpos bytes, declared $targetSize")
179
+ }
180
+ return out
181
+ }
182
+ }
@@ -191,20 +191,21 @@ class ScaleBunOtaModule(reactContext: ReactApplicationContext) :
191
191
  }
192
192
 
193
193
  /**
194
- * Binary-patch staging (S7-NAT-1) — NOT IMPLEMENTED on Android.
194
+ * Binary-patch staging (S7-NAT-1).
195
195
  *
196
- * [SlotManager] has no patch/bspatch path, so there is nothing to apply a diff
197
- * with. The JS contract in `src/specs/NativeScaleBunOta.ts` defines `false` as
198
- * "patching unavailable fall back to a full-bundle download", which is exactly
199
- * the honest answer here; OtaOrchestrator then downloads the whole bundle and the
200
- * update still lands. Resolving false (rather than rejecting) keeps that fallback
201
- * on its documented path instead of surfacing an error the caller must special-case.
196
+ * Downloads a delta, reconstructs the full bundle from the currently
197
+ * installed one, and hands the result to the ordinary staging path — so a
198
+ * patched bundle passes exactly the same SHA-256 and slot-promotion checks
199
+ * as a fully downloaded one. [expectedSha256] is the hash of the COMPLETE
200
+ * bundle, which is what makes a bad patch unable to install: reconstruct
201
+ * the wrong bytes and staging rejects them.
202
202
  *
203
- * This override must exist regardless: on the New Architecture the codegen base
204
- * (`NativeScaleBunOtaSpec`, generated from the JS spec) declares `stagePatch`
205
- * abstract, so omitting it fails Kotlin compilation for every new-arch consumer.
206
- *
207
- * Replace the body with a real bspatch once SlotManager grows patch support.
203
+ * Resolves `false` never rejects for every "cannot patch" case, because
204
+ * `src/specs/NativeScaleBunOta.ts` defines false as "patching unavailable,
205
+ * fall back to a full download". The update still lands, just larger. The
206
+ * cases are: no OTA bundle installed yet (nothing to patch against — the
207
+ * first install is always full), a patch that does not match its own
208
+ * advertised hash, and a malformed patch.
208
209
  */
209
210
  @ReactMethod
210
211
  override fun stagePatch(
@@ -213,7 +214,81 @@ class ScaleBunOtaModule(reactContext: ReactApplicationContext) :
213
214
  expectedSha256: String,
214
215
  promise: Promise,
215
216
  ) {
216
- promise.resolve(false)
217
+ var downloadedPatch: File? = null
218
+ var rebuilt: File? = null
219
+ try {
220
+ // Patching is relative to the bundle already on disk. With no OTA
221
+ // bundle installed the source would be the APK's built-in asset,
222
+ // which the server did not diff against — so there is nothing valid
223
+ // to apply and a full download is the correct path.
224
+ val currentBundle = slotManager.currentBundlePath()
225
+ if (currentBundle == null || !currentBundle.exists()) {
226
+ android.util.Log.i(NAME, "stagePatch: no current OTA bundle to patch — full download")
227
+ promise.resolve(false)
228
+ return
229
+ }
230
+
231
+ val temp = File(reactApplicationContext.cacheDir, "scalebun_ota_patch_${System.nanoTime()}")
232
+ downloadedPatch = temp
233
+ if (!BundleDownloader.download(patchUrl, temp, ::emitProgress)) {
234
+ promise.resolve(false)
235
+ return
236
+ }
237
+
238
+ val patchBytes = temp.readBytes()
239
+
240
+ // Verify the patch against its own hash before parsing it. This is
241
+ // cheap and keeps obviously-corrupt input away from the parser.
242
+ val actualPatchSha = sha256Hex(patchBytes)
243
+ if (!actualPatchSha.equals(patchSha256, ignoreCase = true)) {
244
+ android.util.Log.w(NAME, "stagePatch: patch hash mismatch — full download")
245
+ promise.resolve(false)
246
+ return
247
+ }
248
+
249
+ val sourceBytes = currentBundle.readBytes()
250
+ val rebuiltBytes = try {
251
+ BsPatch.apply(sourceBytes, patchBytes)
252
+ } catch (e: BsPatch.PatchFormatException) {
253
+ android.util.Log.w(NAME, "stagePatch: ${e.message} — full download")
254
+ promise.resolve(false)
255
+ return
256
+ }
257
+
258
+ val out = File(reactApplicationContext.cacheDir, "scalebun_ota_rebuilt_${System.nanoTime()}")
259
+ rebuilt = out
260
+ out.writeBytes(rebuiltBytes)
261
+
262
+ val meta = JSONObject()
263
+ meta.put("sha256", expectedSha256)
264
+ meta.put("installedAt", System.currentTimeMillis())
265
+ meta.put("patched", true)
266
+
267
+ // SlotManager re-hashes and compares against the full-bundle hash;
268
+ // a mis-applied patch cannot get past this.
269
+ val success = slotManager.stage(out, expectedSha256, meta)
270
+ if (!success) {
271
+ android.util.Log.w(NAME, "stagePatch: rebuilt bundle failed verification — full download")
272
+ }
273
+ promise.resolve(success)
274
+ } catch (e: Exception) {
275
+ // Any unexpected failure is still just "no patch" — the caller has
276
+ // a working full-download path and a rejected promise would turn a
277
+ // recoverable situation into a failed update.
278
+ android.util.Log.w(NAME, "stagePatch: ${e.message} — full download")
279
+ promise.resolve(false)
280
+ } finally {
281
+ downloadedPatch?.delete()
282
+ rebuilt?.delete()
283
+ }
284
+ }
285
+
286
+ private fun sha256Hex(bytes: ByteArray): String {
287
+ val digest = java.security.MessageDigest.getInstance("SHA-256")
288
+ val hash = digest.digest(bytes)
289
+ val sb = StringBuilder(hash.size * 2)
290
+ for (b in hash) sb.append(String.format("%02x", b))
291
+ return sb.toString()
217
292
  }
218
293
 
219
294
  @ReactMethod
@@ -18,4 +18,20 @@ abstract class ScaleBunCrashSpec(reactContext: ReactApplicationContext) :
18
18
  abstract fun install(promise: Promise)
19
19
  abstract fun crashNow(promise: Promise)
20
20
  abstract fun drainPendingCrash(promise: Promise)
21
+
22
+ /**
23
+ * These two were added to the JS spec and to [ScaleBunCrashModule] without
24
+ * being added here, and the omission is invisible on the New Architecture:
25
+ * there the base is GENERATED from the JS spec, so it grew the methods
26
+ * automatically. On the Old Architecture this file IS the base and it has
27
+ * to be edited by hand — so `ScaleBunCrashModule` was overriding two
28
+ * methods that did not exist, and the SDK failed to compile for every
29
+ * old-arch consumer with "'setActiveSessionId' overrides nothing".
30
+ *
31
+ * Any change to `src/specs/NativeScaleBunCrash.ts` has to be mirrored here.
32
+ * That is the cost of the two-base design described above; the alternative
33
+ * is that old-arch apps silently stop building.
34
+ */
35
+ abstract fun setActiveSessionId(sessionId: String?, promise: Promise)
36
+ abstract fun clearPendingCrash(crashId: String?, promise: Promise)
21
37
  }
package/bin/scalebun.js CHANGED
@@ -630,12 +630,64 @@ function main() {
630
630
  console.log(' npx scalebun init ios --check Verify hooks exist (exit 1 if missing)');
631
631
  console.log(' npx scalebun init ios --dry-run Preview changes without writing');
632
632
  console.log('');
633
+ console.log('OTA commands (login, quickstart, ota publish, releases, rollout…)');
634
+ console.log('ship with this package (via the bundled @scalebun/cli) and are');
635
+ console.log('forwarded automatically — no extra install needed.');
636
+ console.log('');
633
637
  break;
634
638
  default:
635
- console.error(`Unknown command: ${cmd}`);
636
- console.error('Run `npx scalebun help` for usage.');
637
- process.exit(2);
639
+ // TWO PACKAGES, ONE COMMAND NAME.
640
+ //
641
+ // The OTA commands (login, ota publish, releases, rollout, quickstart…)
642
+ // live in @scalebun/cli, which declares the SAME `scalebun` bin as this
643
+ // package. Every doc and generated CI pipeline says `npx scalebun ota
644
+ // publish`, so whichever package won the bin race decided whether that
645
+ // worked — and when this one won, a new user's very first command died
646
+ // with "Unknown command: ota" and no way to find out why.
647
+ //
648
+ // Delegate instead of guessing: if @scalebun/cli is installed, run it.
649
+ // If not, say exactly what to install rather than what went wrong.
650
+ delegateToOtaCli(argv);
638
651
  }
639
652
  }
640
653
 
654
+ /**
655
+ * Hand the invocation to @scalebun/cli, preserving argv and exit code.
656
+ * Falls back to an actionable install instruction when it is not present.
657
+ */
658
+ function delegateToOtaCli(argv) {
659
+ let cliEntry = null;
660
+ try {
661
+ cliEntry = require.resolve('@scalebun/cli/lib/index.js', {
662
+ paths: [process.cwd(), __dirname],
663
+ });
664
+ } catch {
665
+ cliEntry = null;
666
+ }
667
+
668
+ if (!cliEntry) {
669
+ const cmd = argv[0];
670
+ console.error('');
671
+ console.error(`"${cmd}" is an OTA command, provided by @scalebun/cli.`);
672
+ console.error('');
673
+ console.error('@scalebun/cli ships as a dependency of @scalebun/react-native, so it');
674
+ console.error('should already be present. It could not be resolved — your install may');
675
+ console.error('be incomplete or the dependency was pruned.');
676
+ console.error('');
677
+ console.error('Reinstall dependencies, then re-run:');
678
+ console.error(' npm install');
679
+ console.error(` npx scalebun ${argv.join(' ')}`);
680
+ console.error('');
681
+ console.error('(Or install it explicitly: npm i -D @scalebun/cli)');
682
+ console.error('');
683
+ process.exit(2);
684
+ }
685
+
686
+ const { spawnSync } = require('child_process');
687
+ const res = spawnSync(process.execPath, [cliEntry, ...argv], {
688
+ stdio: 'inherit',
689
+ });
690
+ process.exit(res.status ?? 1);
691
+ }
692
+
641
693
  main();
@@ -489,7 +489,7 @@ var SDK_VERSION;
489
489
  var init_version = __esm({
490
490
  "lib/module/core/constants/version.js"() {
491
491
  "use strict";
492
- SDK_VERSION = "1.4.0";
492
+ SDK_VERSION = "1.5.0";
493
493
  }
494
494
  });
495
495
 
@@ -10041,6 +10041,10 @@ function enableDebug(config) {
10041
10041
  __DEV__ && logger.debug("Debug already enabled, skipping");
10042
10042
  return;
10043
10043
  }
10044
+ if (!__DEV__ && !config.allowInRelease) {
10045
+ logger.warn("[ScaleBun] Debug connection refused in a release build. The debug transport is unencrypted (ws://) and carries captured traffic and the shared secret in cleartext, so it is development-only by default. If you genuinely need it in a release build, set `allowInRelease: true` \u2014 and do not ship that to users.");
10046
+ return;
10047
+ }
10044
10048
  if (!isDevToolsBundled()) {
10045
10049
  logger.warn("[ScaleBun] debugConnection was configured, but the desktop-debugger tooling is not in this bundle. It is excluded by default (the ScaleBun desktop app is not yet released); opt in with `withScaleBun(config, { features: { devTools: true } })` in metro.config.js. All other SDK behaviour is unaffected.");
10046
10050
  return;
@@ -10550,89 +10554,6 @@ var init_nativeCrashBridge = __esm({
10550
10554
  }
10551
10555
  });
10552
10556
 
10553
- // lib/module/analytics/batching.js
10554
- function chunkEvents(events, size = MAX_BATCH_EVENTS) {
10555
- const cap = Math.max(1, Math.min(size, MAX_BATCH_EVENTS));
10556
- if (events.length <= cap) return events.length ? [events.slice()] : [];
10557
- const out = [];
10558
- for (let i = 0; i < events.length; i += cap) {
10559
- out.push(events.slice(i, i + cap));
10560
- }
10561
- return out;
10562
- }
10563
- function clampBatchSize(configured, fallback) {
10564
- const n = typeof configured === "number" && configured > 0 ? configured : fallback;
10565
- return Math.max(1, Math.min(n, MAX_BATCH_EVENTS));
10566
- }
10567
- function batchIdempotencyKey(eventIds) {
10568
- let h = 2166136261;
10569
- const joined = eventIds.join("");
10570
- for (let i = 0; i < joined.length; i++) {
10571
- h ^= joined.charCodeAt(i);
10572
- h = Math.imul(h, 16777619) >>> 0;
10573
- }
10574
- return `sdk-${eventIds.length}-${h.toString(16).padStart(8, "0")}`;
10575
- }
10576
- var MAX_BATCH_EVENTS;
10577
- var init_batching = __esm({
10578
- "lib/module/analytics/batching.js"() {
10579
- "use strict";
10580
- MAX_BATCH_EVENTS = 500;
10581
- }
10582
- });
10583
-
10584
- // lib/module/analytics/automaticEvents.js
10585
- function compactProperties(properties) {
10586
- const out = {
10587
- capture_source: "automatic"
10588
- };
10589
- for (const [key, value] of Object.entries(properties ?? {})) {
10590
- if (value !== void 0) out[key] = value;
10591
- }
10592
- return out;
10593
- }
10594
- function emitAutomaticEvent(name, properties) {
10595
- const event = {
10596
- name,
10597
- properties: compactProperties(properties)
10598
- };
10599
- if (listeners.size === 0) {
10600
- pending3.push(event);
10601
- if (pending3.length > MAX_PENDING2) pending3.shift();
10602
- return;
10603
- }
10604
- for (const listener of listeners) {
10605
- try {
10606
- listener(event);
10607
- } catch {
10608
- }
10609
- }
10610
- }
10611
- function subscribeAutomaticEvents(listener) {
10612
- listeners.add(listener);
10613
- if (pending3.length > 0) {
10614
- const buffered = pending3.splice(0, pending3.length);
10615
- for (const event of buffered) {
10616
- try {
10617
- listener(event);
10618
- } catch {
10619
- }
10620
- }
10621
- }
10622
- return () => {
10623
- listeners.delete(listener);
10624
- };
10625
- }
10626
- var listeners, pending3, MAX_PENDING2;
10627
- var init_automaticEvents = __esm({
10628
- "lib/module/analytics/automaticEvents.js"() {
10629
- "use strict";
10630
- listeners = /* @__PURE__ */ new Set();
10631
- pending3 = [];
10632
- MAX_PENDING2 = 50;
10633
- }
10634
- });
10635
-
10636
10557
  // lib/module/crypto/hmacSha256.js
10637
10558
  function utf8(str3) {
10638
10559
  const out = [];
@@ -10749,6 +10670,89 @@ var init_hmacSha256 = __esm({
10749
10670
  }
10750
10671
  });
10751
10672
 
10673
+ // lib/module/analytics/batching.js
10674
+ function chunkEvents(events, size = MAX_BATCH_EVENTS) {
10675
+ const cap = Math.max(1, Math.min(size, MAX_BATCH_EVENTS));
10676
+ if (events.length <= cap) return events.length ? [events.slice()] : [];
10677
+ const out = [];
10678
+ for (let i = 0; i < events.length; i += cap) {
10679
+ out.push(events.slice(i, i + cap));
10680
+ }
10681
+ return out;
10682
+ }
10683
+ function clampBatchSize(configured, fallback) {
10684
+ const n = typeof configured === "number" && configured > 0 ? configured : fallback;
10685
+ return Math.max(1, Math.min(n, MAX_BATCH_EVENTS));
10686
+ }
10687
+ function batchIdempotencyKey(eventIds) {
10688
+ let h = 2166136261;
10689
+ const joined = eventIds.join("");
10690
+ for (let i = 0; i < joined.length; i++) {
10691
+ h ^= joined.charCodeAt(i);
10692
+ h = Math.imul(h, 16777619) >>> 0;
10693
+ }
10694
+ return `sdk-${eventIds.length}-${h.toString(16).padStart(8, "0")}`;
10695
+ }
10696
+ var MAX_BATCH_EVENTS;
10697
+ var init_batching = __esm({
10698
+ "lib/module/analytics/batching.js"() {
10699
+ "use strict";
10700
+ MAX_BATCH_EVENTS = 500;
10701
+ }
10702
+ });
10703
+
10704
+ // lib/module/analytics/automaticEvents.js
10705
+ function compactProperties(properties) {
10706
+ const out = {
10707
+ capture_source: "automatic"
10708
+ };
10709
+ for (const [key, value] of Object.entries(properties ?? {})) {
10710
+ if (value !== void 0) out[key] = value;
10711
+ }
10712
+ return out;
10713
+ }
10714
+ function emitAutomaticEvent(name, properties) {
10715
+ const event = {
10716
+ name,
10717
+ properties: compactProperties(properties)
10718
+ };
10719
+ if (listeners.size === 0) {
10720
+ pending3.push(event);
10721
+ if (pending3.length > MAX_PENDING2) pending3.shift();
10722
+ return;
10723
+ }
10724
+ for (const listener of listeners) {
10725
+ try {
10726
+ listener(event);
10727
+ } catch {
10728
+ }
10729
+ }
10730
+ }
10731
+ function subscribeAutomaticEvents(listener) {
10732
+ listeners.add(listener);
10733
+ if (pending3.length > 0) {
10734
+ const buffered = pending3.splice(0, pending3.length);
10735
+ for (const event of buffered) {
10736
+ try {
10737
+ listener(event);
10738
+ } catch {
10739
+ }
10740
+ }
10741
+ }
10742
+ return () => {
10743
+ listeners.delete(listener);
10744
+ };
10745
+ }
10746
+ var listeners, pending3, MAX_PENDING2;
10747
+ var init_automaticEvents = __esm({
10748
+ "lib/module/analytics/automaticEvents.js"() {
10749
+ "use strict";
10750
+ listeners = /* @__PURE__ */ new Set();
10751
+ pending3 = [];
10752
+ MAX_PENDING2 = 50;
10753
+ }
10754
+ });
10755
+
10752
10756
  // lib/module/analytics/EventTracker.js
10753
10757
  function uuid() {
10754
10758
  return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
@@ -11114,6 +11118,38 @@ var init_EventTracker = __esm({
11114
11118
  }
11115
11119
  });
11116
11120
 
11121
+ // lib/module/core/id/installationId.js
11122
+ function resolveInstallationId() {
11123
+ try {
11124
+ const storage3 = createStorageBackend();
11125
+ const existing = storage3.get(K_INSTALLATION_ID);
11126
+ if (existing) return existing;
11127
+ const id = `inst-${randomSegment()}${randomSegment()}`;
11128
+ storage3.set(K_INSTALLATION_ID, id);
11129
+ return id;
11130
+ } catch {
11131
+ return `inst-ephemeral-${randomSegment()}`;
11132
+ }
11133
+ }
11134
+ function randomSegment() {
11135
+ try {
11136
+ const g = globalThis;
11137
+ if (g.crypto?.getRandomValues) {
11138
+ const bytes = g.crypto.getRandomValues(new Uint8Array(6));
11139
+ return Array.from(bytes).map((b) => b.toString(36).padStart(2, "0")).join("").slice(0, 8);
11140
+ }
11141
+ } catch {
11142
+ }
11143
+ return Math.random().toString(36).slice(2, 10).padEnd(8, "0");
11144
+ }
11145
+ var init_installationId = __esm({
11146
+ "lib/module/core/id/installationId.js"() {
11147
+ "use strict";
11148
+ init_StorageBackend();
11149
+ init_EventTracker();
11150
+ }
11151
+ });
11152
+
11117
11153
  // lib/module/features/engage/engageSignals.js
11118
11154
  var EngageSignalBus, engageSignals;
11119
11155
  var init_engageSignals = __esm({
@@ -11573,18 +11609,6 @@ function subscribeNativeProgress(onTick) {
11573
11609
  };
11574
11610
  }
11575
11611
  }
11576
- function resolveInstallationId() {
11577
- try {
11578
- const storage3 = createStorageBackend();
11579
- const existing = storage3.get(K_INSTALLATION_ID);
11580
- if (existing) return existing;
11581
- const id = `inst-${Math.random().toString(36).slice(2, 10)}${Math.random().toString(36).slice(2, 10)}`;
11582
- storage3.set(K_INSTALLATION_ID, id);
11583
- return id;
11584
- } catch {
11585
- return `inst-ephemeral-${Math.random().toString(36).slice(2, 10)}`;
11586
- }
11587
- }
11588
11612
  async function deliverOtaEvents(params) {
11589
11613
  const events = otaEventEmitter.flush();
11590
11614
  if (!events.length) return;
@@ -11634,7 +11658,7 @@ var init_OtaOrchestrator = __esm({
11634
11658
  init_OtaEventEmitter();
11635
11659
  init_geoCountry();
11636
11660
  init_deviceAttributes();
11637
- init_EventTracker();
11661
+ init_installationId();
11638
11662
  init_signature();
11639
11663
  init_environment();
11640
11664
  init_retry();
@@ -15374,6 +15398,7 @@ function decodeManual(base64) {
15374
15398
  }
15375
15399
 
15376
15400
  // lib/module/features/session/BackendSessionAdapter.js
15401
+ init_installationId();
15377
15402
  init_version();
15378
15403
  init_bridgeAdapter();
15379
15404
  init_nativeModule();
@@ -15509,6 +15534,13 @@ var BackendSessionAdapter = class _BackendSessionAdapter {
15509
15534
  const payload = {
15510
15535
  sessionId: session.sessionId,
15511
15536
  deviceId: session.deviceId,
15537
+ // The canonical per-install id — the SAME value OTA events are
15538
+ // reported under, and a DIFFERENT value from deviceId. The
15539
+ // backend stores it on the Device so release health can join a
15540
+ // bundle's installs to that device's sessions. Without it, crash
15541
+ // impact for a release resolves to "no data" for every device
15542
+ // that never registered for push.
15543
+ installationId: resolveInstallationId(),
15512
15544
  startedAt: session.startedAt,
15513
15545
  // Link the recording row to the always-on analytics row so the
15514
15546
  // dashboard can join them. track() events land on the analytics