@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.
- package/README.md +38 -0
- package/android/build.gradle +48 -0
- package/android/src/main/java/com/viettelpost/otakit/OTAHashUtils.kt +21 -0
- package/android/src/main/java/com/viettelpost/otakit/OTATestReceiver.kt +51 -0
- package/android/src/main/java/com/viettelpost/otakit/OTAUpdateBundleResolver.kt +405 -0
- package/android/src/main/java/com/viettelpost/otakit/OTAUpdateCleanup.kt +186 -0
- package/android/src/main/java/com/viettelpost/otakit/OTAUpdateDownloader.kt +649 -0
- package/android/src/main/java/com/viettelpost/otakit/OTAUpdateMetadata.kt +72 -0
- package/android/src/main/java/com/viettelpost/otakit/OTAUpdateModule.kt +140 -0
- package/android/src/main/java/com/viettelpost/otakit/OTAUpdatePackage.kt +30 -0
- package/android/src/main/java/com/viettelpost/otakit/OTAUpdateSignatureVerifier.kt +63 -0
- package/android/src/main/java/com/viettelpost/otakit/OTAUpdateStorage.kt +62 -0
- package/android/src/main/java/com/viettelpost/otakit/OTAZipUtils.kt +100 -0
- package/android/src/main/res/raw/ota_public_key.pem +9 -0
- package/bin/cli/assets-zip.js +77 -0
- package/bin/cli/bundle.js +72 -0
- package/bin/cli/deploy.js +224 -0
- package/bin/cli/sign.js +97 -0
- package/bin/cli/upload.js +109 -0
- package/bin/ota.js +200 -0
- package/docs/BACKEND_CONTRACT.md +93 -0
- package/docs/DEPLOY_CLI.md +39 -0
- package/docs/INTEGRATION_ANDROID.md +20 -0
- package/docs/INTEGRATION_IOS.md +21 -0
- package/docs/RELEASE_WORKFLOW.md +14 -0
- package/ios/OTAHashUtils.swift +22 -0
- package/ios/OTAUpdateBundleResolver.swift +359 -0
- package/ios/OTAUpdateCleanup.swift +269 -0
- package/ios/OTAUpdateDownloader.swift +709 -0
- package/ios/OTAUpdateMetadata.swift +47 -0
- package/ios/OTAUpdateModule.mm +190 -0
- package/ios/OTAUpdateSignatureVerifier.swift +81 -0
- package/ios/OTAUpdateStorage.swift +83 -0
- package/ios/OTAZipUtils.swift +103 -0
- package/ios/ota_public_key.pem +9 -0
- package/lib/NativeOTAUpdate.d.ts +77 -0
- package/lib/NativeOTAUpdate.js +59 -0
- package/lib/OTAClient.d.ts +27 -0
- package/lib/OTAClient.js +101 -0
- package/lib/config.d.ts +14 -0
- package/lib/config.js +29 -0
- package/lib/devtools.d.ts +10 -0
- package/lib/devtools.js +54 -0
- package/lib/index.d.ts +15 -0
- package/lib/index.js +32 -0
- package/lib/spec/NativeOTAUpdate.d.ts +16 -0
- package/lib/spec/NativeOTAUpdate.js +4 -0
- package/package.json +82 -0
- package/react-native-ota.podspec +21 -0
- package/scripts/run-bin.js +67 -0
- package/src/NativeOTAUpdate.ts +144 -0
- package/src/OTAClient.ts +151 -0
- package/src/config.ts +41 -0
- package/src/devtools.ts +64 -0
- package/src/index.ts +69 -0
- 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¤tVersion=<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
|
+
}
|