@otakit/capacitor-updater 2.2.0 → 2.3.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.
- package/README.md +5 -0
- package/android/src/main/java/com/otakit/updater/BundleStore.java +11 -0
- package/android/src/main/java/com/otakit/updater/DeltaAssembler.java +416 -0
- package/android/src/main/java/com/otakit/updater/HashUtils.java +16 -0
- package/android/src/main/java/com/otakit/updater/ManifestClient.java +69 -4
- package/android/src/main/java/com/otakit/updater/UpdaterPlugin.java +180 -1
- package/dist/esm/definitions.d.ts +6 -4
- package/dist/esm/definitions.d.ts.map +1 -1
- package/ios/Sources/UpdaterPlugin/BundleStore.swift +17 -0
- package/ios/Sources/UpdaterPlugin/DeltaAssembler.swift +323 -0
- package/ios/Sources/UpdaterPlugin/ManifestClient.swift +54 -6
- package/ios/Sources/UpdaterPlugin/UpdaterPlugin.swift +166 -2
- package/package.json +1 -1
|
@@ -11,9 +11,18 @@ struct ManifestEncryption {
|
|
|
11
11
|
let nonce: String
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
struct ManifestFileEntry {
|
|
15
|
+
let path: String
|
|
16
|
+
let sha256: String
|
|
17
|
+
let size: Int?
|
|
18
|
+
let url: String
|
|
19
|
+
}
|
|
20
|
+
|
|
14
21
|
struct LatestManifest {
|
|
15
22
|
let version: String
|
|
16
|
-
|
|
23
|
+
/// Bundle zip URL. Present for the zip strategy; nil for deltas.
|
|
24
|
+
let url: String?
|
|
25
|
+
/// Zip hash for the zip strategy; canonical filesHash for deltas.
|
|
17
26
|
let sha256: String
|
|
18
27
|
let size: Int
|
|
19
28
|
let runtimeVersion: String?
|
|
@@ -21,6 +30,8 @@ struct LatestManifest {
|
|
|
21
30
|
let strategy: String
|
|
22
31
|
let forceImmediate: Bool
|
|
23
32
|
let encryption: ManifestEncryption?
|
|
33
|
+
/// Per-file entries for the deltas strategy; nil for zip.
|
|
34
|
+
let files: [ManifestFileEntry]?
|
|
24
35
|
}
|
|
25
36
|
|
|
26
37
|
struct ManifestSignature {
|
|
@@ -105,7 +116,6 @@ enum ManifestClient {
|
|
|
105
116
|
guard
|
|
106
117
|
let object = try JSONSerialization.jsonObject(with: data) as? [String: Any],
|
|
107
118
|
let version = object["version"] as? String,
|
|
108
|
-
let downloadUrl = object["url"] as? String,
|
|
109
119
|
let sha256 = object["sha256"] as? String,
|
|
110
120
|
let size = object["size"] as? Int
|
|
111
121
|
else {
|
|
@@ -129,10 +139,17 @@ enum ManifestClient {
|
|
|
129
139
|
let forceImmediate = object["forceImmediate"] as? Bool ?? false
|
|
130
140
|
let encryption = try parseEncryption(object["encryption"])
|
|
131
141
|
|
|
132
|
-
|
|
133
|
-
|
|
142
|
+
let downloadUrl = (object["url"] as? String)?.nilIfEmpty
|
|
143
|
+
var files: [ManifestFileEntry]?
|
|
144
|
+
|
|
145
|
+
if strategy == "deltas" {
|
|
146
|
+
files = try parseFiles(object["files"], allowInsecureUrls: allowInsecureUrls)
|
|
147
|
+
} else {
|
|
148
|
+
guard let downloadUrl, let dlURL = URL(string: downloadUrl) else {
|
|
149
|
+
throw ManifestClientError.invalidResponse
|
|
150
|
+
}
|
|
151
|
+
try requireHTTPS(url: dlURL, allowInsecure: allowInsecureUrls)
|
|
134
152
|
}
|
|
135
|
-
try requireHTTPS(url: dlURL, allowInsecure: allowInsecureUrls)
|
|
136
153
|
|
|
137
154
|
if manifestKeys.isEmpty {
|
|
138
155
|
print("[OtaKit] WARNING: No manifest signing keys configured — signature verification is disabled for this request.")
|
|
@@ -167,10 +184,41 @@ enum ManifestClient {
|
|
|
167
184
|
releaseId: releaseId,
|
|
168
185
|
strategy: strategy,
|
|
169
186
|
forceImmediate: forceImmediate,
|
|
170
|
-
encryption: encryption
|
|
187
|
+
encryption: encryption,
|
|
188
|
+
files: files
|
|
171
189
|
)
|
|
172
190
|
}
|
|
173
191
|
|
|
192
|
+
private static func parseFiles(
|
|
193
|
+
_ rawValue: Any?,
|
|
194
|
+
allowInsecureUrls: Bool
|
|
195
|
+
) throws -> [ManifestFileEntry] {
|
|
196
|
+
guard let rawFiles = rawValue as? [[String: Any]], !rawFiles.isEmpty else {
|
|
197
|
+
throw ManifestClientError.invalidResponse
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
var entries: [ManifestFileEntry] = []
|
|
201
|
+
entries.reserveCapacity(rawFiles.count)
|
|
202
|
+
for rawFile in rawFiles {
|
|
203
|
+
guard let path = rawFile["path"] as? String,
|
|
204
|
+
let sha256 = rawFile["sha256"] as? String,
|
|
205
|
+
let fileUrl = rawFile["url"] as? String,
|
|
206
|
+
let parsedUrl = URL(string: fileUrl) else {
|
|
207
|
+
throw ManifestClientError.invalidResponse
|
|
208
|
+
}
|
|
209
|
+
try requireHTTPS(url: parsedUrl, allowInsecure: allowInsecureUrls)
|
|
210
|
+
entries.append(
|
|
211
|
+
ManifestFileEntry(
|
|
212
|
+
path: path,
|
|
213
|
+
sha256: sha256,
|
|
214
|
+
size: rawFile["size"] as? Int,
|
|
215
|
+
url: fileUrl
|
|
216
|
+
)
|
|
217
|
+
)
|
|
218
|
+
}
|
|
219
|
+
return entries
|
|
220
|
+
}
|
|
221
|
+
|
|
174
222
|
private static func parseEncryption(_ rawValue: Any?) throws -> ManifestEncryption? {
|
|
175
223
|
guard let rawValue, !(rawValue is NSNull) else {
|
|
176
224
|
return nil
|
|
@@ -1040,10 +1040,13 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
1040
1040
|
) -> [String: Any] {
|
|
1041
1041
|
var payload: [String: Any] = [
|
|
1042
1042
|
"version": latest.version,
|
|
1043
|
-
"url": latest.url,
|
|
1044
1043
|
"sha256": latest.sha256,
|
|
1045
1044
|
"size": latest.size,
|
|
1045
|
+
"strategy": latest.strategy,
|
|
1046
1046
|
]
|
|
1047
|
+
if let url = latest.url {
|
|
1048
|
+
payload["url"] = url
|
|
1049
|
+
}
|
|
1047
1050
|
if let runtimeVersion = latest.runtimeVersion {
|
|
1048
1051
|
payload["runtimeVersion"] = runtimeVersion
|
|
1049
1052
|
}
|
|
@@ -1056,7 +1059,11 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
1056
1059
|
_ manifest: LatestManifest,
|
|
1057
1060
|
targetChannel: String?
|
|
1058
1061
|
) async throws -> BundleInfo {
|
|
1059
|
-
|
|
1062
|
+
if manifest.strategy == "deltas" {
|
|
1063
|
+
return try await assembleAndStage(manifest: manifest, targetChannel: targetChannel)
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
guard let urlString = manifest.url, let url = URL(string: urlString) else {
|
|
1060
1067
|
throw NSError(
|
|
1061
1068
|
domain: "OtaKit",
|
|
1062
1069
|
code: 1,
|
|
@@ -1076,6 +1083,163 @@ public class UpdaterPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
1076
1083
|
)
|
|
1077
1084
|
}
|
|
1078
1085
|
|
|
1086
|
+
private static let bundleFileListName = "otakit_files.json"
|
|
1087
|
+
|
|
1088
|
+
/// Deltas strategy: fill content-cache misses and assemble the bundle from
|
|
1089
|
+
/// the cache, then hand it to the same staging path the zip flow uses.
|
|
1090
|
+
private func assembleAndStage(
|
|
1091
|
+
manifest: LatestManifest,
|
|
1092
|
+
targetChannel: String?
|
|
1093
|
+
) async throws -> BundleInfo {
|
|
1094
|
+
// Same conservative disk-space guard as the zip path.
|
|
1095
|
+
let requiredSpace = Int64(Double(manifest.size) * 2.5)
|
|
1096
|
+
if getFreeDiskSpace() < requiredSpace {
|
|
1097
|
+
sendDeviceEvent(
|
|
1098
|
+
action: .downloadError,
|
|
1099
|
+
bundleVersion: manifest.version,
|
|
1100
|
+
runtimeVersion: manifest.runtimeVersion,
|
|
1101
|
+
channel: targetChannel,
|
|
1102
|
+
releaseId: manifest.releaseId,
|
|
1103
|
+
detail: "insufficient_disk_space"
|
|
1104
|
+
)
|
|
1105
|
+
emitEvent(
|
|
1106
|
+
"downloadFailed",
|
|
1107
|
+
failureEventData(
|
|
1108
|
+
version: manifest.version,
|
|
1109
|
+
runtimeVersion: manifest.runtimeVersion,
|
|
1110
|
+
channel: targetChannel,
|
|
1111
|
+
releaseId: manifest.releaseId,
|
|
1112
|
+
reason: "insufficient_disk_space"
|
|
1113
|
+
)
|
|
1114
|
+
)
|
|
1115
|
+
throw NSError(
|
|
1116
|
+
domain: "OtaKit",
|
|
1117
|
+
code: 1,
|
|
1118
|
+
userInfo: [NSLocalizedDescriptionKey: "Insufficient disk space"]
|
|
1119
|
+
)
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
let assembleDirectory = fileManager.temporaryDirectory
|
|
1123
|
+
.appendingPathComponent("otakit-assemble-\(UUID().uuidString)", isDirectory: true)
|
|
1124
|
+
defer {
|
|
1125
|
+
try? fileManager.removeItem(at: assembleDirectory)
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
do {
|
|
1129
|
+
guard let files = manifest.files, !files.isEmpty else {
|
|
1130
|
+
throw NSError(
|
|
1131
|
+
domain: "OtaKit",
|
|
1132
|
+
code: 1,
|
|
1133
|
+
userInfo: [NSLocalizedDescriptionKey: "Delta manifest is missing its file list"]
|
|
1134
|
+
)
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
let assembler = DeltaAssembler(
|
|
1138
|
+
cacheDirectory: store.filesCacheDirectory,
|
|
1139
|
+
downloader: downloader
|
|
1140
|
+
)
|
|
1141
|
+
try assembler.validate(files, expectedFilesHash: manifest.sha256)
|
|
1142
|
+
|
|
1143
|
+
if let builtinDirectory = Bundle.main.resourceURL?
|
|
1144
|
+
.appendingPathComponent("public", isDirectory: true) {
|
|
1145
|
+
assembler.seedFromBuiltinIfNeeded(
|
|
1146
|
+
builtinDirectory: builtinDirectory,
|
|
1147
|
+
nativeBuild: coordinator.nativeBuild
|
|
1148
|
+
)
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
try await assembler.assemble(entries: files, into: assembleDirectory)
|
|
1152
|
+
|
|
1153
|
+
let bundleId = buildBundleId(
|
|
1154
|
+
version: manifest.version,
|
|
1155
|
+
releaseId: manifest.releaseId,
|
|
1156
|
+
sha256: manifest.sha256
|
|
1157
|
+
)
|
|
1158
|
+
let destination = coordinator.bundleDirectory(for: bundleId)
|
|
1159
|
+
if fileManager.fileExists(atPath: destination.path) {
|
|
1160
|
+
try fileManager.removeItem(at: destination)
|
|
1161
|
+
}
|
|
1162
|
+
try fileManager.moveItem(at: assembleDirectory, to: destination)
|
|
1163
|
+
|
|
1164
|
+
// Record this bundle's content hashes for cache pruning.
|
|
1165
|
+
let hashes = files.map { $0.sha256.lowercased() }
|
|
1166
|
+
if let data = try? JSONSerialization.data(withJSONObject: hashes) {
|
|
1167
|
+
try? data.write(
|
|
1168
|
+
to: destination.appendingPathComponent(UpdaterPlugin.bundleFileListName),
|
|
1169
|
+
options: .atomic
|
|
1170
|
+
)
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
let info = BundleInfo(
|
|
1174
|
+
id: bundleId,
|
|
1175
|
+
version: manifest.version,
|
|
1176
|
+
runtimeVersion: manifest.runtimeVersion,
|
|
1177
|
+
status: .pending,
|
|
1178
|
+
downloadedAt: Date(),
|
|
1179
|
+
sha256: manifest.sha256,
|
|
1180
|
+
path: destination.path,
|
|
1181
|
+
channel: targetChannel,
|
|
1182
|
+
releaseId: manifest.releaseId
|
|
1183
|
+
)
|
|
1184
|
+
|
|
1185
|
+
let cleanupBundleIds = try coordinator.stageDownloadedBundle(info)
|
|
1186
|
+
coordinator.cleanupBundles(cleanupBundleIds)
|
|
1187
|
+
|
|
1188
|
+
pruneDeltaCache(assembler: assembler)
|
|
1189
|
+
|
|
1190
|
+
sendDeviceEvent(
|
|
1191
|
+
action: .downloaded,
|
|
1192
|
+
bundleVersion: manifest.version,
|
|
1193
|
+
runtimeVersion: manifest.runtimeVersion,
|
|
1194
|
+
channel: targetChannel,
|
|
1195
|
+
releaseId: manifest.releaseId
|
|
1196
|
+
)
|
|
1197
|
+
emitEvent("updateStaged", ["bundle": info.toDictionary()])
|
|
1198
|
+
return info
|
|
1199
|
+
} catch {
|
|
1200
|
+
sendDeviceEvent(
|
|
1201
|
+
action: .downloadError,
|
|
1202
|
+
bundleVersion: manifest.version,
|
|
1203
|
+
runtimeVersion: manifest.runtimeVersion,
|
|
1204
|
+
channel: targetChannel,
|
|
1205
|
+
releaseId: manifest.releaseId,
|
|
1206
|
+
detail: error.localizedDescription
|
|
1207
|
+
)
|
|
1208
|
+
emitEvent(
|
|
1209
|
+
"downloadFailed",
|
|
1210
|
+
failureEventData(
|
|
1211
|
+
version: manifest.version,
|
|
1212
|
+
runtimeVersion: manifest.runtimeVersion,
|
|
1213
|
+
channel: targetChannel,
|
|
1214
|
+
releaseId: manifest.releaseId,
|
|
1215
|
+
reason: failureReason(from: error)
|
|
1216
|
+
)
|
|
1217
|
+
)
|
|
1218
|
+
throw error
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
/// Keep only cache entries referenced by live bundles (plus the builtin seed).
|
|
1223
|
+
private func pruneDeltaCache(assembler: DeltaAssembler) {
|
|
1224
|
+
var referenced = Set<String>()
|
|
1225
|
+
let liveBundleIds = [
|
|
1226
|
+
store.getCurrentBundleId(),
|
|
1227
|
+
store.getFallbackBundleId(),
|
|
1228
|
+
store.getStagedBundleId(),
|
|
1229
|
+
]
|
|
1230
|
+
for bundleId in liveBundleIds {
|
|
1231
|
+
guard let bundleId else { continue }
|
|
1232
|
+
let listURL = store.bundleDirectory(for: bundleId)
|
|
1233
|
+
.appendingPathComponent(UpdaterPlugin.bundleFileListName)
|
|
1234
|
+
guard let data = try? Data(contentsOf: listURL),
|
|
1235
|
+
let hashes = try? JSONSerialization.jsonObject(with: data) as? [String] else {
|
|
1236
|
+
continue
|
|
1237
|
+
}
|
|
1238
|
+
referenced.formUnion(hashes.map { $0.lowercased() })
|
|
1239
|
+
}
|
|
1240
|
+
assembler.pruneCache(referencedHashes: referenced)
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1079
1243
|
private func pruneIncompatibleBundles() {
|
|
1080
1244
|
let cleanupBundleIds = coordinator.pruneIncompatibleBundles(
|
|
1081
1245
|
isCompatibleRuntime: isCompatibleRuntime
|