@scalebun/react-native 2.0.6 → 2.0.7

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.
@@ -473,6 +473,18 @@ class ScaleBunOtaModule(reactContext: ReactApplicationContext) :
473
473
  */
474
474
  @ReactMethod
475
475
  override fun restartApp() {
476
+ // Tell the boot guard this next launch is ours.
477
+ //
478
+ // Hooked HERE rather than at the JS call sites so it cannot be forgotten:
479
+ // every restart the SDK performs goes through this method, and each one
480
+ // used to spend one of the bundle's two boot attempts as though it had
481
+ // crashed. Recorded before the restart is dispatched, because after it
482
+ // there is no "after".
483
+ try {
484
+ slotManager.markIntentionalRestart()
485
+ } catch (e: Throwable) {
486
+ android.util.Log.w(NAME, "restartApp: could not record intent", e)
487
+ }
476
488
  val application = reactApplicationContext.applicationContext
477
489
  com.facebook.react.bridge.UiThreadUtil.runOnUiThread {
478
490
  try {
@@ -49,6 +49,27 @@ class SlotManager(private val filesDir: File) {
49
49
  private val revertRecord: File get() = File(otaRoot, REVERT_RECORD_FILENAME)
50
50
  private val sequenceFile: File get() = File(otaRoot, "highest_sequence")
51
51
 
52
+ /**
53
+ * Record that the next launch is a restart WE asked for.
54
+ *
55
+ * Consumed once by the boot guard so an intentional restart does not spend
56
+ * one of the bundle's two boot attempts. No-op when no boot marker is
57
+ * present, which is the normal steady state.
58
+ */
59
+ fun markIntentionalRestart() {
60
+ try {
61
+ if (!bootMarker.exists()) return
62
+ val json = JSONObject(bootMarker.readText())
63
+ json.put("intentionalRestart", true)
64
+ bootMarker.writeText(json.toString())
65
+ FileOutputStream(bootMarker, true).use { it.fd.sync() }
66
+ } catch (e: Exception) {
67
+ // Best effort: failing to record intent only costs a boot attempt,
68
+ // which is strictly better than failing the restart itself.
69
+ Log.w(TAG, "Could not record intentional restart", e)
70
+ }
71
+ }
72
+
52
73
  /**
53
74
  * Highest CONFIRMED release sequence (anti-downgrade floor, docs §15). Only
54
75
  * updated once a bundle is marked healthy, so a failed/rolled-back higher
@@ -362,6 +383,26 @@ class SlotManager(private val filesDir: File) {
362
383
  // that starts with the marker still present increments it. Reaching
363
384
  // the limit means the bundle has been given MAX_BOOT_ATTEMPTS
364
385
  // chances to call markHealthy() and never did → revert.
386
+ // A DELIBERATE restart is not a failed boot.
387
+ //
388
+ // The counter cannot tell "the bundle crashed on startup" from "the
389
+ // app restarted itself on purpose" — both simply arrive with the
390
+ // marker still present. So an update that installs and then restarts
391
+ // to activate spends one of its two lives immediately, and any second
392
+ // restart inside the healthy window spends the other and reverts a
393
+ // bundle that was never broken. Observed live: six full 19MB
394
+ // downloads and five reverts before one cycle happened to survive.
395
+ //
396
+ // restart() records its intent here first. That boot is expected, so
397
+ // it is not counted; the flag is one-shot, so a crash on the very
398
+ // next launch still counts and a genuinely broken bundle still goes.
399
+ if (markerJson.optBoolean("intentionalRestart", false)) {
400
+ markerJson.remove("intentionalRestart")
401
+ bootMarker.writeText(markerJson.toString())
402
+ FileOutputStream(bootMarker, true).use { it.fd.sync() }
403
+ Log.i(TAG, "Boot guard: expected restart, not counted as a failed boot")
404
+ return
405
+ }
365
406
  val attempts = markerJson.optInt("bootAttempts", 0) + 1
366
407
  if (attempts >= MAX_BOOT_ATTEMPTS) {
367
408
  Log.w(
@@ -605,7 +605,7 @@ var SDK_VERSION;
605
605
  var init_version = __esm({
606
606
  "lib/module/core/constants/version.js"() {
607
607
  "use strict";
608
- SDK_VERSION = "2.0.6";
608
+ SDK_VERSION = "2.0.7";
609
609
  }
610
610
  });
611
611
 
@@ -600,7 +600,7 @@ var SDK_VERSION;
600
600
  var init_version = __esm({
601
601
  "lib/module/core/constants/version.js"() {
602
602
  "use strict";
603
- SDK_VERSION = "2.0.6";
603
+ SDK_VERSION = "2.0.7";
604
604
  }
605
605
  });
606
606
 
@@ -44,6 +44,32 @@ class OtaSlotManager {
44
44
 
45
45
  // MARK: - Public API
46
46
 
47
+ /// Record that the next bridge load is a restart WE asked for.
48
+ ///
49
+ /// Consumed once by the boot guard so an intentional restart does not spend
50
+ /// one of the bundle's two boot attempts. No-op when no boot marker is
51
+ /// present, which is the normal steady state.
52
+ ///
53
+ /// Mirrors SlotManager.markIntentionalRestart() on Android. It matters more
54
+ /// here: checkBootGuard() runs from getBundleURL(), which React Native calls
55
+ /// on every bridge creation — including the reload restartApp() triggers —
56
+ /// so without this the activation restart ALWAYS consumes an attempt.
57
+ func markIntentionalRestart() {
58
+ let fm = FileManager.default
59
+ guard fm.fileExists(atPath: bootMarkerFile.path) else { return }
60
+ do {
61
+ let data = try Data(contentsOf: bootMarkerFile)
62
+ guard var marker = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { return }
63
+ marker["intentionalRestart"] = true
64
+ let out = try JSONSerialization.data(withJSONObject: marker)
65
+ try out.write(to: bootMarkerFile, options: .atomic)
66
+ } catch {
67
+ // Best effort: failing to record intent only costs a boot attempt,
68
+ // which is strictly better than failing the restart itself.
69
+ NSLog("[ScaleBunOta] Could not record intentional restart: \(error)")
70
+ }
71
+ }
72
+
47
73
  /// Returns the URL to the active JS bundle, or nil if no OTA bundle is installed.
48
74
  func currentBundleURL() -> URL? {
49
75
  let bundle = currentSlot.appendingPathComponent(OtaSlotManager.bundleFilename)
@@ -271,6 +297,30 @@ class OtaSlotManager {
271
297
  // still finds it increments it, and reaching the limit means the
272
298
  // bundle has had its chances and never became healthy.
273
299
  var marker = markerJson
300
+
301
+ // The counter cannot tell "the bundle crashed on startup" from
302
+ // "the app restarted itself on purpose" — both simply arrive
303
+ // with the marker still present. An update that installs and
304
+ // then restarts to activate would spend one of its two lives
305
+ // immediately, and any second restart inside the healthy window
306
+ // would spend the other and revert a bundle that was never
307
+ // broken. That is exactly what happened on Android before
308
+ // markIntentionalRestart(): six full downloads, five reverts,
309
+ // one cycle that happened to survive.
310
+ //
311
+ // restartApp() records its intent first. That load is expected,
312
+ // so it is not counted; the flag is one-shot, so a crash on the
313
+ // very next launch still counts and a genuinely broken bundle
314
+ // still goes.
315
+ if marker["intentionalRestart"] as? Bool == true {
316
+ marker.removeValue(forKey: "intentionalRestart")
317
+ if let data = try? JSONSerialization.data(withJSONObject: marker) {
318
+ try? data.write(to: bootMarkerFile, options: .atomic)
319
+ }
320
+ NSLog("[ScaleBunOta] Boot guard: expected restart, not counted as a failed boot")
321
+ return
322
+ }
323
+
274
324
  let attempts = (marker["bootAttempts"] as? Int ?? 0) + 1
275
325
  if attempts >= OtaSlotManager.maxBootAttempts {
276
326
  NSLog("[ScaleBunOta] Bundle failed to become healthy in \(attempts) launch(es) — reverting")
@@ -294,6 +294,18 @@ class ScaleBunOtaModule: NSObject, RCTBridgeModule {
294
294
  */
295
295
  @objc(restartApp)
296
296
  func restartApp() {
297
+ // Tell the boot guard this next bridge load is ours.
298
+ //
299
+ // Hooked HERE rather than at the JS call sites so it cannot be forgotten:
300
+ // every restart the SDK performs goes through this method. Recorded
301
+ // before the reload is dispatched, because after it there is no "after".
302
+ //
303
+ // On iOS this is not merely a nicety. checkBootGuard() runs from
304
+ // getBundleURL(), which React Native calls on every bridge creation —
305
+ // and the reload below creates one. Without this flag the activation
306
+ // restart always spends a boot attempt, leaving a healthy bundle one
307
+ // relaunch away from being reverted as though it had crashed.
308
+ ScaleBunOtaModule.slotManager.markIntentionalRestart()
297
309
  DispatchQueue.main.async {
298
310
  RCTTriggerReloadCommandListeners("ScaleBun OTA update")
299
311
  }
@@ -14,5 +14,5 @@ exports.SDK_VERSION = void 0;
14
14
  * value, so a stale one makes a rollout unobservable — which is the exact problem sending an SDK
15
15
  * version was introduced to solve.
16
16
  */
17
- const SDK_VERSION = exports.SDK_VERSION = '2.0.6';
17
+ const SDK_VERSION = exports.SDK_VERSION = '2.0.7';
18
18
  //# sourceMappingURL=version.js.map
@@ -8,5 +8,5 @@
8
8
  * value, so a stale one makes a rollout unobservable — which is the exact problem sending an SDK
9
9
  * version was introduced to solve.
10
10
  */
11
- export const SDK_VERSION = '2.0.6';
11
+ export const SDK_VERSION = '2.0.7';
12
12
  //# sourceMappingURL=version.js.map
@@ -8,5 +8,5 @@
8
8
  * value, so a stale one makes a rollout unobservable — which is the exact problem sending an SDK
9
9
  * version was introduced to solve.
10
10
  */
11
- export declare const SDK_VERSION = "2.0.6";
11
+ export declare const SDK_VERSION = "2.0.7";
12
12
  //# sourceMappingURL=version.d.ts.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scalebun/react-native",
3
- "version": "2.0.6",
3
+ "version": "2.0.7",
4
4
  "description": "React Native SDK for ScaleBun",
5
5
  "main": "lib/commonjs/index",
6
6
  "module": "lib/module/index",
@@ -111,7 +111,7 @@
111
111
  "@babel/runtime": "^7.25.0",
112
112
  "@jridgewell/sourcemap-codec": "1.5.5",
113
113
  "@jridgewell/trace-mapping": "0.3.31",
114
- "@scalebun/cli": "^2.0.6"
114
+ "@scalebun/cli": "^2.0.7"
115
115
  },
116
116
  "codegenConfig": {
117
117
  "name": "ScaleBunSpec",
@@ -8,4 +8,4 @@
8
8
  * value, so a stale one makes a rollout unobservable — which is the exact problem sending an SDK
9
9
  * version was introduced to solve.
10
10
  */
11
- export const SDK_VERSION = '2.0.6';
11
+ export const SDK_VERSION = '2.0.7';