@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,709 @@
1
+ import Foundation
2
+
3
+ struct OTADownloadInfo: Codable {
4
+ let version: String
5
+ let tempBundlePath: String
6
+ let finalBundlePath: String
7
+ let tempAssetsZipPath: String
8
+ let finalAssetsDirectoryPath: String
9
+ let tempExists: Bool
10
+ let finalExists: Bool
11
+ let tempAssetsZipExists: Bool
12
+ let finalAssetFileCount: Int
13
+ let tempSize: UInt64
14
+ let finalSize: UInt64
15
+ let tempAssetsZipSize: UInt64
16
+ let finalAssetsSize: UInt64
17
+ let tempSha256: String?
18
+ let finalSha256: String?
19
+ let tempAssetsSha256: String?
20
+ let signatureRequired: Bool
21
+ }
22
+
23
+ struct OTAUpdateDownloaderError: Error {
24
+ let code: String
25
+ let message: String
26
+ let underlyingError: Error?
27
+
28
+ init(code: String, message: String, underlyingError: Error? = nil) {
29
+ self.code = code
30
+ self.message = message
31
+ self.underlyingError = underlyingError
32
+ }
33
+
34
+ var nsError: NSError {
35
+ var userInfo: [String: Any] = [
36
+ NSLocalizedDescriptionKey: message,
37
+ "otaCode": code,
38
+ ]
39
+ if let underlyingError = underlyingError {
40
+ userInfo[NSUnderlyingErrorKey] = underlyingError as NSError
41
+ }
42
+ return NSError(domain: "OTAUpdateDownloader", code: 1, userInfo: userInfo)
43
+ }
44
+ }
45
+
46
+ private struct OTAUpdatePayload {
47
+ let version: String
48
+ let bundleUrl: String?
49
+ let assetsUrl: String?
50
+ let platform: String
51
+ let fileName: String
52
+ let sha256: String?
53
+ let assetsSha256: String?
54
+ let signature: String?
55
+ }
56
+
57
+ @objc(OTAUpdateDownloader)
58
+ final class OTAUpdateDownloader: NSObject {
59
+ @objc static let shared = OTAUpdateDownloader()
60
+
61
+ private let storage = OTAUpdateStorage.shared
62
+ private let fileManager = FileManager.default
63
+ private let encoder = JSONEncoder()
64
+
65
+ private let bundleFileName = "main.jsbundle"
66
+ private let assetsZipFileName = "assets.zip"
67
+ private let platformIOS = "ios"
68
+ private let partialSuffix = ".download"
69
+
70
+ private let statusDownloaded = "downloaded"
71
+ private let statusDownloadFailed = "download_failed"
72
+ private let statusVerifying = "verifying"
73
+ private let statusVerified = "verified"
74
+ private let statusVerifyFailed = "verify_failed"
75
+ private let statusPending = "pending"
76
+
77
+ private let reasonDownloadFailed = "download_failed"
78
+ private let reasonMissingSha256 = "missing_sha256"
79
+ private let reasonMissingAssetsSha256 = "missing_assets_sha256"
80
+ private let reasonMissingSignature = "missing_signature"
81
+ private let reasonSignatureInvalid = "signature_invalid"
82
+ private let reasonSha256Mismatch = "sha256_mismatch"
83
+ private let reasonAssetsSha256Mismatch = "assets_sha256_mismatch"
84
+ private let reasonAssetsZipInvalid = "assets_zip_invalid"
85
+ private let reasonAssetsExtractFailed = "assets_extract_failed"
86
+ private let reasonTempBundleNotFoundForVerify = "temp_bundle_not_found_for_verify"
87
+ private let reasonTempAssetsNotFoundForVerify = "temp_assets_not_found_for_verify"
88
+ private let signatureMismatchMessage = "OTA signature mismatch. Check that version, platform, fileName, lowercase sha256, and assetsSha256 when assets are included exactly match the signed payload."
89
+
90
+ @objc func downloadAndInstallBundle(_ updateJson: String, completion: @escaping (NSNumber?, NSError?) -> Void) {
91
+ DispatchQueue.global(qos: .utility).async {
92
+ do {
93
+ _ = try self.downloadBundle(updateJson)
94
+ let didInstall = try self.installDownloadedBundle(updateJson)
95
+ completion(didInstall as NSNumber, nil)
96
+ } catch {
97
+ completion(nil, self.toNSError(error))
98
+ }
99
+ }
100
+ }
101
+
102
+ @objc func getDownloadInfo(_ version: String) throws -> String {
103
+ let normalizedVersion = try normalizeVersion(version)
104
+ let tempBundleURL = try tempBundleURL(version: normalizedVersion)
105
+ let finalBundleURL = try finalBundleURL(version: normalizedVersion)
106
+ let tempAssetsURL = try tempAssetsURL(version: normalizedVersion)
107
+ let finalAssetsDirectoryURL = try finalAssetsDirectoryURL(version: normalizedVersion)
108
+ let info = OTADownloadInfo(
109
+ version: normalizedVersion,
110
+ tempBundlePath: tempBundleURL.path,
111
+ finalBundlePath: finalBundleURL.path,
112
+ tempAssetsZipPath: tempAssetsURL.path,
113
+ finalAssetsDirectoryPath: finalAssetsDirectoryURL.path,
114
+ tempExists: fileExistsAndNonEmpty(tempBundleURL),
115
+ finalExists: fileExistsAndNonEmpty(finalBundleURL),
116
+ tempAssetsZipExists: fileExistsAndNonEmpty(tempAssetsURL),
117
+ finalAssetFileCount: directoryFileCount(finalAssetsDirectoryURL),
118
+ tempSize: fileSize(tempBundleURL),
119
+ finalSize: fileSize(finalBundleURL),
120
+ tempAssetsZipSize: fileSize(tempAssetsURL),
121
+ finalAssetsSize: directorySize(finalAssetsDirectoryURL),
122
+ tempSha256: try fileSha256IfExists(tempBundleURL),
123
+ finalSha256: try fileSha256IfExists(finalBundleURL),
124
+ tempAssetsSha256: try fileSha256IfExists(tempAssetsURL),
125
+ signatureRequired: true
126
+ )
127
+ let data = try encoder.encode(info)
128
+ guard let json = String(data: data, encoding: .utf8) else {
129
+ throw OTAUpdateDownloaderError(code: "OTA_GET_DOWNLOAD_INFO_FAILED", message: "Failed to encode OTA download info")
130
+ }
131
+ return json
132
+ }
133
+
134
+ private func downloadBundle(_ updateJson: String) throws -> String {
135
+ let update = try parsePayload(updateJson, requireBundleUrl: true)
136
+ try validateVersionCanInstall(update.version)
137
+
138
+ let tempDir = try tempBundleDirectory(version: update.version)
139
+ let partialURL = tempDir.appendingPathComponent(bundleFileName + partialSuffix)
140
+ let tempBundleURL = tempDir.appendingPathComponent(bundleFileName)
141
+ let partialAssetsURL = tempDir.appendingPathComponent(assetsZipFileName + partialSuffix)
142
+ let tempAssetsURL = tempDir.appendingPathComponent(assetsZipFileName)
143
+
144
+ try? OTAUpdateCleanup.shared.cleanupTemp(version: update.version)
145
+ try fileManager.createDirectory(at: tempDir, withIntermediateDirectories: true, attributes: nil)
146
+
147
+ do {
148
+ try downloadToFile(
149
+ urlString: update.bundleUrl ?? "",
150
+ destinationURL: partialURL
151
+ )
152
+ guard fileExistsAndNonEmpty(partialURL) else {
153
+ throw OTAUpdateDownloaderError(code: "OTA_DOWNLOAD_FAILED", message: "Downloaded OTA bundle is empty")
154
+ }
155
+ if fileManager.fileExists(atPath: tempBundleURL.path) {
156
+ try fileManager.removeItem(at: tempBundleURL)
157
+ }
158
+ do {
159
+ try fileManager.moveItem(at: partialURL, to: tempBundleURL)
160
+ } catch {
161
+ try fileManager.copyItem(at: partialURL, to: tempBundleURL)
162
+ try? fileManager.removeItem(at: partialURL)
163
+ }
164
+ guard fileExistsAndNonEmpty(tempBundleURL) else {
165
+ throw OTAUpdateDownloaderError(code: "OTA_DOWNLOAD_FAILED", message: "Downloaded OTA bundle was not finalized")
166
+ }
167
+
168
+ if let assetsUrl = update.assetsUrl, !assetsUrl.isEmpty {
169
+ try downloadToFile(
170
+ urlString: assetsUrl,
171
+ destinationURL: partialAssetsURL,
172
+ label: "OTA assets ZIP"
173
+ )
174
+ guard fileExistsAndNonEmpty(partialAssetsURL) else {
175
+ throw OTAUpdateDownloaderError(code: "OTA_ASSETS_DOWNLOAD_FAILED", message: "Downloaded OTA assets ZIP is empty")
176
+ }
177
+ if fileManager.fileExists(atPath: tempAssetsURL.path) {
178
+ try fileManager.removeItem(at: tempAssetsURL)
179
+ }
180
+ do {
181
+ try fileManager.moveItem(at: partialAssetsURL, to: tempAssetsURL)
182
+ } catch {
183
+ try fileManager.copyItem(at: partialAssetsURL, to: tempAssetsURL)
184
+ try? fileManager.removeItem(at: partialAssetsURL)
185
+ }
186
+ guard fileExistsAndNonEmpty(tempAssetsURL) else {
187
+ throw OTAUpdateDownloaderError(code: "OTA_ASSETS_DOWNLOAD_FAILED", message: "Downloaded OTA assets ZIP was not finalized")
188
+ }
189
+ }
190
+
191
+ var metadata = try storage.readMetadata()
192
+ metadata.status = statusDownloaded
193
+ metadata.failedBundleVersion = nil
194
+ metadata.lastFailureReason = nil
195
+ try storage.writeMetadata(metadata)
196
+ return tempBundleURL.path
197
+ } catch {
198
+ try? fileManager.removeItem(at: partialURL)
199
+ try? fileManager.removeItem(at: partialAssetsURL)
200
+ try? OTAUpdateCleanup.shared.cleanupTemp(version: update.version)
201
+ var metadata = try storage.readMetadata()
202
+ metadata.status = statusDownloadFailed
203
+ metadata.failedBundleVersion = update.version
204
+ let otaError = error as? OTAUpdateDownloaderError
205
+ metadata.lastFailureReason = otaError?.code == "OTA_ASSETS_DOWNLOAD_FAILED" ? "assets_download_failed" : reasonDownloadFailed
206
+ try storage.writeMetadata(metadata)
207
+ if let otaError = otaError, otaError.code == "OTA_ASSETS_DOWNLOAD_FAILED" {
208
+ throw otaError
209
+ }
210
+ throw OTAUpdateDownloaderError(code: "OTA_DOWNLOAD_FAILED", message: "Failed to download OTA bundle for version \(update.version)", underlyingError: error)
211
+ }
212
+ }
213
+
214
+ private func verifyDownloadedBundle(_ updateJson: String) throws -> Bool {
215
+ let update = try parsePayload(updateJson, requireSha256: true)
216
+ return try verifyDownloadedBundle(update)
217
+ }
218
+
219
+ private func verifyUpdateSignature(_ updateJson: String) throws -> Bool {
220
+ let update = try parsePayload(
221
+ updateJson,
222
+ requireSha256: true,
223
+ requireSignature: true,
224
+ requireSigningFields: true
225
+ )
226
+ return try verifyUpdateSignature(update)
227
+ }
228
+
229
+ private func installDownloadedBundle(_ updateJson: String) throws -> Bool {
230
+ let update = try parsePayload(
231
+ updateJson,
232
+ requireSha256: true,
233
+ requireSignature: true,
234
+ requireSigningFields: true
235
+ )
236
+ try validateVersionCanInstall(update.version)
237
+ try verifyDownloadedUpdate(update)
238
+
239
+ let tempDir = try tempBundleDirectory(version: update.version)
240
+ let tempBundle = tempDir.appendingPathComponent(bundleFileName)
241
+ let tempAssets = tempDir.appendingPathComponent(assetsZipFileName)
242
+ guard fileExistsAndNonEmpty(tempBundle) else {
243
+ try? OTAUpdateCleanup.shared.cleanupTemp(version: update.version)
244
+ throw OTAUpdateDownloaderError(code: "OTA_TEMP_BUNDLE_NOT_FOUND", message: "Downloaded OTA temp bundle was not found at \(tempBundle.path)")
245
+ }
246
+
247
+ let finalDir = try finalBundleDirectory(version: update.version)
248
+ let finalBundle = finalDir.appendingPathComponent(bundleFileName)
249
+ do {
250
+ _ = try OTAUpdateCleanup.shared.deleteFinalBundleForFailedInstall(version: update.version)
251
+ if fileManager.fileExists(atPath: finalDir.path) {
252
+ throw OTAUpdateDownloaderError(code: "OTA_INSTALL_FAILED", message: "Unable to clear previous OTA bundle directory")
253
+ }
254
+ try fileManager.createDirectory(at: finalDir, withIntermediateDirectories: true, attributes: nil)
255
+ try fileManager.copyItem(at: tempBundle, to: finalBundle)
256
+ guard fileExistsAndNonEmpty(finalBundle) else {
257
+ throw OTAUpdateDownloaderError(code: "OTA_INSTALL_FAILED", message: "Installed OTA bundle is missing or empty")
258
+ }
259
+ if update.assetsUrl?.isEmpty == false {
260
+ guard fileExistsAndNonEmpty(tempAssets) else {
261
+ try? OTAUpdateCleanup.shared.cleanupTemp(version: update.version)
262
+ try? OTAUpdateCleanup.shared.deleteFinalBundleForFailedInstall(version: update.version)
263
+ throw OTAUpdateDownloaderError(code: "OTA_TEMP_ASSETS_NOT_FOUND", message: "Downloaded OTA temp assets ZIP was not found at \(tempAssets.path)")
264
+ }
265
+ do {
266
+ try OTAZipUtils.unzip(zipURL: tempAssets, destinationURL: finalDir)
267
+ } catch {
268
+ try markVerifyFailed(version: update.version, reason: reasonAssetsExtractFailed)
269
+ try? OTAUpdateCleanup.shared.cleanupTemp(version: update.version)
270
+ try? OTAUpdateCleanup.shared.deleteFinalBundleForFailedInstall(version: update.version)
271
+ if let otaError = error as? OTAUpdateDownloaderError {
272
+ throw otaError
273
+ }
274
+ throw OTAUpdateDownloaderError(code: "OTA_ASSETS_EXTRACT_FAILED", message: "Failed to extract OTA assets for version \(update.version)", underlyingError: error)
275
+ }
276
+ }
277
+ do {
278
+ _ = try OTAUpdateCleanup.shared.cleanupTemp(version: update.version)
279
+ } catch {
280
+ NSLog("OTAKit cleanup temp after install failed: %@", error.localizedDescription)
281
+ }
282
+
283
+ var metadata = try storage.readMetadata()
284
+ metadata.previousBundleVersion = metadata.activeBundleVersion
285
+ metadata.pendingBundleVersion = update.version
286
+ metadata.status = statusPending
287
+ metadata.launchCountForPending = 0
288
+ metadata.failedBundleVersion = nil
289
+ metadata.lastFailureReason = nil
290
+ try storage.writeMetadata(metadata)
291
+ return true
292
+ } catch {
293
+ try? OTAUpdateCleanup.shared.cleanupTemp(version: update.version)
294
+ try? OTAUpdateCleanup.shared.deleteFinalBundleForFailedInstall(version: update.version)
295
+ if let otaError = error as? OTAUpdateDownloaderError {
296
+ throw otaError
297
+ }
298
+ throw OTAUpdateDownloaderError(code: "OTA_INSTALL_FAILED", message: "Failed to install downloaded OTA bundle for version \(update.version)", underlyingError: error)
299
+ }
300
+ }
301
+
302
+ private func verifyDownloadedUpdate(_ update: OTAUpdatePayload) throws {
303
+ do {
304
+ _ = try verifyDownloadedBundle(update)
305
+ _ = try verifyUpdateSignature(update)
306
+ } catch {
307
+ if let otaError = error as? OTAUpdateDownloaderError,
308
+ otaError.code == "OTA_MISSING_SIGNATURE" ||
309
+ otaError.code == "OTA_SIGNATURE_INVALID" ||
310
+ otaError.code == "OTA_ASSETS_SHA256_MISMATCH" ||
311
+ otaError.code == "OTA_ASSETS_ZIP_INVALID" {
312
+ try? OTAUpdateCleanup.shared.cleanupTemp(version: update.version)
313
+ }
314
+ throw error
315
+ }
316
+ }
317
+
318
+ private func verifyDownloadedBundle(_ update: OTAUpdatePayload) throws -> Bool {
319
+ guard let expectedSha256 = update.sha256, !expectedSha256.isEmpty else {
320
+ try markVerifyFailed(version: update.version, reason: reasonMissingSha256)
321
+ try? OTAUpdateCleanup.shared.cleanupTemp(version: update.version)
322
+ throw OTAUpdateDownloaderError(code: "OTA_MISSING_SHA256", message: "OTA update \(update.version) is missing required sha256")
323
+ }
324
+
325
+ let tempDir = try tempBundleDirectory(version: update.version)
326
+ let tempBundle = tempDir.appendingPathComponent(bundleFileName)
327
+ guard fileExistsAndNonEmpty(tempBundle) else {
328
+ try markVerifyFailed(version: update.version, reason: reasonTempBundleNotFoundForVerify)
329
+ try? OTAUpdateCleanup.shared.cleanupTemp(version: update.version)
330
+ throw OTAUpdateDownloaderError(code: "OTA_TEMP_BUNDLE_NOT_FOUND", message: "Downloaded OTA temp bundle was not found at \(tempBundle.path)")
331
+ }
332
+
333
+ var metadata = try storage.readMetadata()
334
+ metadata.status = statusVerifying
335
+ metadata.failedBundleVersion = nil
336
+ metadata.lastFailureReason = nil
337
+ try storage.writeMetadata(metadata)
338
+
339
+ let actualSha256 = try OTAHashUtils.sha256(fileURL: tempBundle)
340
+ guard actualSha256.caseInsensitiveCompare(expectedSha256) == .orderedSame else {
341
+ try? OTAUpdateCleanup.shared.cleanupTemp(version: update.version)
342
+ try markVerifyFailed(version: update.version, reason: reasonSha256Mismatch)
343
+ throw OTAUpdateDownloaderError(code: "OTA_SHA256_MISMATCH", message: "OTA SHA-256 verification failed for version \(update.version)")
344
+ }
345
+
346
+ if let assetsUrl = update.assetsUrl, !assetsUrl.isEmpty {
347
+ guard let expectedAssetsSha256 = update.assetsSha256, !expectedAssetsSha256.isEmpty else {
348
+ try? OTAUpdateCleanup.shared.cleanupTemp(version: update.version)
349
+ try markVerifyFailed(version: update.version, reason: reasonMissingAssetsSha256)
350
+ throw OTAUpdateDownloaderError(code: "OTA_MISSING_ASSETS_SHA256", message: "OTA update \(update.version) includes assetsUrl but is missing required assetsSha256")
351
+ }
352
+
353
+ let tempAssets = tempDir.appendingPathComponent(assetsZipFileName)
354
+ guard fileExistsAndNonEmpty(tempAssets) else {
355
+ try markVerifyFailed(version: update.version, reason: reasonTempAssetsNotFoundForVerify)
356
+ try? OTAUpdateCleanup.shared.cleanupTemp(version: update.version)
357
+ throw OTAUpdateDownloaderError(code: "OTA_TEMP_ASSETS_NOT_FOUND", message: "Downloaded OTA temp assets ZIP was not found at \(tempAssets.path)")
358
+ }
359
+
360
+ let actualAssetsSha256 = try OTAHashUtils.sha256(fileURL: tempAssets)
361
+ guard actualAssetsSha256.caseInsensitiveCompare(expectedAssetsSha256) == .orderedSame else {
362
+ try? OTAUpdateCleanup.shared.cleanupTemp(version: update.version)
363
+ try markVerifyFailed(version: update.version, reason: reasonAssetsSha256Mismatch)
364
+ throw OTAUpdateDownloaderError(code: "OTA_ASSETS_SHA256_MISMATCH", message: "OTA assets SHA-256 verification failed for version \(update.version)")
365
+ }
366
+
367
+ do {
368
+ try OTAZipUtils.validate(zipURL: tempAssets)
369
+ } catch {
370
+ try? OTAUpdateCleanup.shared.cleanupTemp(version: update.version)
371
+ try markVerifyFailed(version: update.version, reason: reasonAssetsZipInvalid)
372
+ if let otaError = error as? OTAUpdateDownloaderError {
373
+ throw otaError
374
+ }
375
+ throw OTAUpdateDownloaderError(code: "OTA_ASSETS_ZIP_INVALID", message: "OTA assets ZIP validation failed for version \(update.version)", underlyingError: error)
376
+ }
377
+ }
378
+
379
+ var verifiedMetadata = try storage.readMetadata()
380
+ verifiedMetadata.status = statusVerified
381
+ verifiedMetadata.failedBundleVersion = nil
382
+ verifiedMetadata.lastFailureReason = nil
383
+ try storage.writeMetadata(verifiedMetadata)
384
+ return true
385
+ }
386
+
387
+ private func verifyUpdateSignature(_ update: OTAUpdatePayload) throws -> Bool {
388
+ guard let signature = update.signature, !signature.isEmpty else {
389
+ try markVerifyFailed(version: update.version, reason: reasonMissingSignature)
390
+ try? OTAUpdateCleanup.shared.cleanupTemp(version: update.version)
391
+ throw OTAUpdateDownloaderError(code: "OTA_MISSING_SIGNATURE", message: "OTA update \(update.version) is missing required signature")
392
+ }
393
+ let canonicalPayload = OTAUpdateSignatureVerifier.canonicalPayload(
394
+ version: update.version,
395
+ platform: update.platform,
396
+ fileName: update.fileName,
397
+ sha256: update.sha256 ?? "",
398
+ assetsSha256: update.assetsSha256
399
+ )
400
+ let isValid: Bool
401
+ do {
402
+ isValid = try OTAUpdateSignatureVerifier.verifySignature(
403
+ canonicalPayload: canonicalPayload,
404
+ base64Signature: signature
405
+ )
406
+ } catch {
407
+ NSLog(
408
+ "OTA signature verification error for version %@. Canonical payload used by iOS:\n%@",
409
+ update.version,
410
+ canonicalPayload
411
+ )
412
+ try? markVerifyFailed(version: update.version, reason: reasonSignatureInvalid)
413
+ try? OTAUpdateCleanup.shared.cleanupTemp(version: update.version)
414
+ throw OTAUpdateDownloaderError(
415
+ code: "OTA_SIGNATURE_INVALID",
416
+ message: signatureMismatchMessage,
417
+ underlyingError: error
418
+ )
419
+ }
420
+
421
+ guard isValid else {
422
+ NSLog(
423
+ "OTA signature mismatch for version %@. Canonical payload used by iOS:\n%@",
424
+ update.version,
425
+ canonicalPayload
426
+ )
427
+ try markVerifyFailed(version: update.version, reason: reasonSignatureInvalid)
428
+ try? OTAUpdateCleanup.shared.cleanupTemp(version: update.version)
429
+ throw OTAUpdateDownloaderError(code: "OTA_SIGNATURE_INVALID", message: signatureMismatchMessage)
430
+ }
431
+ return true
432
+ }
433
+
434
+ private func parsePayload(
435
+ _ updateJson: String,
436
+ requireBundleUrl: Bool = false,
437
+ requireSha256: Bool = false,
438
+ requireSignature: Bool = false,
439
+ requireSigningFields: Bool = false
440
+ ) throws -> OTAUpdatePayload {
441
+ guard let data = updateJson.data(using: .utf8),
442
+ let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
443
+ throw OTAUpdateDownloaderError(code: "OTA_INVALID_UPDATE", message: "updateJson must be valid JSON")
444
+ }
445
+
446
+ let version = try normalizeVersion(stringValue(json["version"]))
447
+ let bundleUrl = stringValue(json["bundleUrl"])?.trimmingCharacters(in: .whitespacesAndNewlines)
448
+ if requireBundleUrl && (bundleUrl?.isEmpty ?? true) {
449
+ throw OTAUpdateDownloaderError(code: "OTA_INVALID_UPDATE", message: "bundleUrl must not be empty")
450
+ }
451
+
452
+ let rawAssetsUrl = stringValue(json["assetsUrl"])
453
+ let assetsUrl = rawAssetsUrl?.trimmingCharacters(in: .whitespacesAndNewlines)
454
+
455
+ let rawPlatform = stringValue(json["platform"])?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
456
+ if requireSigningFields && (rawPlatform?.isEmpty ?? true) {
457
+ throw OTAUpdateDownloaderError(code: "OTA_INVALID_UPDATE", message: "platform must not be empty")
458
+ }
459
+ if let rawPlatform = rawPlatform, !rawPlatform.isEmpty, rawPlatform != platformIOS {
460
+ throw OTAUpdateDownloaderError(code: "OTA_INVALID_UPDATE", message: "platform must be ios")
461
+ }
462
+ let platform = rawPlatform?.isEmpty == false ? rawPlatform! : platformIOS
463
+
464
+ let rawFileName = stringValue(json["fileName"])?.trimmingCharacters(in: .whitespacesAndNewlines)
465
+ if requireSigningFields && (rawFileName?.isEmpty ?? true) {
466
+ throw OTAUpdateDownloaderError(code: "OTA_INVALID_UPDATE", message: "fileName must not be empty")
467
+ }
468
+ if let rawFileName = rawFileName, !rawFileName.isEmpty, rawFileName != bundleFileName {
469
+ throw OTAUpdateDownloaderError(code: "OTA_INVALID_UPDATE", message: "fileName must be \(bundleFileName)")
470
+ }
471
+ let fileName = rawFileName?.isEmpty == false ? rawFileName! : bundleFileName
472
+
473
+ let sha256 = stringValue(json["sha256"])?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
474
+ if requireSha256 && (sha256?.isEmpty ?? true) {
475
+ try? markVerifyFailed(version: version, reason: reasonMissingSha256)
476
+ try? OTAUpdateCleanup.shared.cleanupTemp(version: version)
477
+ throw OTAUpdateDownloaderError(code: "OTA_MISSING_SHA256", message: "OTA update \(version) is missing required sha256")
478
+ }
479
+
480
+ let assetsSha256 = stringValue(json["assetsSha256"])?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
481
+ if requireSha256 && (assetsUrl?.isEmpty == false) && (assetsSha256?.isEmpty ?? true) {
482
+ try? markVerifyFailed(version: version, reason: reasonMissingAssetsSha256)
483
+ try? OTAUpdateCleanup.shared.cleanupTemp(version: version)
484
+ throw OTAUpdateDownloaderError(code: "OTA_MISSING_ASSETS_SHA256", message: "OTA update \(version) includes assetsUrl but is missing required assetsSha256")
485
+ }
486
+
487
+ let signature = stringValue(json["signature"])?.trimmingCharacters(in: .whitespacesAndNewlines)
488
+ if requireSignature && (signature?.isEmpty ?? true) {
489
+ try? markVerifyFailed(version: version, reason: reasonMissingSignature)
490
+ try? OTAUpdateCleanup.shared.cleanupTemp(version: version)
491
+ throw OTAUpdateDownloaderError(code: "OTA_MISSING_SIGNATURE", message: "OTA update \(version) is missing required signature")
492
+ }
493
+
494
+ return OTAUpdatePayload(
495
+ version: version,
496
+ bundleUrl: bundleUrl,
497
+ assetsUrl: assetsUrl,
498
+ platform: platform,
499
+ fileName: fileName,
500
+ sha256: sha256,
501
+ assetsSha256: assetsSha256,
502
+ signature: signature
503
+ )
504
+ }
505
+
506
+ private func validateVersionCanInstall(_ version: String) throws {
507
+ let metadata = try storage.readMetadata()
508
+ if metadata.activeBundleVersion == version || metadata.pendingBundleVersion == version {
509
+ throw OTAUpdateDownloaderError(
510
+ code: "OTA_VERSION_ALREADY_EXISTS",
511
+ message: "OTA version already exists as active or pending. Use a new version and recalculate hashes for every rebuild. version=\(version)"
512
+ )
513
+ }
514
+ }
515
+
516
+ private func downloadToFile(urlString: String, destinationURL: URL, label: String = "OTA bundle") throws {
517
+ guard let url = URL(string: urlString) else {
518
+ throw OTAUpdateDownloaderError(code: "OTA_INVALID_UPDATE", message: "\(label) URL is invalid")
519
+ }
520
+
521
+ let semaphore = DispatchSemaphore(value: 0)
522
+ var result: Result<URL, Error>?
523
+ let configuration = URLSessionConfiguration.ephemeral
524
+ configuration.timeoutIntervalForRequest = 15
525
+ configuration.timeoutIntervalForResource = 60
526
+ let session = URLSession(configuration: configuration)
527
+ let task = session.downloadTask(with: url) { temporaryURL, response, error in
528
+ defer {
529
+ semaphore.signal()
530
+ }
531
+ if let error = error {
532
+ result = .failure(error)
533
+ return
534
+ }
535
+ if let response = response as? HTTPURLResponse, !(200...299).contains(response.statusCode) {
536
+ let code = label == "OTA assets ZIP" ? "OTA_ASSETS_DOWNLOAD_FAILED" : "OTA_DOWNLOAD_FAILED"
537
+ result = .failure(OTAUpdateDownloaderError(code: code, message: "HTTP \(response.statusCode) while downloading \(label)"))
538
+ return
539
+ }
540
+ guard let temporaryURL = temporaryURL else {
541
+ let code = label == "OTA assets ZIP" ? "OTA_ASSETS_DOWNLOAD_FAILED" : "OTA_DOWNLOAD_FAILED"
542
+ result = .failure(OTAUpdateDownloaderError(code: code, message: "URLSession did not produce a temporary file for \(label)"))
543
+ return
544
+ }
545
+ result = .success(temporaryURL)
546
+ }
547
+
548
+ task.resume()
549
+ semaphore.wait()
550
+ session.finishTasksAndInvalidate()
551
+
552
+ switch result {
553
+ case .success(let temporaryURL):
554
+ try fileManager.moveItem(at: temporaryURL, to: destinationURL)
555
+ case .failure(let error):
556
+ if let otaError = error as? OTAUpdateDownloaderError {
557
+ throw otaError
558
+ }
559
+ let code = label == "OTA assets ZIP" ? "OTA_ASSETS_DOWNLOAD_FAILED" : "OTA_DOWNLOAD_FAILED"
560
+ throw OTAUpdateDownloaderError(code: code, message: "Failed to download \(label)", underlyingError: error)
561
+ case .none:
562
+ let code = label == "OTA assets ZIP" ? "OTA_ASSETS_DOWNLOAD_FAILED" : "OTA_DOWNLOAD_FAILED"
563
+ throw OTAUpdateDownloaderError(code: code, message: "\(label) download did not complete")
564
+ }
565
+ }
566
+
567
+ private func tempBundleDirectory(version: String) throws -> URL {
568
+ try otaDirectory()
569
+ .appendingPathComponent("tmp", isDirectory: true)
570
+ .appendingPathComponent(version, isDirectory: true)
571
+ }
572
+
573
+ private func finalBundleDirectory(version: String) throws -> URL {
574
+ try otaDirectory()
575
+ .appendingPathComponent("bundles", isDirectory: true)
576
+ .appendingPathComponent(version, isDirectory: true)
577
+ }
578
+
579
+ private func tempBundleURL(version: String) throws -> URL {
580
+ try tempBundleDirectory(version: version).appendingPathComponent(bundleFileName)
581
+ }
582
+
583
+ private func tempAssetsURL(version: String) throws -> URL {
584
+ try tempBundleDirectory(version: version).appendingPathComponent(assetsZipFileName)
585
+ }
586
+
587
+ private func finalBundleURL(version: String) throws -> URL {
588
+ try finalBundleDirectory(version: version).appendingPathComponent(bundleFileName)
589
+ }
590
+
591
+ private func finalAssetsDirectoryURL(version: String) throws -> URL {
592
+ try finalBundleDirectory(version: version).appendingPathComponent("assets", isDirectory: true)
593
+ }
594
+
595
+ private func otaDirectory() throws -> URL {
596
+ let supportURL = try fileManager.url(
597
+ for: .applicationSupportDirectory,
598
+ in: .userDomainMask,
599
+ appropriateFor: nil,
600
+ create: true
601
+ )
602
+ let otaURL = supportURL.appendingPathComponent("OTA", isDirectory: true)
603
+ try fileManager.createDirectory(at: otaURL, withIntermediateDirectories: true, attributes: nil)
604
+ return otaURL
605
+ }
606
+
607
+ private func markVerifyFailed(version: String, reason: String) throws {
608
+ var metadata = try storage.readMetadata()
609
+ metadata.status = statusVerifyFailed
610
+ metadata.pendingBundleVersion = nil
611
+ metadata.failedBundleVersion = version
612
+ metadata.lastFailureReason = reason
613
+ try storage.writeMetadata(metadata)
614
+ }
615
+
616
+ private func fileExistsAndNonEmpty(_ url: URL) -> Bool {
617
+ fileManager.fileExists(atPath: url.path) && fileSize(url) > 0
618
+ }
619
+
620
+ private func directoryExistsAndHasFiles(_ url: URL) -> Bool {
621
+ guard let enumerator = fileManager.enumerator(at: url, includingPropertiesForKeys: nil) else {
622
+ return false
623
+ }
624
+ return enumerator.nextObject() != nil
625
+ }
626
+
627
+ private func fileSize(_ url: URL) -> UInt64 {
628
+ guard let attributes = try? fileManager.attributesOfItem(atPath: url.path),
629
+ let size = attributes[.size] as? NSNumber else {
630
+ return 0
631
+ }
632
+ return size.uint64Value
633
+ }
634
+
635
+ private func directorySize(_ url: URL) -> UInt64 {
636
+ guard let enumerator = fileManager.enumerator(at: url, includingPropertiesForKeys: [.fileSizeKey, .isRegularFileKey]) else {
637
+ return 0
638
+ }
639
+
640
+ var totalSize: UInt64 = 0
641
+ for case let fileURL as URL in enumerator {
642
+ let values = try? fileURL.resourceValues(forKeys: [.fileSizeKey, .isRegularFileKey])
643
+ if values?.isRegularFile == true, let fileSize = values?.fileSize {
644
+ totalSize += UInt64(fileSize)
645
+ }
646
+ }
647
+ return totalSize
648
+ }
649
+
650
+ private func directoryFileCount(_ url: URL) -> Int {
651
+ guard let enumerator = fileManager.enumerator(at: url, includingPropertiesForKeys: [.isRegularFileKey]) else {
652
+ return 0
653
+ }
654
+
655
+ var fileCount = 0
656
+ for case let fileURL as URL in enumerator {
657
+ let values = try? fileURL.resourceValues(forKeys: [.isRegularFileKey])
658
+ if values?.isRegularFile == true {
659
+ fileCount += 1
660
+ }
661
+ }
662
+ return fileCount
663
+ }
664
+
665
+ private func fileSha256IfExists(_ url: URL) throws -> String? {
666
+ guard fileExistsAndNonEmpty(url) else {
667
+ return nil
668
+ }
669
+ return try OTAHashUtils.sha256(fileURL: url)
670
+ }
671
+
672
+ private func normalizeVersion(_ version: String?) throws -> String {
673
+ let normalizedVersion = version?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
674
+ if normalizedVersion.isEmpty {
675
+ throw OTAUpdateDownloaderError(code: "OTA_INVALID_UPDATE", message: "version must not be empty")
676
+ }
677
+ if normalizedVersion.contains("/") || normalizedVersion.contains("\\") {
678
+ throw OTAUpdateDownloaderError(code: "OTA_INVALID_UPDATE", message: "version must not contain path separators")
679
+ }
680
+ return normalizedVersion
681
+ }
682
+
683
+ private func stringValue(_ value: Any?) -> String? {
684
+ switch value {
685
+ case let value as String:
686
+ return value
687
+ case let value as NSNumber:
688
+ return value.stringValue
689
+ default:
690
+ return nil
691
+ }
692
+ }
693
+
694
+ private func toNSError(_ error: Error) -> NSError {
695
+ if let otaError = error as? OTAUpdateDownloaderError {
696
+ return otaError.nsError
697
+ }
698
+ var userInfo: [String: Any] = [
699
+ NSLocalizedDescriptionKey: error.localizedDescription,
700
+ "otaCode": "OTA_DOWNLOAD_AND_INSTALL_FAILED",
701
+ ]
702
+ userInfo[NSUnderlyingErrorKey] = error as NSError
703
+ return NSError(
704
+ domain: "OTAUpdateDownloader",
705
+ code: 1,
706
+ userInfo: userInfo
707
+ )
708
+ }
709
+ }