@sigx/lynx-updates 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +162 -0
  3. package/android/com/sigx/updates/UpdateDownloader.kt +154 -0
  4. package/android/com/sigx/updates/UpdateStore.kt +367 -0
  5. package/android/com/sigx/updates/UpdatesActivityHook.kt +25 -0
  6. package/android/com/sigx/updates/UpdatesBundleResolver.kt +18 -0
  7. package/android/com/sigx/updates/UpdatesEventBus.kt +54 -0
  8. package/android/com/sigx/updates/UpdatesLifecyclePublisher.kt +42 -0
  9. package/android/com/sigx/updates/UpdatesModule.kt +235 -0
  10. package/dist/controller.d.ts +31 -0
  11. package/dist/controller.d.ts.map +1 -0
  12. package/dist/controller.js +344 -0
  13. package/dist/controller.js.map +1 -0
  14. package/dist/events.d.ts +18 -0
  15. package/dist/events.d.ts.map +1 -0
  16. package/dist/events.js +61 -0
  17. package/dist/events.js.map +1 -0
  18. package/dist/index.d.ts +5 -0
  19. package/dist/index.d.ts.map +1 -0
  20. package/dist/index.js +5 -0
  21. package/dist/index.js.map +1 -0
  22. package/dist/native.d.ts +41 -0
  23. package/dist/native.d.ts.map +1 -0
  24. package/dist/native.js +161 -0
  25. package/dist/native.js.map +1 -0
  26. package/dist/provider/static-manifest.d.ts +66 -0
  27. package/dist/provider/static-manifest.d.ts.map +1 -0
  28. package/dist/provider/static-manifest.js +173 -0
  29. package/dist/provider/static-manifest.js.map +1 -0
  30. package/dist/state.d.ts +23 -0
  31. package/dist/state.d.ts.map +1 -0
  32. package/dist/state.js +73 -0
  33. package/dist/state.js.map +1 -0
  34. package/dist/types.d.ts +203 -0
  35. package/dist/types.d.ts.map +1 -0
  36. package/dist/types.js +12 -0
  37. package/dist/types.js.map +1 -0
  38. package/dist/updates.d.ts +45 -0
  39. package/dist/updates.d.ts.map +1 -0
  40. package/dist/updates.js +67 -0
  41. package/dist/updates.js.map +1 -0
  42. package/dist/use-updates.d.ts +16 -0
  43. package/dist/use-updates.d.ts.map +1 -0
  44. package/dist/use-updates.js +29 -0
  45. package/dist/use-updates.js.map +1 -0
  46. package/ios/UpdateDownloader.swift +152 -0
  47. package/ios/UpdateStore.swift +286 -0
  48. package/ios/UpdatesBundleResolver.swift +15 -0
  49. package/ios/UpdatesEventBus.swift +59 -0
  50. package/ios/UpdatesLifecyclePublisher.swift +48 -0
  51. package/ios/UpdatesModule.swift +178 -0
  52. package/package.json +59 -0
  53. package/signalx-module.json +35 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025-2026 Andreas Ekdahl
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 ADDED
@@ -0,0 +1,162 @@
1
+ # @sigx/lynx-updates
2
+
3
+ Over-the-air (OTA) bundle updates for sigx-lynx. Ship JS-only releases to
4
+ installed apps without a store round-trip — with pluggable backends, every
5
+ update mode from fully-automatic to fully-manual, and crash-driven rollback.
6
+
7
+ ```bash
8
+ pnpm add @sigx/lynx-updates
9
+ sigx prebuild # links the native module + bakes the runtime fingerprint
10
+ ```
11
+
12
+ ## Quick start
13
+
14
+ ```tsx
15
+ // src/main.tsx
16
+ import { defineApp } from '@sigx/lynx';
17
+ import { Updates } from '@sigx/lynx-updates';
18
+ import App from './App';
19
+
20
+ Updates.configure({
21
+ provider: { url: 'https://cdn.example.com/myapp/production/manifest.json' },
22
+ mode: 'silent', // download now, apply on next launch
23
+ checkOn: ['launch', 'foreground'],
24
+ });
25
+
26
+ defineApp(<App />).mount(null);
27
+ ```
28
+
29
+ Publish an update:
30
+
31
+ ```bash
32
+ sigx build # produces dist/main.lynx.bundle
33
+ sigx updates:publish # writes updates-dist/production/{manifest.json, updates/<id>/...}
34
+ # upload updates-dist/production/ to any static host — done.
35
+ ```
36
+
37
+ ## Update modes
38
+
39
+ | Mode | Behavior |
40
+ |---|---|
41
+ | `'silent'` (default) | Auto check + download; the update applies on the next cold launch. |
42
+ | `'immediate'` | Auto check + download, then applies immediately via an in-place reload. |
43
+ | `'manual'` | Nothing automatic — drive `checkForUpdate()` / `download()` / `apply()` yourself. |
44
+
45
+ **Mandatory updates** (`mandatory: true` in the manifest, `--mandatory` on
46
+ publish) override every mode: `state.mandatory` becomes true (block the UI —
47
+ see `<UpdateGate>` in `@sigx/lynx-updates-ui`), and the update downloads and
48
+ applies automatically. Opt out with `honorMandatory: false`.
49
+
50
+ ## Runtime-version compatibility
51
+
52
+ An OTA bundle can only run on a native binary that has the native modules it
53
+ expects. `sigx prebuild` computes a **runtime fingerprint** from the linked
54
+ native modules' source content, the Lynx SDK version and the scaffold
55
+ revision, and bakes it into the binary. `sigx updates:publish` stamps the
56
+ same fingerprint into the manifest, and the client refuses mismatches:
57
+
58
+ - Add/remove/update a native module package → new fingerprint → published
59
+ updates no longer match → ship a store release. The check surfaces this as
60
+ `{ type: 'incompatible' }` / the `incompatibleUpdate` event.
61
+ - JS-only changes (any lockstep release that doesn't touch native code) keep
62
+ the fingerprint stable — published updates stay valid.
63
+ - Prefer manual control? Pin it: `updates: { runtimeVersion: '1.0.0' }` in
64
+ `signalx.config.ts` (Expo-style — you own the compatibility guarantee).
65
+
66
+ After a store update, all downloaded OTA updates are dropped automatically
67
+ (the binary's fingerprint/versionCode no longer match the recorded state).
68
+
69
+ ## Rollback safety
70
+
71
+ Updates commit in two phases. A downloaded update is *pending* until the app
72
+ signals a healthy boot via `markReady()` — called automatically just after
73
+ `configure()` (set `autoMarkReady: false` to gate on your own signal, e.g.
74
+ first screen rendered). If the app crashes before `markReady()` on
75
+ `rollback.maxFailedLaunches` consecutive launches (default 2), the native
76
+ side deletes the update and reverts to the previous bundle. Detect it:
77
+
78
+ ```ts
79
+ const { didRollBack } = await Updates.getCurrentlyRunning();
80
+ ```
81
+
82
+ ## API
83
+
84
+ ```ts
85
+ Updates.configure(config) // sync, idempotent — call before defineApp()
86
+ Updates.checkForUpdate() // → { type: 'update-available' | 'up-to-date' | 'incompatible', ... }
87
+ Updates.download(manifest?) // download + verify + stage for next launch
88
+ Updates.apply() // apply staged update NOW (in-place reload; only rejects)
89
+ Updates.markReady() // health signal — commits the pending update
90
+ Updates.getCurrentlyRunning() // { updateId, isEmbedded, isFirstLaunchAfterUpdate, didRollBack, ... }
91
+ Updates.clearUpdates() // back to the baked bundle on next launch
92
+ Updates.getState() / Updates.addListener(fn) / Updates.isAvailable()
93
+ useUpdates() // Computed<UpdatesState> for components
94
+ ```
95
+
96
+ State machine: `idle → checking → up-to-date | available | incompatible`,
97
+ `available → downloading → ready → applying`; failures land in `error` and
98
+ every transition fires a typed `UpdatesEvent`.
99
+
100
+ ## Custom backends
101
+
102
+ The static-manifest provider is ~150 lines over `fetch`. Anything else —
103
+ auth, signed manifests, staged rollout services, the Expo Updates protocol —
104
+ implements `UpdateProvider` in its own package, no core changes:
105
+
106
+ ```ts
107
+ import type { UpdateProvider } from '@sigx/lynx-updates';
108
+
109
+ const myBackend: UpdateProvider = {
110
+ name: 'my-backend',
111
+ async checkForUpdate(ctx) {
112
+ // ctx: { platform, runtimeVersion, currentUpdateId, embeddedVersion, channel }
113
+ const res = await fetch(`https://updates.example.com/check`, { ... });
114
+ // normalize your protocol's answer to an UpdateManifest
115
+ return { type: 'update-available', manifest };
116
+ },
117
+ async resolveDownload(manifest) {
118
+ return { url: manifest.bundleUrl, sha256: manifest.sha256, headers: { Authorization: '…' } };
119
+ },
120
+ };
121
+
122
+ Updates.configure({ provider: myBackend });
123
+ ```
124
+
125
+ The byte transfer always happens natively (streamed to disk with incremental
126
+ SHA-256 verification) — providers only decide *what* to download.
127
+
128
+ ## Static manifest format
129
+
130
+ `sigx updates:publish` maintains this document; serve it from any static host:
131
+
132
+ ```json
133
+ {
134
+ "schemaVersion": 1,
135
+ "updates": [{
136
+ "id": "a1b2c3d4e5f60718",
137
+ "version": "1.4.2",
138
+ "channel": "production",
139
+ "platforms": ["android"],
140
+ "runtimeVersion": "fp1-3aa01b2c44de9921",
141
+ "bundleUrl": "updates/a1b2c3d4e5f60718/main.lynx.bundle",
142
+ "sha256": "<64-hex>",
143
+ "mandatory": false,
144
+ "createdAt": "2026-06-12T10:00:00Z",
145
+ "metadata": { "releaseNotes": "Bug fixes." }
146
+ }]
147
+ }
148
+ ```
149
+
150
+ One URL serves every channel/runtime version: old binaries keep matching
151
+ their entries while new binaries pick up new ones. `bundleUrl` may be
152
+ relative (resolved against the manifest URL).
153
+
154
+ ## Notes
155
+
156
+ - **Dev builds**: when running from a dev server URL, OTA is inert (the dev
157
+ server owns the bundle). Baked-bundle debug runs DO consult the update
158
+ store, so rollback can be exercised locally.
159
+ - **Web**: no-ops gracefully — every API degrades like the other native
160
+ modules.
161
+ - Prebuilt UI (update prompt, blocking gate, progress, restart banner):
162
+ [`@sigx/lynx-updates-ui`](../lynx-updates-ui/README.md).
@@ -0,0 +1,154 @@
1
+ package com.sigx.updates
2
+
3
+ import android.content.Context
4
+ import android.util.Log
5
+ import java.io.File
6
+ import java.io.FileOutputStream
7
+ import java.net.HttpURLConnection
8
+ import java.net.URL
9
+ import java.security.MessageDigest
10
+ import java.util.concurrent.atomic.AtomicBoolean
11
+ import org.json.JSONObject
12
+
13
+ /**
14
+ * Streaming bundle downloader: bytes go straight to `tmp/<id>.partial` with
15
+ * an incremental SHA-256, then atomically move into `updates/<id>/` once the
16
+ * hash matches. Single-flight — concurrent calls beyond the first fail fast.
17
+ */
18
+ object UpdateDownloader {
19
+
20
+ private const val TAG = "SigxUpdates"
21
+ private const val CONNECT_TIMEOUT_MS = 15_000
22
+ private const val READ_TIMEOUT_MS = 30_000
23
+ private const val PROGRESS_INTERVAL_MS = 150L
24
+
25
+ private val inFlight = AtomicBoolean(false)
26
+
27
+ /**
28
+ * @return null on success, or an error message. `code` semantics ride in
29
+ * the message prefix; the module maps them to bridge errors.
30
+ */
31
+ fun download(
32
+ context: Context,
33
+ url: String,
34
+ expectedSha256: String,
35
+ updateId: String,
36
+ headers: Map<String, String>,
37
+ manifestJson: String,
38
+ ): String? {
39
+ // Already on disk and intact → success without a byte transferred.
40
+ if (UpdateStore.bundleFile(context, updateId).exists() &&
41
+ UpdateStore.verifySha256(context, updateId)
42
+ ) {
43
+ return null
44
+ }
45
+
46
+ if (!inFlight.compareAndSet(false, true)) {
47
+ return "E_DOWNLOAD_IN_PROGRESS: another download is running"
48
+ }
49
+ try {
50
+ return downloadLocked(context, url, expectedSha256, updateId, headers, manifestJson)
51
+ } finally {
52
+ inFlight.set(false)
53
+ }
54
+ }
55
+
56
+ private fun downloadLocked(
57
+ context: Context,
58
+ url: String,
59
+ expectedSha256: String,
60
+ updateId: String,
61
+ headers: Map<String, String>,
62
+ manifestJson: String,
63
+ ): String? {
64
+ val tmpDir = UpdateStore.tmpDir(context)
65
+ tmpDir.mkdirs()
66
+ val partial = File(tmpDir, "$updateId.partial")
67
+
68
+ var connection: HttpURLConnection? = null
69
+ try {
70
+ connection = URL(url).openConnection() as HttpURLConnection
71
+ connection.connectTimeout = CONNECT_TIMEOUT_MS
72
+ connection.readTimeout = READ_TIMEOUT_MS
73
+ connection.instanceFollowRedirects = true
74
+ for ((key, value) in headers) {
75
+ connection.setRequestProperty(key, value)
76
+ }
77
+
78
+ val status = connection.responseCode
79
+ if (status !in 200..299) {
80
+ return "Download failed: HTTP $status"
81
+ }
82
+
83
+ val totalBytes = connection.contentLengthLong.takeIf { it >= 0 }
84
+ val digest = MessageDigest.getInstance("SHA-256")
85
+ var receivedBytes = 0L
86
+ var lastProgressAt = 0L
87
+
88
+ connection.inputStream.use { input ->
89
+ FileOutputStream(partial).use { out ->
90
+ val buffer = ByteArray(64 * 1024)
91
+ while (true) {
92
+ val read = input.read(buffer)
93
+ if (read < 0) break
94
+ out.write(buffer, 0, read)
95
+ digest.update(buffer, 0, read)
96
+ receivedBytes += read
97
+ val now = System.currentTimeMillis()
98
+ if (now - lastProgressAt >= PROGRESS_INTERVAL_MS) {
99
+ lastProgressAt = now
100
+ UpdatesEventBus.emitProgress(receivedBytes, totalBytes)
101
+ }
102
+ }
103
+ out.fd.sync()
104
+ }
105
+ }
106
+ UpdatesEventBus.emitProgress(receivedBytes, totalBytes ?: receivedBytes)
107
+
108
+ val actual = digest.digest().joinToString("") { "%02x".format(it) }
109
+ if (!actual.equals(expectedSha256, ignoreCase = true)) {
110
+ partial.delete()
111
+ return "E_HASH_MISMATCH: expected $expectedSha256, got $actual"
112
+ }
113
+
114
+ // Atomic-ish promote: write metadata first, bundle rename last.
115
+ val dir = UpdateStore.updateDir(context, updateId)
116
+ dir.deleteRecursively()
117
+ dir.mkdirs()
118
+ val meta = try {
119
+ JSONObject(manifestJson)
120
+ } catch (e: Exception) {
121
+ JSONObject()
122
+ }
123
+ meta.put("sha256", expectedSha256.lowercase())
124
+ meta.put("sizeBytes", receivedBytes)
125
+ meta.put("sourceUrl", url)
126
+ meta.put("downloadedAt", System.currentTimeMillis())
127
+ // Atomic + fsynced: a truncated update.json would fail
128
+ // verifySha256() on next launch and roll back a perfectly good
129
+ // bundle as "corrupt".
130
+ val metaFile = UpdateStore.updateJsonFile(context, updateId)
131
+ val metaTmp = File(dir, "update.json.tmp")
132
+ FileOutputStream(metaTmp).use { out ->
133
+ out.write(meta.toString().toByteArray(Charsets.UTF_8))
134
+ out.fd.sync()
135
+ }
136
+ if (!metaTmp.renameTo(metaFile)) {
137
+ metaFile.delete()
138
+ metaTmp.renameTo(metaFile)
139
+ }
140
+ val bundle = UpdateStore.bundleFile(context, updateId)
141
+ if (!partial.renameTo(bundle)) {
142
+ partial.copyTo(bundle, overwrite = true)
143
+ partial.delete()
144
+ }
145
+ Log.i(TAG, "Downloaded update $updateId ($receivedBytes bytes)")
146
+ return null
147
+ } catch (e: Exception) {
148
+ partial.delete()
149
+ return "Download failed: ${e.message}"
150
+ } finally {
151
+ connection?.disconnect()
152
+ }
153
+ }
154
+ }