pulse-updates 1.0.11 → 1.0.12
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 +21 -0
- package/README.md +57 -8
- package/android/src/main/java/app/pulse/updates/PulseController.kt +234 -37
- package/android/src/main/java/app/pulse/updates/database/PulseDatabase.kt +14 -0
- package/android/src/main/java/app/pulse/updates/errorrecovery/PulseErrorRecovery.kt +43 -0
- package/android/src/main/java/app/pulse/updates/launcher/PulseAppLauncher.kt +11 -4
- package/ios/PulseUpdates/AppLauncher/PulseAppLauncher.swift +15 -8
- package/ios/PulseUpdates/PulseController.swift +152 -4
- package/ios/PulseUpdates/PulseTypes.swift +21 -1
- package/lib/typescript/types.d.ts +10 -0
- package/lib/typescript/types.d.ts.map +1 -1
- package/package.json +10 -8
- package/scripts/publish.mjs +176 -5
- package/src/types.ts +10 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Pulse Updates
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
|
-
|
|
1
|
+
<p align="center"><img src="logo.png" width="88" alt="Pulse Updates" /></p>
|
|
2
|
+
<h1 align="center">Pulse Updates — React Native SDK</h1>
|
|
3
|
+
<p align="center">
|
|
4
|
+
<b>Lightweight over-the-air (OTA) updates for React Native.</b><br/>
|
|
5
|
+
Ship JavaScript & assets instantly — a simpler, self-hostable alternative to expo-updates,
|
|
6
|
+
with full New Architecture (Fabric/Bridgeless) support and Ed25519 signature verification.
|
|
7
|
+
</p>
|
|
2
8
|
|
|
3
|
-
|
|
9
|
+
---
|
|
4
10
|
|
|
5
11
|
## Features
|
|
6
12
|
|
|
@@ -45,8 +51,17 @@ Add these keys to your `Info.plist`:
|
|
|
45
51
|
<string>$(MARKETING_VERSION)</string>
|
|
46
52
|
<key>PulseUpdatesCheckOnLaunch</key>
|
|
47
53
|
<string>ALWAYS</string>
|
|
54
|
+
<!-- Code signing (REQUIRED in release builds) -->
|
|
55
|
+
<key>PulseUpdatesRequireSignature</key>
|
|
56
|
+
<true/>
|
|
57
|
+
<key>PulseUpdatesSigningKeyId</key>
|
|
58
|
+
<string>your-key-id</string>
|
|
59
|
+
<key>PulseUpdatesSigningPublicKey</key>
|
|
60
|
+
<string>base64-ed25519-public-key</string>
|
|
48
61
|
```
|
|
49
62
|
|
|
63
|
+
> **Security:** In release builds `PulseUpdatesRequireSignature` defaults to `true`. When it is on, both `PulseUpdatesSigningKeyId` and `PulseUpdatesSigningPublicKey` are **required** — if the public key is missing or a manifest's Ed25519 signature does not verify, the update is rejected (fail-closed) and the embedded bundle is used. See [SECURITY.md](./SECURITY.md).
|
|
64
|
+
|
|
50
65
|
### Android Setup
|
|
51
66
|
|
|
52
67
|
Add to your `android/app/build.gradle`:
|
|
@@ -64,8 +79,14 @@ Add metadata to `AndroidManifest.xml` inside `<application>`:
|
|
|
64
79
|
<meta-data android:name="PulseUpdatesURL" android:value="https://your-update-server.com" />
|
|
65
80
|
<meta-data android:name="PulseUpdatesRuntimeVersion" android:value="@string/app_version" />
|
|
66
81
|
<meta-data android:name="PulseUpdatesCheckOnLaunch" android:value="ALWAYS" />
|
|
82
|
+
<!-- Code signing (REQUIRED in release builds) -->
|
|
83
|
+
<meta-data android:name="PulseUpdatesRequireSignature" android:value="true" />
|
|
84
|
+
<meta-data android:name="PulseUpdatesSigningKeyId" android:value="your-key-id" />
|
|
85
|
+
<meta-data android:name="PulseUpdatesSigningPublicKey" android:value="base64-ed25519-public-key" />
|
|
67
86
|
```
|
|
68
87
|
|
|
88
|
+
> **Security:** In release builds `PulseUpdatesRequireSignature` defaults to `true`. When it is on, both `PulseUpdatesSigningKeyId` and `PulseUpdatesSigningPublicKey` are **required** — if the public key is missing or a manifest's Ed25519 signature does not verify, the update is rejected (fail-closed) and the embedded bundle is used. See [SECURITY.md](./SECURITY.md).
|
|
89
|
+
|
|
69
90
|
#### New Architecture (Bridgeless) Setup
|
|
70
91
|
|
|
71
92
|
For React Native 0.76+ with New Architecture enabled, update your `MainApplication.kt`:
|
|
@@ -119,6 +140,9 @@ class MainApplication : Application(), ReactApplication {
|
|
|
119
140
|
| `PulseUpdatesCheckOnLaunch` | string | `ALWAYS` | When to check: `ALWAYS`, `WIFI_ONLY`, `NEVER` |
|
|
120
141
|
| `PulseUpdatesChannel` | string | `production` | Update channel |
|
|
121
142
|
| `PulseUpdatesLaunchWaitMs` | number | `0` | Wait time for update check on launch |
|
|
143
|
+
| `PulseUpdatesRequireSignature` | boolean | `true` in release, `false` in debug | Reject manifests without a valid Ed25519 signature (fail-closed). When `true`, the signing keys below are **required** |
|
|
144
|
+
| `PulseUpdatesSigningKeyId` | string | required in release | Key id that must match the manifest's `signature.keyId` |
|
|
145
|
+
| `PulseUpdatesSigningPublicKey` | string | required in release | Base64 Ed25519 public key used to verify the manifest signature |
|
|
122
146
|
|
|
123
147
|
## JavaScript API
|
|
124
148
|
|
|
@@ -263,13 +287,38 @@ npx pulse-updates publish \
|
|
|
263
287
|
| Option | Description |
|
|
264
288
|
|--------|-------------|
|
|
265
289
|
| `--platform` | Target platform: `ios` or `android` (required) |
|
|
266
|
-
| `--build` | Build number for this update (
|
|
290
|
+
| `--build` | Build number for this update (optional metadata) |
|
|
291
|
+
| `--dry-run` | Build + validate locally without creating a release on the server |
|
|
267
292
|
| `--channel` | Update channel (default: `production`) |
|
|
268
293
|
| `--api-key` | Server API key (or set in `pulse.config.json`) |
|
|
269
294
|
| `--api-url` | Server URL (auto-detected from native config) |
|
|
270
295
|
| `--runtime-version` | Runtime version (auto-detected from native config) |
|
|
271
296
|
| `--message` | Release notes |
|
|
272
297
|
| `--skip-bundle` | Skip bundle creation (use existing) |
|
|
298
|
+
| `--strict` | Abort the publish if new native modules are detected (default: warn only) |
|
|
299
|
+
|
|
300
|
+
### Generating Signing Keys
|
|
301
|
+
|
|
302
|
+
Manifest signing uses Ed25519. Generate a matched keypair and paste each half into the right place:
|
|
303
|
+
|
|
304
|
+
```bash
|
|
305
|
+
npx pulse-updates keygen [--key-id <id>]
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
It prints the **private** seed for the server (`Pulse:SigningKey` / `SIGNING_KEY`) and the **public**
|
|
309
|
+
key for the app (`PulseUpdatesSigningPublicKey` in Info.plist / AndroidManifest). The server's
|
|
310
|
+
per-app Setup panel and `GET /api/signing` expose the public key too.
|
|
311
|
+
|
|
312
|
+
### Registering Native Capabilities
|
|
313
|
+
|
|
314
|
+
For capability-based crash-prediction, record what native modules the shipped binary actually provides:
|
|
315
|
+
|
|
316
|
+
```bash
|
|
317
|
+
npx pulse-updates register-capabilities --platform <ios|android> [--api-key <key>]
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
Run it at build time (after a native build) so the server can warn when a future JS-only update
|
|
321
|
+
references native modules the installed binary doesn't have.
|
|
273
322
|
|
|
274
323
|
### Configuration File
|
|
275
324
|
|
|
@@ -287,11 +336,11 @@ Create `pulse.config.json` in your project root:
|
|
|
287
336
|
|
|
288
337
|
Pulse Updates requires a compatible server. The server must implement:
|
|
289
338
|
|
|
290
|
-
- `POST /api/releases`
|
|
291
|
-
- `POST /api/assets/check`
|
|
292
|
-
- `POST /api/assets
|
|
293
|
-
- `POST /api/releases/:id/finalize`
|
|
294
|
-
- `GET /
|
|
339
|
+
- `POST /api/releases` — create a release (CLI, `X-API-Key`)
|
|
340
|
+
- `POST /api/releases/:id/assets/check` — check which assets already exist (dedup)
|
|
341
|
+
- `POST /api/releases/:id/assets` — upload an asset
|
|
342
|
+
- `POST /api/releases/:id/finalize` — finalize the release
|
|
343
|
+
- `GET /pulse/manifest/:appSlug` — public signed manifest the on-device SDK fetches
|
|
295
344
|
|
|
296
345
|
## Embedded Manifest
|
|
297
346
|
|
|
@@ -99,6 +99,32 @@ class PulseController private constructor() {
|
|
|
99
99
|
private var embeddedManifest: EmbeddedManifest? = null
|
|
100
100
|
private val executor = Executors.newSingleThreadExecutor()
|
|
101
101
|
|
|
102
|
+
// Stable, anonymous per-install device id (persisted in the pulse dir). Lazily generated.
|
|
103
|
+
// Used to (a) bucket staged rollouts server-side via the Pulse-Device-Id header and
|
|
104
|
+
// (b) attribute device telemetry events. Failures to read/persist are logged, never silent.
|
|
105
|
+
private var _deviceId: String? = null
|
|
106
|
+
val deviceId: String
|
|
107
|
+
get() {
|
|
108
|
+
_deviceId?.let { return it }
|
|
109
|
+
val dir = directory
|
|
110
|
+
val resolved = if (dir != null) {
|
|
111
|
+
val f = File(dir, "device_id")
|
|
112
|
+
var existing: String? = null
|
|
113
|
+
if (f.exists()) {
|
|
114
|
+
try { existing = f.readText().trim().ifEmpty { null } }
|
|
115
|
+
catch (e: Exception) { pulseLogWarn(TAG, "deviceId: read failed (${e.message}); regenerating") }
|
|
116
|
+
}
|
|
117
|
+
existing ?: java.util.UUID.randomUUID().toString().also { id ->
|
|
118
|
+
try { f.writeText(id) }
|
|
119
|
+
catch (e: Exception) { pulseLogWarn(TAG, "deviceId: persist failed (${e.message}); a new id will be used next launch") }
|
|
120
|
+
}
|
|
121
|
+
} else {
|
|
122
|
+
java.util.UUID.randomUUID().toString()
|
|
123
|
+
}
|
|
124
|
+
_deviceId = resolved
|
|
125
|
+
return resolved
|
|
126
|
+
}
|
|
127
|
+
|
|
102
128
|
// Cached manifest from last check (to avoid duplicate requests in fetch)
|
|
103
129
|
// Only valid for the immediate check->fetch sequence, cleared on use or new check
|
|
104
130
|
private var lastCheckManifest: ManifestModel? = null
|
|
@@ -168,7 +194,8 @@ class PulseController private constructor() {
|
|
|
168
194
|
launchWaitMs = metadata.getInt("PulseUpdatesLaunchWaitMs", 0),
|
|
169
195
|
channel = metadata.getString("PulseUpdatesChannel"),
|
|
170
196
|
signingKeyId = metadata.getString("PulseUpdatesSigningKeyId"),
|
|
171
|
-
signingPublicKey = metadata.getString("PulseUpdatesSigningPublicKey")
|
|
197
|
+
signingPublicKey = metadata.getString("PulseUpdatesSigningPublicKey"),
|
|
198
|
+
requireSignature = metadata.getBoolean("PulseUpdatesRequireSignature", true)
|
|
172
199
|
)
|
|
173
200
|
} catch (e: Exception) {
|
|
174
201
|
pulseLogWarn(TAG, "Failed to load native config: ${e.message}")
|
|
@@ -220,6 +247,20 @@ class PulseController private constructor() {
|
|
|
220
247
|
|
|
221
248
|
// Synchronously select the best update for launch
|
|
222
249
|
selectBestUpdateSync()
|
|
250
|
+
|
|
251
|
+
// Arm crash-rollback on the cold-start path too (parity with start() and iOS). The documented
|
|
252
|
+
// getBundleFile integration goes through here, and initializeWithoutStarting() sets
|
|
253
|
+
// isStarted=true, so a later start() early-returns and would never install recovery — it must
|
|
254
|
+
// be done here or a bad OTA could never auto-roll-back on the documented Android path.
|
|
255
|
+
if (errorRecovery == null) {
|
|
256
|
+
errorRecovery = PulseErrorRecovery().also {
|
|
257
|
+
it.initialize(errorRecoveryDelegate)
|
|
258
|
+
it.startMonitoring()
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
// Mark the launched OTA update as "launch in progress" so the health/recovery bookkeeping
|
|
262
|
+
// (consecutive failures, success/failure counts) is correct on this path.
|
|
263
|
+
launchedUpdate?.takeIf { !isEmbeddedLaunch }?.let { database?.recordLaunchStart(it.updateId) }
|
|
223
264
|
}
|
|
224
265
|
|
|
225
266
|
// Synchronously select the best update (used for initial launch before JS starts)
|
|
@@ -520,6 +561,21 @@ class PulseController private constructor() {
|
|
|
520
561
|
return null
|
|
521
562
|
}
|
|
522
563
|
|
|
564
|
+
// True when the host app is built debuggable. This is the runtime equivalent of iOS #if DEBUG:
|
|
565
|
+
// this module is an android-library, so its own BuildConfig.DEBUG reflects the library build
|
|
566
|
+
// type, not the host app. FLAG_DEBUGGABLE on the host context is authoritative.
|
|
567
|
+
private fun isHostDebuggable(): Boolean {
|
|
568
|
+
val ctx = context ?: return false
|
|
569
|
+
return (ctx.applicationInfo.flags and android.content.pm.ApplicationInfo.FLAG_DEBUGGABLE) != 0
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// Refuse remote updates when a release build requires signing but no public key is configured.
|
|
573
|
+
// Debug builds are allowed to run unsigned remote updates for local iteration.
|
|
574
|
+
private fun shouldRefuseRemoteUpdates(cfg: PulseUpdatesConfig): Boolean {
|
|
575
|
+
if (isHostDebuggable()) return false
|
|
576
|
+
return cfg.requireSignature && cfg.signingPublicKey.isNullOrEmpty()
|
|
577
|
+
}
|
|
578
|
+
|
|
523
579
|
fun checkForUpdate(callback: (Result<CheckResult>) -> Unit) {
|
|
524
580
|
val cfg = config
|
|
525
581
|
if (cfg == null) {
|
|
@@ -532,6 +588,15 @@ class PulseController private constructor() {
|
|
|
532
588
|
return
|
|
533
589
|
}
|
|
534
590
|
|
|
591
|
+
// Fail-closed: in non-debug builds, refuse remote updates when signing is not
|
|
592
|
+
// configured. Falls back to the embedded bundle (no remote install).
|
|
593
|
+
if (shouldRefuseRemoteUpdates(cfg)) {
|
|
594
|
+
pulseLogError(TAG, "checkForUpdate: signing required but not configured in a release build; " +
|
|
595
|
+
"refusing remote updates and using embedded bundle")
|
|
596
|
+
callback(Result.failure(PulseUpdatesException("Signing required but not configured")))
|
|
597
|
+
return
|
|
598
|
+
}
|
|
599
|
+
|
|
535
600
|
executor.execute {
|
|
536
601
|
// Clear any previous cached manifest before new check
|
|
537
602
|
lastCheckManifest = null
|
|
@@ -552,6 +617,7 @@ class PulseController private constructor() {
|
|
|
552
617
|
}
|
|
553
618
|
|
|
554
619
|
lastCheckManifest = checkResult.manifest
|
|
620
|
+
reportEvent("check", checkResult.manifest.updateId)
|
|
555
621
|
pulseLog(TAG, "checkForUpdate: cached manifest for fetch (ota=$otaCommitTime > embedded=$embeddedCommitTime)")
|
|
556
622
|
}
|
|
557
623
|
},
|
|
@@ -577,6 +643,15 @@ class PulseController private constructor() {
|
|
|
577
643
|
return
|
|
578
644
|
}
|
|
579
645
|
|
|
646
|
+
// Fail-closed: never download/install a remote update in a release build that
|
|
647
|
+
// lacks signing configuration (covers the cachedManifest fast-path that skips check).
|
|
648
|
+
if (shouldRefuseRemoteUpdates(cfg)) {
|
|
649
|
+
pulseLogError(TAG, "fetchUpdate: signing required but not configured in a release build; " +
|
|
650
|
+
"refusing remote updates and using embedded bundle")
|
|
651
|
+
callback(Result.failure(PulseUpdatesException("Signing required but not configured")))
|
|
652
|
+
return
|
|
653
|
+
}
|
|
654
|
+
|
|
580
655
|
// Use cached manifest from recent check if available
|
|
581
656
|
var manifestToUse = cachedManifest
|
|
582
657
|
if (manifestToUse == null && lastCheckManifest != null) {
|
|
@@ -588,7 +663,12 @@ class PulseController private constructor() {
|
|
|
588
663
|
lastCheckManifest = null
|
|
589
664
|
|
|
590
665
|
executor.execute {
|
|
591
|
-
PulseRemoteLoader.fetchUpdate(cfg, database, dir, manifestToUse
|
|
666
|
+
PulseRemoteLoader.fetchUpdate(cfg, database, dir, manifestToUse) { result ->
|
|
667
|
+
// A freshly downloaded update marks this device as "served" on the server, which is
|
|
668
|
+
// what gates its launch outcomes into the crash-rate auto-rollback.
|
|
669
|
+
result.onSuccess { fr -> if (fr.isNew) reportEvent("download", fr.manifest?.updateId) }
|
|
670
|
+
callback(result)
|
|
671
|
+
}
|
|
592
672
|
}
|
|
593
673
|
}
|
|
594
674
|
|
|
@@ -607,6 +687,7 @@ class PulseController private constructor() {
|
|
|
607
687
|
} catch (e: Exception) {
|
|
608
688
|
pulseLogWarn(TAG, "Failed to record successful launch: ${e.message}")
|
|
609
689
|
}
|
|
690
|
+
if (!isEmbeddedLaunch) reportEvent("launch_success", update.updateId)
|
|
610
691
|
}
|
|
611
692
|
|
|
612
693
|
// Run reaper after successful launch
|
|
@@ -620,7 +701,52 @@ class PulseController private constructor() {
|
|
|
620
701
|
} catch (e: Exception) {
|
|
621
702
|
pulseLogWarn(TAG, "Failed to record launch failure: ${e.message}")
|
|
622
703
|
}
|
|
704
|
+
if (!isEmbeddedLaunch) reportEvent("launch_failure", update.updateId)
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
/**
|
|
709
|
+
* Fire-and-forget device telemetry to the server's /pulse/events endpoint, on a daemon thread so
|
|
710
|
+
* it never blocks update work. Best-effort: a failure is logged, never silently swallowed, and
|
|
711
|
+
* never affects the app. The server marks a device "served" on the "download" event — only served
|
|
712
|
+
* devices count toward crash-rate auto-rollback — and uses launch_success/launch_failure to drive
|
|
713
|
+
* that rate. appSlug + base are derived from the manifest URL (.../pulse/manifest/{appSlug}).
|
|
714
|
+
*/
|
|
715
|
+
private fun reportEvent(type: String, updateId: String?) {
|
|
716
|
+
if (updateId.isNullOrEmpty()) return
|
|
717
|
+
val cfg = config ?: return
|
|
718
|
+
val marker = "/pulse/manifest/"
|
|
719
|
+
val idx = cfg.updateUrl.indexOf(marker)
|
|
720
|
+
if (idx < 0) {
|
|
721
|
+
pulseLogWarn(TAG, "reportEvent($type): updateUrl '${cfg.updateUrl}' has no $marker — cannot derive events endpoint, skipping")
|
|
722
|
+
return
|
|
623
723
|
}
|
|
724
|
+
val base = cfg.updateUrl.substring(0, idx)
|
|
725
|
+
val appSlug = cfg.updateUrl.substring(idx + marker.length).substringBefore('/').substringBefore('?')
|
|
726
|
+
val eventsUrl = "$base/pulse/events"
|
|
727
|
+
val device = deviceId
|
|
728
|
+
Thread({
|
|
729
|
+
try {
|
|
730
|
+
val conn = URL(eventsUrl).openConnection() as java.net.HttpURLConnection
|
|
731
|
+
conn.requestMethod = "POST"
|
|
732
|
+
conn.setRequestProperty("Content-Type", "application/json")
|
|
733
|
+
conn.connectTimeout = 5000
|
|
734
|
+
conn.readTimeout = 5000
|
|
735
|
+
conn.doOutput = true
|
|
736
|
+
val body = JSONObject()
|
|
737
|
+
.put("appSlug", appSlug)
|
|
738
|
+
.put("updateId", updateId)
|
|
739
|
+
.put("deviceId", device)
|
|
740
|
+
.put("type", type)
|
|
741
|
+
.toString()
|
|
742
|
+
conn.outputStream.use { it.write(body.toByteArray(Charsets.UTF_8)) }
|
|
743
|
+
val code = conn.responseCode
|
|
744
|
+
if (code !in 200..299) pulseLogWarn(TAG, "reportEvent($type) -> HTTP $code")
|
|
745
|
+
conn.disconnect()
|
|
746
|
+
} catch (e: Exception) {
|
|
747
|
+
pulseLogWarn(TAG, "reportEvent($type) failed: ${e.message}")
|
|
748
|
+
}
|
|
749
|
+
}, "pulse-telemetry").apply { isDaemon = true }.start()
|
|
624
750
|
}
|
|
625
751
|
|
|
626
752
|
// MARK: - Private Methods
|
|
@@ -831,6 +957,13 @@ class PulseController private constructor() {
|
|
|
831
957
|
launchedUpdate?.updateId?.let { updateId ->
|
|
832
958
|
try {
|
|
833
959
|
database?.recordFailedLaunch(updateId)
|
|
960
|
+
// Exclude this update from future launch selection: launchableUpdates() only
|
|
961
|
+
// returns status IN ('ready','embedded'), so flipping a crashing OTA update to
|
|
962
|
+
// FAILED makes the launcher (and selectBestUpdateSync) skip it and fall back to
|
|
963
|
+
// the previous good/embedded update. Never demote the embedded update itself.
|
|
964
|
+
if (launchedUpdate?.isEmbedded != true) {
|
|
965
|
+
database?.setStatus(updateId, PulseUpdateStatus.FAILED)
|
|
966
|
+
}
|
|
834
967
|
} catch (e: Exception) {
|
|
835
968
|
pulseLogWarn(TAG, "Failed to record failed launch: ${e.message}")
|
|
836
969
|
}
|
|
@@ -871,7 +1004,10 @@ data class PulseUpdatesConfig(
|
|
|
871
1004
|
val launchWaitMs: Int = 0,
|
|
872
1005
|
val channel: String? = null,
|
|
873
1006
|
val signingKeyId: String? = null,
|
|
874
|
-
val signingPublicKey: String? = null
|
|
1007
|
+
val signingPublicKey: String? = null,
|
|
1008
|
+
// Fail-closed by default: require a valid Ed25519 signature on remote manifests.
|
|
1009
|
+
// Only meaningful for non-debug builds; debug builds always allow unsigned updates.
|
|
1010
|
+
val requireSignature: Boolean = true
|
|
875
1011
|
) {
|
|
876
1012
|
val scopeKey: String
|
|
877
1013
|
get() = try {
|
|
@@ -1107,6 +1243,9 @@ object PulseRemoteLoader {
|
|
|
1107
1243
|
connection.setRequestProperty("Pulse-Protocol-Version", "2")
|
|
1108
1244
|
connection.setRequestProperty("X-Pulse-Platform", "android")
|
|
1109
1245
|
connection.setRequestProperty("X-Pulse-Runtime-Version", config.runtimeVersion)
|
|
1246
|
+
// Stable device id so the server can bucket this device into staged rollouts (without it,
|
|
1247
|
+
// a device is held back from any partial rollout until it reaches 100%).
|
|
1248
|
+
connection.setRequestProperty("Pulse-Device-Id", PulseController.getInstance().deviceId)
|
|
1110
1249
|
|
|
1111
1250
|
pulseLog(TAG, "checkForUpdate: url=${config.updateUrl}")
|
|
1112
1251
|
pulseLog(TAG, "checkForUpdate: runtimeVersion=${config.runtimeVersion}")
|
|
@@ -1158,8 +1297,15 @@ object PulseRemoteLoader {
|
|
|
1158
1297
|
return
|
|
1159
1298
|
}
|
|
1160
1299
|
|
|
1161
|
-
// Verify signature
|
|
1162
|
-
|
|
1300
|
+
// Verify signature. When requireSignature is set we MUST have a configured key and
|
|
1301
|
+
// a valid signature (verifyManifestSignature returns false on a missing signature),
|
|
1302
|
+
// so an unsigned/misconfigured release fails closed instead of installing.
|
|
1303
|
+
if (config.requireSignature) {
|
|
1304
|
+
if (config.signingPublicKey.isNullOrEmpty()) {
|
|
1305
|
+
pulseLogError(TAG, "checkForUpdate: requireSignature is set but no signingPublicKey configured; refusing update")
|
|
1306
|
+
callback(Result.failure(PulseUpdatesException("Signature required but no public key configured")))
|
|
1307
|
+
return
|
|
1308
|
+
}
|
|
1163
1309
|
pulseLog(TAG, "checkForUpdate: verifying signature...")
|
|
1164
1310
|
if (!verifyManifestSignature(manifest, response, config)) {
|
|
1165
1311
|
pulseLogWarn(TAG, "checkForUpdate: signature verification failed")
|
|
@@ -1167,6 +1313,17 @@ object PulseRemoteLoader {
|
|
|
1167
1313
|
return
|
|
1168
1314
|
}
|
|
1169
1315
|
pulseLog(TAG, "checkForUpdate: signature valid")
|
|
1316
|
+
} else if (config.signingPublicKey != null && manifest.signature != null) {
|
|
1317
|
+
// Optional mode (e.g. DEBUG): verify only WHEN a signature is present.
|
|
1318
|
+
// verifyManifestSignature returns false on a null signature, so verifying
|
|
1319
|
+
// unconditionally here would wrongly reject legitimately-unsigned DEBUG updates.
|
|
1320
|
+
pulseLog(TAG, "checkForUpdate: verifying signature (optional)...")
|
|
1321
|
+
if (!verifyManifestSignature(manifest, response, config)) {
|
|
1322
|
+
pulseLogWarn(TAG, "checkForUpdate: signature verification failed")
|
|
1323
|
+
callback(Result.failure(PulseUpdatesException("Signature verification failed")))
|
|
1324
|
+
return
|
|
1325
|
+
}
|
|
1326
|
+
pulseLog(TAG, "checkForUpdate: signature valid")
|
|
1170
1327
|
}
|
|
1171
1328
|
|
|
1172
1329
|
// Check if same update
|
|
@@ -1289,19 +1446,26 @@ object PulseRemoteLoader {
|
|
|
1289
1446
|
|
|
1290
1447
|
// Download bundle (store in assets directory, same as other assets)
|
|
1291
1448
|
val bundleDest = File(directory, "assets/sha256/$bundleHash")
|
|
1292
|
-
if
|
|
1449
|
+
// Never trust a pre-existing content-addressed file blindly: re-hash it, and if it does not
|
|
1450
|
+
// match (corruption / partial prior write) delete it and re-download. Only skip the download
|
|
1451
|
+
// when the existing file verifies against the manifest hash.
|
|
1452
|
+
var needBundleDownload = true
|
|
1453
|
+
if (bundleDest.exists()) {
|
|
1454
|
+
val existingHash = try { sha256Hex(bundleDest) } catch (e: Exception) { null }
|
|
1455
|
+
if (existingHash == bundleHash) {
|
|
1456
|
+
needBundleDownload = false
|
|
1457
|
+
} else {
|
|
1458
|
+
pulseLogWarn(TAG, "Pre-existing bundle failed re-hash (got=${existingHash?.take(16)} want=${bundleHash.take(16)}), re-downloading")
|
|
1459
|
+
bundleDest.delete()
|
|
1460
|
+
}
|
|
1461
|
+
}
|
|
1462
|
+
if (needBundleDownload) {
|
|
1293
1463
|
try {
|
|
1294
|
-
downloadFile
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
val actualHash = sha256Hex(bundleDest)
|
|
1298
|
-
if (actualHash != bundleHash) {
|
|
1299
|
-
bundleDest.delete()
|
|
1300
|
-
database?.setStatus(updateId, PulseUpdateStatus.FAILED)
|
|
1301
|
-
callback(Result.failure(PulseUpdatesException("Bundle hash mismatch")))
|
|
1302
|
-
return
|
|
1303
|
-
}
|
|
1464
|
+
// downloadFile now verifies the SHA-256 on the staging temp BEFORE the atomic move,
|
|
1465
|
+
// so a hash mismatch never produces a file at bundleDest.
|
|
1466
|
+
downloadFile(manifest.bundle.url, bundleDest, stagingDir, bundleHash)
|
|
1304
1467
|
} catch (e: Exception) {
|
|
1468
|
+
bundleDest.delete()
|
|
1305
1469
|
database?.setStatus(updateId, PulseUpdateStatus.FAILED)
|
|
1306
1470
|
stagingDir.deleteRecursively()
|
|
1307
1471
|
callback(Result.failure(e))
|
|
@@ -1335,29 +1499,40 @@ object PulseRemoteLoader {
|
|
|
1335
1499
|
val assetHash = asset.hash.lowercase()
|
|
1336
1500
|
val assetDest = File(directory, "assets/sha256/$assetHash")
|
|
1337
1501
|
|
|
1338
|
-
// Skip download if
|
|
1502
|
+
// Skip download only if a pre-existing cached file actually re-hashes correctly;
|
|
1503
|
+
// otherwise treat it as missing and (re)download. Never reuse a file without re-hash.
|
|
1339
1504
|
if (assetDest.exists()) {
|
|
1340
|
-
|
|
1505
|
+
val existingHash = try { sha256Hex(assetDest) } catch (e: Exception) { null }
|
|
1506
|
+
if (existingHash != assetHash) {
|
|
1507
|
+
pulseLogWarn(TAG, "Pre-existing asset failed re-hash for ${asset.key}, re-downloading")
|
|
1508
|
+
assetDest.delete()
|
|
1509
|
+
try {
|
|
1510
|
+
downloadFile(asset.url, assetDest, stagingDir, assetHash)
|
|
1511
|
+
} catch (e: Exception) {
|
|
1512
|
+
assetDest.delete()
|
|
1513
|
+
database?.setStatus(updateId, PulseUpdateStatus.FAILED)
|
|
1514
|
+
stagingDir.deleteRecursively()
|
|
1515
|
+
callback(Result.failure(PulseUpdatesException("Asset download failed for ${asset.key}: ${e.message}")))
|
|
1516
|
+
return
|
|
1517
|
+
}
|
|
1518
|
+
}
|
|
1341
1519
|
}
|
|
1342
|
-
//
|
|
1520
|
+
// Use embedded fallback at runtime - nothing to download, just link below
|
|
1343
1521
|
else if (embeddedHashes.contains(assetHash)) {
|
|
1344
1522
|
pulseLog(TAG, "Asset ${assetHash.take(16)}... exists in embedded, skipping download")
|
|
1345
|
-
// Don't download - will use embedded fallback at runtime
|
|
1346
1523
|
}
|
|
1347
|
-
//
|
|
1524
|
+
// Not present anywhere: download, verifying hash on the staging temp before the move.
|
|
1525
|
+
// ALL-OR-NOTHING: any failure aborts the whole update instead of leaving it READY with a
|
|
1526
|
+
// missing/corrupt asset (matches iOS downloadAssets which fails the batch on first error).
|
|
1348
1527
|
else {
|
|
1349
1528
|
try {
|
|
1350
|
-
downloadFile(asset.url, assetDest, stagingDir)
|
|
1351
|
-
|
|
1352
|
-
val actualHash = sha256Hex(assetDest)
|
|
1353
|
-
if (actualHash != assetHash) {
|
|
1354
|
-
assetDest.delete()
|
|
1355
|
-
pulseLogWarn(TAG, "Asset hash mismatch for ${asset.key}")
|
|
1356
|
-
continue
|
|
1357
|
-
}
|
|
1529
|
+
downloadFile(asset.url, assetDest, stagingDir, assetHash)
|
|
1358
1530
|
} catch (e: Exception) {
|
|
1359
|
-
|
|
1360
|
-
|
|
1531
|
+
assetDest.delete()
|
|
1532
|
+
database?.setStatus(updateId, PulseUpdateStatus.FAILED)
|
|
1533
|
+
stagingDir.deleteRecursively()
|
|
1534
|
+
callback(Result.failure(PulseUpdatesException("Asset download failed for ${asset.key}: ${e.message}")))
|
|
1535
|
+
return
|
|
1361
1536
|
}
|
|
1362
1537
|
}
|
|
1363
1538
|
|
|
@@ -1389,17 +1564,39 @@ object PulseRemoteLoader {
|
|
|
1389
1564
|
callback(Result.success(Unit))
|
|
1390
1565
|
}
|
|
1391
1566
|
|
|
1392
|
-
private fun downloadFile(url: String, destination: File, stagingDir: File) {
|
|
1567
|
+
private fun downloadFile(url: String, destination: File, stagingDir: File, expectedHash: String? = null) {
|
|
1393
1568
|
destination.parentFile?.mkdirs()
|
|
1394
1569
|
val tempFile = File(stagingDir, "temp_${System.currentTimeMillis()}")
|
|
1395
1570
|
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1571
|
+
try {
|
|
1572
|
+
URL(url).openStream().use { input ->
|
|
1573
|
+
tempFile.outputStream().use { output ->
|
|
1574
|
+
input.copyTo(output)
|
|
1575
|
+
}
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1578
|
+
// Verify SHA-256 on the STAGING TEMP before it ever lands at the content-addressed
|
|
1579
|
+
// destination. A mismatch must never produce a file at `destination`.
|
|
1580
|
+
if (expectedHash != null) {
|
|
1581
|
+
val actualHash = sha256Hex(tempFile)
|
|
1582
|
+
if (actualHash != expectedHash) {
|
|
1583
|
+
throw PulseUpdatesException("Hash mismatch: expected ${expectedHash.take(16)}, got ${actualHash.take(16)}")
|
|
1584
|
+
}
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1587
|
+
// Atomic move into place. rename can fail across boundaries or if the target exists;
|
|
1588
|
+
// delete any stale target first and treat a failed rename as a hard error so callers
|
|
1589
|
+
// can abort the all-or-nothing apply.
|
|
1590
|
+
destination.delete()
|
|
1591
|
+
if (!tempFile.renameTo(destination)) {
|
|
1592
|
+
throw PulseUpdatesException("Failed to move verified file into place: ${destination.name}")
|
|
1593
|
+
}
|
|
1594
|
+
} finally {
|
|
1595
|
+
// Never leave a partial/temp file behind, regardless of success or failure.
|
|
1596
|
+
if (tempFile.exists()) {
|
|
1597
|
+
tempFile.delete()
|
|
1399
1598
|
}
|
|
1400
1599
|
}
|
|
1401
|
-
|
|
1402
|
-
tempFile.renameTo(destination)
|
|
1403
1600
|
}
|
|
1404
1601
|
|
|
1405
1602
|
private fun sha256Hex(file: File): String {
|
|
@@ -238,6 +238,13 @@ class PulseDatabase(context: Context, directory: File) : SQLiteOpenHelper(
|
|
|
238
238
|
|
|
239
239
|
fun recordSuccessfulLaunch(updateId: String) {
|
|
240
240
|
val now = System.currentTimeMillis()
|
|
241
|
+
// Upsert: on Android recordLaunchStart() is never called in the launch path, so the health
|
|
242
|
+
// row may not exist yet. A bare UPDATE would be a silent no-op and successful_launch_count
|
|
243
|
+
// would stay 0, which the error-recovery pipeline reads to decide whether to roll back.
|
|
244
|
+
writableDatabase.execSQL(
|
|
245
|
+
"INSERT OR IGNORE INTO health (update_id) VALUES (?)",
|
|
246
|
+
arrayOf(updateId)
|
|
247
|
+
)
|
|
241
248
|
writableDatabase.execSQL("""
|
|
242
249
|
UPDATE health SET
|
|
243
250
|
app_ready_at = ?,
|
|
@@ -249,6 +256,13 @@ class PulseDatabase(context: Context, directory: File) : SQLiteOpenHelper(
|
|
|
249
256
|
}
|
|
250
257
|
|
|
251
258
|
fun recordFailedLaunch(updateId: String) {
|
|
259
|
+
// Upsert (see recordSuccessfulLaunch): the health row may not exist because recordLaunchStart
|
|
260
|
+
// is not wired into the Android launch path. Without the INSERT this UPDATE is a silent
|
|
261
|
+
// no-op and failed_launch_count never increments, defeating crash-rollback bookkeeping.
|
|
262
|
+
writableDatabase.execSQL(
|
|
263
|
+
"INSERT OR IGNORE INTO health (update_id) VALUES (?)",
|
|
264
|
+
arrayOf(updateId)
|
|
265
|
+
)
|
|
252
266
|
writableDatabase.execSQL("""
|
|
253
267
|
UPDATE health SET
|
|
254
268
|
failed_launch_count = failed_launch_count + 1,
|
|
@@ -29,6 +29,11 @@ class PulseErrorRecovery {
|
|
|
29
29
|
private var delegate: PulseErrorRecoveryDelegate? = null
|
|
30
30
|
private var shouldHandleException = false
|
|
31
31
|
|
|
32
|
+
// Previous JVM uncaught-exception handler, so we can chain/restore it (Android analog of
|
|
33
|
+
// iOS RCTGet/SetFatalHandler). Captured when we install ours in startMonitoring().
|
|
34
|
+
private var previousUncaughtHandler: Thread.UncaughtExceptionHandler? = null
|
|
35
|
+
private var pulseUncaughtHandler: Thread.UncaughtExceptionHandler? = null
|
|
36
|
+
|
|
32
37
|
fun initialize(delegate: PulseErrorRecoveryDelegate) {
|
|
33
38
|
this.delegate = delegate
|
|
34
39
|
if (!::handler.isInitialized) {
|
|
@@ -40,6 +45,43 @@ class PulseErrorRecovery {
|
|
|
40
45
|
fun startMonitoring() {
|
|
41
46
|
registerContentAppearedListener()
|
|
42
47
|
shouldHandleException = true
|
|
48
|
+
installUncaughtExceptionHandler()
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Install a JVM-level uncaught exception handler that routes crashes into the error recovery
|
|
53
|
+
* pipeline before delegating to the previously installed handler. This is the Android analog of
|
|
54
|
+
* iOS setRCTErrorHandlers(): without it, PulseErrorRecoveryHandler has no entry point and bad
|
|
55
|
+
* updates can brick the app.
|
|
56
|
+
*/
|
|
57
|
+
private fun installUncaughtExceptionHandler() {
|
|
58
|
+
if (pulseUncaughtHandler != null) return
|
|
59
|
+
|
|
60
|
+
previousUncaughtHandler = Thread.getDefaultUncaughtExceptionHandler()
|
|
61
|
+
val handler = Thread.UncaughtExceptionHandler { thread, throwable ->
|
|
62
|
+
try {
|
|
63
|
+
if (shouldHandleException) {
|
|
64
|
+
handleException(throwable as? Exception ?: RuntimeException(throwable))
|
|
65
|
+
}
|
|
66
|
+
} catch (e: Exception) {
|
|
67
|
+
pulseLogError(TAG, "ErrorRecovery: failed to handle uncaught exception: ${e.message}")
|
|
68
|
+
} finally {
|
|
69
|
+
previousUncaughtHandler?.uncaughtException(thread, throwable)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
pulseUncaughtHandler = handler
|
|
73
|
+
Thread.setDefaultUncaughtExceptionHandler(handler)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
private fun uninstallUncaughtExceptionHandler() {
|
|
77
|
+
// Only restore if our handler is still the active one (avoid clobbering a handler
|
|
78
|
+
// installed after us).
|
|
79
|
+
if (pulseUncaughtHandler != null &&
|
|
80
|
+
Thread.getDefaultUncaughtExceptionHandler() === pulseUncaughtHandler) {
|
|
81
|
+
Thread.setDefaultUncaughtExceptionHandler(previousUncaughtHandler)
|
|
82
|
+
}
|
|
83
|
+
pulseUncaughtHandler = null
|
|
84
|
+
previousUncaughtHandler = null
|
|
43
85
|
}
|
|
44
86
|
|
|
45
87
|
/**
|
|
@@ -65,6 +107,7 @@ class PulseErrorRecovery {
|
|
|
65
107
|
// wait 10s before unsetting error handlers
|
|
66
108
|
handler.postDelayed({
|
|
67
109
|
shouldHandleException = false
|
|
110
|
+
uninstallUncaughtExceptionHandler()
|
|
68
111
|
handler.postDelayed({ handlerThread.quitSafely() }, 10000)
|
|
69
112
|
}, 10000)
|
|
70
113
|
}
|
|
@@ -519,15 +519,22 @@ class PulseDefaultSelectionPolicy : PulseSelectionPolicy {
|
|
|
519
519
|
* Priority: 1) Ready updates (newest by commitTime), 2) Embedded updates (fallback)
|
|
520
520
|
*/
|
|
521
521
|
override fun selectUpdateToLaunch(updates: List<PulseUpdate>): PulseUpdate? {
|
|
522
|
-
//
|
|
523
|
-
|
|
522
|
+
// Exclude updates that failed to launch and were never confirmed good
|
|
523
|
+
// (failedLaunchCount > 0 && successfulLaunchCount == 0) so a bad update can't be
|
|
524
|
+
// re-selected on every launch — a crash loop that can effectively brick the app.
|
|
525
|
+
// Mirrors iOS PulseDefaultSelectionPolicy.selectUpdateToLaunch.
|
|
526
|
+
val healthy = updates.filter { !(it.failedLaunchCount > 0 && it.successfulLaunchCount == 0) }
|
|
527
|
+
|
|
528
|
+
// First, try to find the newest ready (downloaded) healthy update
|
|
529
|
+
val readyUpdates = healthy.filter { it.status == PulseUpdateStatus.READY }
|
|
524
530
|
val newestReady = readyUpdates.maxByOrNull { it.commitTime.time }
|
|
525
531
|
if (newestReady != null) {
|
|
526
532
|
return newestReady
|
|
527
533
|
}
|
|
528
534
|
|
|
529
|
-
// Fall back to embedded update
|
|
530
|
-
return
|
|
535
|
+
// Fall back to embedded update (embedded is always launchable).
|
|
536
|
+
return healthy.find { it.status == PulseUpdateStatus.EMBEDDED }
|
|
537
|
+
?: updates.find { it.status == PulseUpdateStatus.EMBEDDED }
|
|
531
538
|
}
|
|
532
539
|
|
|
533
540
|
override fun selectUpdatesToDelete(launchedUpdate: PulseUpdateInfo, allUpdates: List<PulseUpdateInfo>): List<PulseUpdateInfo> {
|