@viettelpost/react-native-ota 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/README.md +38 -0
  2. package/android/build.gradle +48 -0
  3. package/android/src/main/java/com/viettelpost/otakit/OTAHashUtils.kt +21 -0
  4. package/android/src/main/java/com/viettelpost/otakit/OTATestReceiver.kt +51 -0
  5. package/android/src/main/java/com/viettelpost/otakit/OTAUpdateBundleResolver.kt +405 -0
  6. package/android/src/main/java/com/viettelpost/otakit/OTAUpdateCleanup.kt +186 -0
  7. package/android/src/main/java/com/viettelpost/otakit/OTAUpdateDownloader.kt +649 -0
  8. package/android/src/main/java/com/viettelpost/otakit/OTAUpdateMetadata.kt +72 -0
  9. package/android/src/main/java/com/viettelpost/otakit/OTAUpdateModule.kt +140 -0
  10. package/android/src/main/java/com/viettelpost/otakit/OTAUpdatePackage.kt +30 -0
  11. package/android/src/main/java/com/viettelpost/otakit/OTAUpdateSignatureVerifier.kt +63 -0
  12. package/android/src/main/java/com/viettelpost/otakit/OTAUpdateStorage.kt +62 -0
  13. package/android/src/main/java/com/viettelpost/otakit/OTAZipUtils.kt +100 -0
  14. package/android/src/main/res/raw/ota_public_key.pem +9 -0
  15. package/bin/cli/assets-zip.js +77 -0
  16. package/bin/cli/bundle.js +72 -0
  17. package/bin/cli/deploy.js +224 -0
  18. package/bin/cli/sign.js +97 -0
  19. package/bin/cli/upload.js +109 -0
  20. package/bin/ota.js +200 -0
  21. package/docs/BACKEND_CONTRACT.md +93 -0
  22. package/docs/DEPLOY_CLI.md +39 -0
  23. package/docs/INTEGRATION_ANDROID.md +20 -0
  24. package/docs/INTEGRATION_IOS.md +21 -0
  25. package/docs/RELEASE_WORKFLOW.md +14 -0
  26. package/ios/OTAHashUtils.swift +22 -0
  27. package/ios/OTAUpdateBundleResolver.swift +359 -0
  28. package/ios/OTAUpdateCleanup.swift +269 -0
  29. package/ios/OTAUpdateDownloader.swift +709 -0
  30. package/ios/OTAUpdateMetadata.swift +47 -0
  31. package/ios/OTAUpdateModule.mm +190 -0
  32. package/ios/OTAUpdateSignatureVerifier.swift +81 -0
  33. package/ios/OTAUpdateStorage.swift +83 -0
  34. package/ios/OTAZipUtils.swift +103 -0
  35. package/ios/ota_public_key.pem +9 -0
  36. package/lib/NativeOTAUpdate.d.ts +77 -0
  37. package/lib/NativeOTAUpdate.js +59 -0
  38. package/lib/OTAClient.d.ts +27 -0
  39. package/lib/OTAClient.js +101 -0
  40. package/lib/config.d.ts +14 -0
  41. package/lib/config.js +29 -0
  42. package/lib/devtools.d.ts +10 -0
  43. package/lib/devtools.js +54 -0
  44. package/lib/index.d.ts +15 -0
  45. package/lib/index.js +32 -0
  46. package/lib/spec/NativeOTAUpdate.d.ts +16 -0
  47. package/lib/spec/NativeOTAUpdate.js +4 -0
  48. package/package.json +82 -0
  49. package/react-native-ota.podspec +21 -0
  50. package/scripts/run-bin.js +67 -0
  51. package/src/NativeOTAUpdate.ts +144 -0
  52. package/src/OTAClient.ts +151 -0
  53. package/src/config.ts +41 -0
  54. package/src/devtools.ts +64 -0
  55. package/src/index.ts +69 -0
  56. package/src/spec/NativeOTAUpdate.ts +21 -0
@@ -0,0 +1,93 @@
1
+ # Backend Contract
2
+
3
+ ## Read Endpoint
4
+
5
+ The device checks for one eligible release:
6
+
7
+ ```http
8
+ GET /api/ota/check-update?platform=ios|android&currentVersion=<version>
9
+ ```
10
+
11
+ Update response:
12
+
13
+ ```json
14
+ {
15
+ "version": "2026.06.26-001",
16
+ "bundleUrl": "https://cdn.example.com/ota/2026.06.26-001/ios/main.jsbundle",
17
+ "fileName": "main.jsbundle",
18
+ "platform": "ios",
19
+ "sha256": "lowercase64hex",
20
+ "signature": "base64EncodedRsaSha256Signature",
21
+ "assetsUrl": "https://cdn.example.com/ota/2026.06.26-001/ios/assets.zip",
22
+ "assetsSha256": "lowercase64hex"
23
+ }
24
+ ```
25
+
26
+ Return `204 No Content` when there is no eligible update.
27
+
28
+ ## Signing Payload
29
+
30
+ The exact payload is newline joined with no trailing newline:
31
+
32
+ ```text
33
+ version=<version>
34
+ platform=<ios|android>
35
+ fileName=<main.jsbundle|index.android.bundle>
36
+ sha256=<lowercase hex>
37
+ assetsSha256=<lowercase hex>
38
+ ```
39
+
40
+ Omit `assetsSha256` for bundle-only updates.
41
+
42
+ Algorithm:
43
+
44
+ - RSA-SHA256
45
+ - PKCS#1 v1.5 padding
46
+ - Base64 signature output
47
+
48
+ ## Upload Endpoint
49
+
50
+ The CLI expects:
51
+
52
+ ```http
53
+ POST /api/ota/releases
54
+ Authorization: Bearer <token>
55
+ Content-Type: multipart/form-data
56
+ ```
57
+
58
+ Multipart fields:
59
+
60
+ - `version`
61
+ - `platform`
62
+ - `fileName`
63
+ - `sha256`
64
+ - `assetsSha256`, only when assets are uploaded
65
+ - `signature`
66
+ - `canonicalPayload`, optional audit field
67
+ - `bundle`, JS bundle file
68
+ - `assets`, optional `assets.zip`
69
+
70
+ Expected responses:
71
+
72
+ ```http
73
+ 201 Created
74
+ ```
75
+
76
+ ```json
77
+ {
78
+ "releaseId": "123",
79
+ "version": "2026.06.26-001",
80
+ "platform": "ios",
81
+ "bundleUrl": "https://cdn.example.com/...",
82
+ "assetsUrl": "https://cdn.example.com/...",
83
+ "status": "DRAFT"
84
+ }
85
+ ```
86
+
87
+ Return `409 Conflict` when `version + platform` already exists so the CLI can auto-increment the sequence.
88
+
89
+ Optional publish endpoint:
90
+
91
+ ```http
92
+ POST /api/ota/releases/{id}/publish
93
+ ```
@@ -0,0 +1,39 @@
1
+ # Deploy CLI
2
+
3
+ The package exposes one binary:
4
+
5
+ ```sh
6
+ ota deploy [--platform ios|android|all] [--version <version>] \
7
+ --api https://cms.example.com \
8
+ --token <token> \
9
+ --private-key ./keys/ota_private_key.pem \
10
+ --public-key ./ios/ota_public_key.pem
11
+ ```
12
+
13
+ Safe local validation:
14
+
15
+ ```sh
16
+ ota deploy --platform android --dry-run
17
+ ota deploy --platform ios --dry-run
18
+ ```
19
+
20
+ Pipeline:
21
+
22
+ 1. Build the platform bundle with `react-native bundle`.
23
+ 2. Zip assets from the React Native `--assets-dest` output.
24
+ 3. Compute lowercase SHA-256 for the bundle and optional `assets.zip`.
25
+ 4. Sign the canonical payload with RSA-SHA256.
26
+ 5. Self-verify with the public key when supplied.
27
+ 6. Upload multipart data to `POST /api/ota/releases`, unless `--dry-run` is set.
28
+
29
+ Bundle names are fixed:
30
+
31
+ - Android: `index.android.bundle`
32
+ - iOS: `main.jsbundle`
33
+
34
+ Asset zip shape:
35
+
36
+ - Android: entries are at zip root, such as `drawable-*` and `raw`.
37
+ - iOS: every entry must live under `assets/`.
38
+
39
+ The default deploy status is `DRAFT`. Passing `--activate` calls the publish endpoint after upload.
@@ -0,0 +1,20 @@
1
+ # Android Integration
2
+
3
+ This package only provides the OTA runtime. A host app must wire native bundle resolution before downloaded bundles can run.
4
+
5
+ Expected host-app work:
6
+
7
+ 1. Install `@viettelpost/react-native-ota`.
8
+ 2. Ensure the package autolinks or manually add `OTAUpdatePackage`.
9
+ 3. Add `ota_public_key.pem` under the host app `res/raw` when overriding the package default public key.
10
+ 4. In the app's `ReactNativeHost`, return the package resolver from `getJSBundleFile()`.
11
+
12
+ Example shape:
13
+
14
+ ```kotlin
15
+ override fun getJSBundleFile(): String? {
16
+ return OTAUpdateBundleResolver.resolveBundlePath(applicationContext)
17
+ }
18
+ ```
19
+
20
+ If the resolver returns `null`, React Native falls back to the embedded bundle. That fallback is intentional and is the safety floor.
@@ -0,0 +1,21 @@
1
+ # iOS Integration
2
+
3
+ This package only provides the OTA runtime. A host app must wire native bundle resolution before downloaded bundles can run.
4
+
5
+ Expected host-app work:
6
+
7
+ 1. Install `@viettelpost/react-native-ota`.
8
+ 2. Run `pod install`.
9
+ 3. Add `ota_public_key.pem` to the host app bundle when overriding the package default public key.
10
+ 4. In the app delegate bundle URL method, ask the package resolver for an OTA bundle URL before falling back to the embedded bundle.
11
+
12
+ Example shape:
13
+
14
+ ```swift
15
+ override func bundleURL() -> URL? {
16
+ return OTAUpdateBundleResolver.shared.resolveBundleURL()
17
+ ?? Bundle.main.url(forResource: "main", withExtension: "jsbundle")
18
+ }
19
+ ```
20
+
21
+ If the resolver returns `nil`, React Native falls back to the embedded bundle. That fallback is intentional and is the safety floor.
@@ -0,0 +1,14 @@
1
+ # Release Workflow
2
+
3
+ Recommended flow:
4
+
5
+ 1. Run `ota deploy --platform all --dry-run`.
6
+ 2. Inspect bundle names, asset zip shape, hashes, and canonical payload.
7
+ 3. Run non-dry-run deploy with a CI-provided private key and token.
8
+ 4. Review the `DRAFT` release in the CMS.
9
+ 5. Publish through CMS or pass `--activate` when the release should become active immediately.
10
+ 6. Launch a test device, call `OTA.sync()`, restart, and call `OTA.markSuccess()` after the app is healthy.
11
+
12
+ Rollback behavior is handled on-device for failed pending bundles and by backend release status for disabling bad releases globally.
13
+
14
+ Never commit private keys or generated release bundles.
@@ -0,0 +1,22 @@
1
+ import Foundation
2
+ import CryptoKit
3
+
4
+ enum OTAHashUtils {
5
+ static func sha256(fileURL: URL) throws -> String {
6
+ let handle = try FileHandle(forReadingFrom: fileURL)
7
+ defer {
8
+ try? handle.close()
9
+ }
10
+
11
+ var hasher = SHA256()
12
+ while true {
13
+ let data = handle.readData(ofLength: 1024 * 1024)
14
+ if data.isEmpty {
15
+ break
16
+ }
17
+ hasher.update(data: data)
18
+ }
19
+
20
+ return hasher.finalize().map { String(format: "%02x", $0) }.joined()
21
+ }
22
+ }
@@ -0,0 +1,359 @@
1
+ import Foundation
2
+
3
+ struct OTABundleInfo: Codable {
4
+ let runningBundleVersion: String
5
+ let activeBundleVersion: String
6
+ let pendingBundleVersion: String?
7
+ let status: String
8
+ let bundlePath: String?
9
+ let assetsDirectoryPath: String?
10
+ let assetsDirectoryExists: Bool
11
+ let isEmbedded: Bool
12
+ }
13
+
14
+ @objc(OTAUpdateBundleResolver)
15
+ public final class OTAUpdateBundleResolver: NSObject {
16
+ @objc public static let shared = OTAUpdateBundleResolver()
17
+
18
+ private let storage: OTAUpdateStorage
19
+ private let fileManager: FileManager
20
+ private let encoder: JSONEncoder
21
+
22
+ private let embeddedVersion = "embedded"
23
+ private let statusActive = "active"
24
+ private let statusPending = "pending"
25
+ private let statusFailed = "failed"
26
+ private let statusRolledBack = "rolled_back"
27
+
28
+ private let missingPendingBundleReason = "missing_pending_bundle"
29
+ private let pendingBundleFileMissingReason = "pending_bundle_file_missing"
30
+ private let markSuccessNotCalledReason = "mark_success_not_called"
31
+ private let activeBundleFileMissingReason = "active_bundle_file_missing"
32
+
33
+ override init() {
34
+ storage = OTAUpdateStorage.shared
35
+ fileManager = .default
36
+ encoder = JSONEncoder()
37
+ super.init()
38
+ }
39
+
40
+ init(storage: OTAUpdateStorage, fileManager: FileManager, encoder: JSONEncoder) {
41
+ self.storage = storage
42
+ self.fileManager = fileManager
43
+ self.encoder = encoder
44
+ super.init()
45
+ }
46
+
47
+ @objc public func resolveBundleURL() -> URL? {
48
+ do {
49
+ return try resolveBundleURLOrThrow()
50
+ } catch {
51
+ return nil
52
+ }
53
+ }
54
+
55
+ @objc func getOTADirectoryPath() throws -> String {
56
+ let directoryURL = try otaDirectoryURL()
57
+ try fileManager.createDirectory(
58
+ at: directoryURL,
59
+ withIntermediateDirectories: true,
60
+ attributes: nil
61
+ )
62
+ return directoryURL.path
63
+ }
64
+
65
+ // Manual test hook: the bundle file must already be copied into private OTA storage.
66
+ @objc func prepareManualInstall(_ bundleVersion: String) throws -> NSNumber {
67
+ let normalizedVersion = bundleVersion.trimmingCharacters(in: .whitespacesAndNewlines)
68
+ guard !normalizedVersion.isEmpty else {
69
+ throw NSError(
70
+ domain: "OTAUpdateManualInstall",
71
+ code: OTAUpdateManualInstallErrorCode.invalidBundleVersion.rawValue,
72
+ userInfo: [NSLocalizedDescriptionKey: "bundleVersion must not be empty"]
73
+ )
74
+ }
75
+
76
+ let url = try bundleURL(for: normalizedVersion)
77
+ guard fileManager.fileExists(atPath: url.path) else {
78
+ throw NSError(
79
+ domain: "OTAUpdateManualInstall",
80
+ code: OTAUpdateManualInstallErrorCode.bundleFileNotFound.rawValue,
81
+ userInfo: [NSLocalizedDescriptionKey: "OTA bundle file was not found at \(url.path)"]
82
+ )
83
+ }
84
+
85
+ var metadata = try storage.readMetadata()
86
+ metadata.previousBundleVersion = metadata.activeBundleVersion
87
+ metadata.pendingBundleVersion = normalizedVersion
88
+ metadata.status = statusPending
89
+ metadata.launchCountForPending = 0
90
+ metadata.lastFailureReason = nil
91
+ try storage.writeMetadata(metadata)
92
+ return true as NSNumber
93
+ }
94
+
95
+ // Manual test hook: promote only the pending bundle that actually launched.
96
+ @objc func markSuccess() throws -> NSNumber {
97
+ var metadata = try storage.readMetadata()
98
+ guard metadata.status == statusPending,
99
+ let pendingVersion = metadata.pendingBundleVersion,
100
+ !pendingVersion.isEmpty,
101
+ metadata.runningBundleVersion == pendingVersion else {
102
+ return false as NSNumber
103
+ }
104
+
105
+ metadata.previousBundleVersion = metadata.activeBundleVersion
106
+ metadata.activeBundleVersion = pendingVersion
107
+ metadata.pendingBundleVersion = nil
108
+ metadata.status = statusActive
109
+ metadata.launchCountForPending = 0
110
+ metadata.lastSuccessfulLaunchAt = ISO8601DateFormatter().string(from: Date())
111
+ metadata.lastFailureReason = nil
112
+ try storage.writeMetadata(metadata)
113
+
114
+ // Metadata is saved before cleanup so a cleanup warning cannot undo a confirmed update.
115
+ do {
116
+ _ = try OTAUpdateCleanup.shared.cleanupAfterSuccessfulActivation(activeVersion: pendingVersion)
117
+ _ = try OTAUpdateCleanup.shared.cleanupAllTemp()
118
+ } catch {
119
+ NSLog("OTAKit cleanup after markSuccess failed: %@", error.localizedDescription)
120
+ }
121
+ return true as NSNumber
122
+ }
123
+
124
+ @objc func copyBundleFromDocuments(_ bundleVersion: String, fileName: String) throws -> NSNumber {
125
+ let normalizedVersion = bundleVersion.trimmingCharacters(in: .whitespacesAndNewlines)
126
+ let normalizedFileName = fileName.trimmingCharacters(in: .whitespacesAndNewlines)
127
+ guard !normalizedVersion.isEmpty, !normalizedFileName.isEmpty else {
128
+ throw NSError(
129
+ domain: "OTAUpdateManualInstall",
130
+ code: OTAUpdateManualInstallErrorCode.invalidBundleVersion.rawValue,
131
+ userInfo: [NSLocalizedDescriptionKey: "bundleVersion and fileName must not be empty"]
132
+ )
133
+ }
134
+
135
+ let documentsURL = try fileManager.url(
136
+ for: .documentDirectory,
137
+ in: .userDomainMask,
138
+ appropriateFor: nil,
139
+ create: false
140
+ )
141
+ let sourceURL = documentsURL.appendingPathComponent(normalizedFileName)
142
+ guard fileManager.fileExists(atPath: sourceURL.path) else {
143
+ throw NSError(
144
+ domain: "OTAUpdateManualInstall",
145
+ code: OTAUpdateManualInstallErrorCode.bundleFileNotFound.rawValue,
146
+ userInfo: [NSLocalizedDescriptionKey: "Source bundle file was not found at \(sourceURL.path)"]
147
+ )
148
+ }
149
+
150
+ let destinationURL = try bundleURL(for: normalizedVersion)
151
+ try fileManager.createDirectory(
152
+ at: destinationURL.deletingLastPathComponent(),
153
+ withIntermediateDirectories: true,
154
+ attributes: nil
155
+ )
156
+ if fileManager.fileExists(atPath: destinationURL.path) {
157
+ try fileManager.removeItem(at: destinationURL)
158
+ }
159
+ try fileManager.copyItem(at: sourceURL, to: destinationURL)
160
+ return true as NSNumber
161
+ }
162
+
163
+ @objc func getCurrentBundleInfoJSONString() throws -> String {
164
+ let metadata = try storage.readMetadata()
165
+ let info = currentBundleInfo(for: metadata)
166
+ let data = try encoder.encode(info)
167
+ guard let jsonString = String(data: data, encoding: .utf8) else {
168
+ throw OTAUpdateBundleResolverError.jsonEncodingFailed
169
+ }
170
+ return jsonString
171
+ }
172
+
173
+ private func resolveBundleURLOrThrow() throws -> URL? {
174
+ let metadata = try storage.readMetadata()
175
+
176
+ if metadata.status == statusPending {
177
+ return try resolvePendingBundle(metadata)
178
+ }
179
+
180
+ return try resolveActiveBundle(metadata)
181
+ }
182
+
183
+ private func resolvePendingBundle(_ metadata: OTAUpdateMetadata) throws -> URL? {
184
+ guard let pendingVersion = metadata.pendingBundleVersion, !pendingVersion.isEmpty else {
185
+ var nextMetadata = metadata
186
+ nextMetadata.status = statusFailed
187
+ nextMetadata.pendingBundleVersion = nil
188
+ nextMetadata.runningBundleVersion = embeddedVersion
189
+ nextMetadata.launchCountForPending = 0
190
+ nextMetadata.lastFailureReason = missingPendingBundleReason
191
+ try storage.writeMetadata(nextMetadata)
192
+ return nil
193
+ }
194
+
195
+ let pendingURL = try bundleURL(for: pendingVersion)
196
+ guard fileManager.fileExists(atPath: pendingURL.path) else {
197
+ let runningVersion = try validActiveBundleVersion(metadata) ?? embeddedVersion
198
+ var nextMetadata = metadata
199
+ nextMetadata.status = statusFailed
200
+ nextMetadata.pendingBundleVersion = nil
201
+ nextMetadata.failedBundleVersion = pendingVersion
202
+ nextMetadata.runningBundleVersion = runningVersion
203
+ nextMetadata.launchCountForPending = 0
204
+ nextMetadata.lastFailureReason = pendingBundleFileMissingReason
205
+ try storage.writeMetadata(nextMetadata)
206
+ // The pending version cannot run, so its package and temp files are safe to remove.
207
+ do {
208
+ _ = try OTAUpdateCleanup.shared.cleanupFailedPendingBundle(failedVersion: pendingVersion)
209
+ } catch {
210
+ NSLog("OTAKit cleanup missing pending bundle failed: %@", error.localizedDescription)
211
+ }
212
+ return try bundleURLIfNotEmbedded(for: runningVersion)
213
+ }
214
+
215
+ if metadata.launchCountForPending > 0 {
216
+ let runningVersion = try validActiveBundleVersion(metadata) ?? embeddedVersion
217
+ var nextMetadata = metadata
218
+ nextMetadata.status = statusRolledBack
219
+ nextMetadata.pendingBundleVersion = nil
220
+ nextMetadata.failedBundleVersion = pendingVersion
221
+ nextMetadata.runningBundleVersion = runningVersion
222
+ nextMetadata.launchCountForPending = 0
223
+ nextMetadata.lastFailureReason = markSuccessNotCalledReason
224
+ try storage.writeMetadata(nextMetadata)
225
+ // Save rollback metadata first; cleanup failure must not prevent fallback startup.
226
+ do {
227
+ _ = try OTAUpdateCleanup.shared.cleanupFailedPendingBundle(failedVersion: pendingVersion)
228
+ } catch {
229
+ NSLog("OTAKit cleanup failed pending bundle failed: %@", error.localizedDescription)
230
+ }
231
+ return try bundleURLIfNotEmbedded(for: runningVersion)
232
+ }
233
+
234
+ var nextMetadata = metadata
235
+ nextMetadata.runningBundleVersion = pendingVersion
236
+ nextMetadata.launchCountForPending = metadata.launchCountForPending + 1
237
+ try storage.writeMetadata(nextMetadata)
238
+ return pendingURL
239
+ }
240
+
241
+ private func resolveActiveBundle(_ metadata: OTAUpdateMetadata) throws -> URL? {
242
+ let activeVersion = metadata.activeBundleVersion
243
+ if !isEmbeddedVersion(activeVersion) {
244
+ let activeURL = try bundleURL(for: activeVersion)
245
+ if fileManager.fileExists(atPath: activeURL.path) {
246
+ var nextMetadata = metadata
247
+ nextMetadata.runningBundleVersion = activeVersion
248
+ try storage.writeMetadata(nextMetadata)
249
+ return activeURL
250
+ }
251
+
252
+ var nextMetadata = metadata
253
+ nextMetadata.status = statusFailed
254
+ nextMetadata.failedBundleVersion = activeVersion
255
+ nextMetadata.runningBundleVersion = embeddedVersion
256
+ nextMetadata.lastFailureReason = activeBundleFileMissingReason
257
+ try storage.writeMetadata(nextMetadata)
258
+ return nil
259
+ }
260
+
261
+ var nextMetadata = metadata
262
+ nextMetadata.runningBundleVersion = embeddedVersion
263
+ try storage.writeMetadata(nextMetadata)
264
+ return nil
265
+ }
266
+
267
+ private func currentBundleInfo(for metadata: OTAUpdateMetadata) -> OTABundleInfo {
268
+ let isEmbedded = isEmbeddedVersion(metadata.runningBundleVersion)
269
+ let bundlePath: String?
270
+ let assetsDirectoryPath: String?
271
+ let assetsDirectoryExists: Bool
272
+ if isEmbedded {
273
+ bundlePath = Bundle.main.url(forResource: "main", withExtension: "jsbundle")?.path
274
+ assetsDirectoryPath = nil
275
+ assetsDirectoryExists = false
276
+ } else {
277
+ let bundleURL = try? bundleURL(for: metadata.runningBundleVersion)
278
+ bundlePath = bundleURL.flatMap { fileManager.fileExists(atPath: $0.path) ? $0.path : nil }
279
+ let assetsURL = try? assetsDirectoryURL(for: metadata.runningBundleVersion)
280
+ assetsDirectoryPath = assetsURL?.path
281
+ assetsDirectoryExists = assetsURL.map { directoryExistsAndHasFiles($0) } ?? false
282
+ }
283
+
284
+ return OTABundleInfo(
285
+ runningBundleVersion: metadata.runningBundleVersion,
286
+ activeBundleVersion: metadata.activeBundleVersion,
287
+ pendingBundleVersion: metadata.pendingBundleVersion,
288
+ status: metadata.status,
289
+ bundlePath: bundlePath,
290
+ assetsDirectoryPath: assetsDirectoryPath,
291
+ assetsDirectoryExists: assetsDirectoryExists,
292
+ isEmbedded: isEmbedded
293
+ )
294
+ }
295
+
296
+ private func validActiveBundleVersion(_ metadata: OTAUpdateMetadata) throws -> String? {
297
+ let activeVersion = metadata.activeBundleVersion
298
+ if isEmbeddedVersion(activeVersion) {
299
+ return nil
300
+ }
301
+
302
+ let activeURL = try bundleURL(for: activeVersion)
303
+ return fileManager.fileExists(atPath: activeURL.path) ? activeVersion : nil
304
+ }
305
+
306
+ private func bundleURLIfNotEmbedded(for bundleVersion: String) throws -> URL? {
307
+ if isEmbeddedVersion(bundleVersion) {
308
+ return nil
309
+ }
310
+ return try bundleURL(for: bundleVersion)
311
+ }
312
+
313
+ private func bundleURL(for bundleVersion: String) throws -> URL {
314
+ try otaDirectoryURL()
315
+ .appendingPathComponent("bundles", isDirectory: true)
316
+ .appendingPathComponent(bundleVersion, isDirectory: true)
317
+ .appendingPathComponent("main.jsbundle")
318
+ }
319
+
320
+ private func assetsDirectoryURL(for bundleVersion: String) throws -> URL {
321
+ try otaDirectoryURL()
322
+ .appendingPathComponent("bundles", isDirectory: true)
323
+ .appendingPathComponent(bundleVersion, isDirectory: true)
324
+ .appendingPathComponent("assets", isDirectory: true)
325
+ }
326
+
327
+ private func otaDirectoryURL() throws -> URL {
328
+ let supportURL = try fileManager.url(
329
+ for: .applicationSupportDirectory,
330
+ in: .userDomainMask,
331
+ appropriateFor: nil,
332
+ create: true
333
+ )
334
+ return supportURL.appendingPathComponent("OTA", isDirectory: true)
335
+ }
336
+
337
+ private func isEmbeddedVersion(_ bundleVersion: String?) -> Bool {
338
+ guard let bundleVersion = bundleVersion, !bundleVersion.isEmpty else {
339
+ return true
340
+ }
341
+ return bundleVersion == embeddedVersion
342
+ }
343
+
344
+ private func directoryExistsAndHasFiles(_ url: URL) -> Bool {
345
+ guard let enumerator = fileManager.enumerator(at: url, includingPropertiesForKeys: nil) else {
346
+ return false
347
+ }
348
+ return enumerator.nextObject() != nil
349
+ }
350
+ }
351
+
352
+ enum OTAUpdateBundleResolverError: Error {
353
+ case jsonEncodingFailed
354
+ }
355
+
356
+ private enum OTAUpdateManualInstallErrorCode: Int {
357
+ case invalidBundleVersion = 1
358
+ case bundleFileNotFound = 2
359
+ }