@otakit/capacitor-updater 2.2.0 → 2.3.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.
@@ -1124,9 +1124,12 @@ public class UpdaterPlugin extends Plugin {
1124
1124
  private JSObject manifestToJSObject(ManifestClient.LatestManifest latest) {
1125
1125
  JSObject object = new JSObject();
1126
1126
  object.put("version", latest.version);
1127
- object.put("url", latest.url);
1127
+ if (latest.url != null) {
1128
+ object.put("url", latest.url);
1129
+ }
1128
1130
  object.put("sha256", latest.sha256);
1129
1131
  object.put("size", latest.size);
1132
+ object.put("strategy", latest.strategy);
1130
1133
  if (latest.runtimeVersion != null) {
1131
1134
  object.put("runtimeVersion", latest.runtimeVersion);
1132
1135
  }
@@ -1139,6 +1142,14 @@ public class UpdaterPlugin extends Plugin {
1139
1142
  ManifestClient.LatestManifest latest,
1140
1143
  String targetChannel
1141
1144
  ) throws Exception {
1145
+ if ("deltas".equals(latest.strategy)) {
1146
+ return assembleAndStage(latest, targetChannel);
1147
+ }
1148
+
1149
+ if (latest.url == null) {
1150
+ throw new IllegalStateException("Invalid download URL from manifest");
1151
+ }
1152
+
1142
1153
  return downloadAndStage(
1143
1154
  new URL(latest.url),
1144
1155
  latest.version,
@@ -1151,6 +1162,174 @@ public class UpdaterPlugin extends Plugin {
1151
1162
  );
1152
1163
  }
1153
1164
 
1165
+ private static final String BUNDLE_FILE_LIST_NAME = "otakit_files.json";
1166
+
1167
+ /**
1168
+ * Deltas strategy: fill content-cache misses and assemble the bundle from
1169
+ * the cache, then hand it to the same staging path the zip flow uses.
1170
+ */
1171
+ private BundleInfo assembleAndStage(ManifestClient.LatestManifest manifest, String targetChannel)
1172
+ throws Exception {
1173
+ // Same conservative disk-space guard as the zip path.
1174
+ if (manifest.size > 0) {
1175
+ long requiredSpace = (long) (manifest.size * 2.5);
1176
+ if (getFreeDiskSpace() < requiredSpace) {
1177
+ sendDeviceEvent(
1178
+ "download_error",
1179
+ manifest.version,
1180
+ manifest.runtimeVersion,
1181
+ targetChannel,
1182
+ manifest.releaseId,
1183
+ "insufficient_disk_space"
1184
+ );
1185
+ emitEvent(
1186
+ "downloadFailed",
1187
+ failureEventData(
1188
+ manifest.version,
1189
+ manifest.runtimeVersion,
1190
+ targetChannel,
1191
+ manifest.releaseId,
1192
+ "insufficient_disk_space"
1193
+ )
1194
+ );
1195
+ throw new IllegalStateException("Insufficient disk space");
1196
+ }
1197
+ }
1198
+
1199
+ File assembleDirectory = new File(
1200
+ getContext().getCacheDir(),
1201
+ "otakit-assemble-" + System.currentTimeMillis()
1202
+ );
1203
+
1204
+ try {
1205
+ if (manifest.files == null || manifest.files.isEmpty()) {
1206
+ throw new IllegalStateException("Delta manifest is missing its file list");
1207
+ }
1208
+
1209
+ DeltaAssembler assembler = new DeltaAssembler(
1210
+ store.getFilesCacheDirectory(),
1211
+ allowInsecureUrls
1212
+ );
1213
+ assembler.validate(manifest.files, manifest.sha256);
1214
+ assembler.seedFromBuiltinIfNeeded(getContext(), BUILTIN_ASSET_PATH, store.getNativeBuild());
1215
+ assembler.assemble(manifest.files, assembleDirectory, getContext());
1216
+
1217
+ String bundleId = buildBundleId(manifest.version, manifest.releaseId, manifest.sha256);
1218
+ File destination = coordinator.bundleDirectory(bundleId);
1219
+ if (destination.exists()) {
1220
+ deleteRecursively(destination);
1221
+ }
1222
+ moveDirectory(assembleDirectory, destination);
1223
+
1224
+ // Record this bundle's content hashes for cache pruning.
1225
+ try {
1226
+ org.json.JSONArray hashes = new org.json.JSONArray();
1227
+ for (ManifestClient.ManifestFileEntry entry : manifest.files) {
1228
+ hashes.put(entry.sha256.toLowerCase());
1229
+ }
1230
+ try (
1231
+ FileOutputStream output = new FileOutputStream(
1232
+ new File(destination, BUNDLE_FILE_LIST_NAME)
1233
+ )
1234
+ ) {
1235
+ output.write(hashes.toString().getBytes(StandardCharsets.UTF_8));
1236
+ }
1237
+ } catch (Exception ignored) {}
1238
+
1239
+ BundleInfo info = new BundleInfo(
1240
+ bundleId,
1241
+ manifest.version,
1242
+ manifest.runtimeVersion,
1243
+ BundleStatus.PENDING,
1244
+ System.currentTimeMillis(),
1245
+ manifest.sha256,
1246
+ destination.getAbsolutePath(),
1247
+ targetChannel,
1248
+ manifest.releaseId
1249
+ );
1250
+ java.util.List<String> cleanupBundleIds = coordinator.stageDownloadedBundle(info);
1251
+ coordinator.cleanupBundles(cleanupBundleIds);
1252
+
1253
+ pruneDeltaCache(assembler);
1254
+
1255
+ sendDeviceEvent(
1256
+ "downloaded",
1257
+ manifest.version,
1258
+ manifest.runtimeVersion,
1259
+ targetChannel,
1260
+ manifest.releaseId,
1261
+ null
1262
+ );
1263
+ JSObject stagedData = new JSObject();
1264
+ stagedData.put("bundle", info.toJSObject());
1265
+ emitEvent("updateStaged", stagedData);
1266
+ return info;
1267
+ } catch (Exception e) {
1268
+ sendDeviceEvent(
1269
+ "download_error",
1270
+ manifest.version,
1271
+ manifest.runtimeVersion,
1272
+ targetChannel,
1273
+ manifest.releaseId,
1274
+ e.getMessage()
1275
+ );
1276
+ emitEvent(
1277
+ "downloadFailed",
1278
+ failureEventData(
1279
+ manifest.version,
1280
+ manifest.runtimeVersion,
1281
+ targetChannel,
1282
+ manifest.releaseId,
1283
+ failureReason(e)
1284
+ )
1285
+ );
1286
+ throw e;
1287
+ } finally {
1288
+ if (assembleDirectory.exists()) {
1289
+ try {
1290
+ deleteRecursively(assembleDirectory);
1291
+ } catch (Exception ignored) {}
1292
+ }
1293
+ }
1294
+ }
1295
+
1296
+ /** Keep only cache entries referenced by live bundles (plus the builtin seed). */
1297
+ private void pruneDeltaCache(DeltaAssembler assembler) {
1298
+ java.util.Set<String> referenced = new java.util.HashSet<>();
1299
+ String[] liveBundleIds = new String[] {
1300
+ store.getCurrentBundleId(),
1301
+ store.getFallbackBundleId(),
1302
+ store.getStagedBundleId(),
1303
+ };
1304
+ for (String bundleId : liveBundleIds) {
1305
+ if (bundleId == null) {
1306
+ continue;
1307
+ }
1308
+ File listFile = new File(store.bundleDirectory(bundleId), BUNDLE_FILE_LIST_NAME);
1309
+ if (!listFile.exists()) {
1310
+ continue;
1311
+ }
1312
+ try (FileInputStream input = new FileInputStream(listFile)) {
1313
+ java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream();
1314
+ byte[] buffer = new byte[8192];
1315
+ int read;
1316
+ while ((read = input.read(buffer)) > 0) {
1317
+ out.write(buffer, 0, read);
1318
+ }
1319
+ org.json.JSONArray hashes = new org.json.JSONArray(
1320
+ new String(out.toByteArray(), StandardCharsets.UTF_8)
1321
+ );
1322
+ for (int index = 0; index < hashes.length(); index++) {
1323
+ String hash = hashes.optString(index, null);
1324
+ if (hash != null) {
1325
+ referenced.add(hash.toLowerCase());
1326
+ }
1327
+ }
1328
+ } catch (Exception ignored) {}
1329
+ }
1330
+ assembler.pruneCache(referenced);
1331
+ }
1332
+
1154
1333
  private void moveDirectory(File source, File destination) throws Exception {
1155
1334
  if (source.renameTo(destination)) {
1156
1335
  return;
@@ -37,14 +37,16 @@ export interface LatestVersion {
37
37
  version: string;
38
38
  /** Native compatibility lane for this update. */
39
39
  runtimeVersion?: string;
40
- /** Download URL */
41
- url: string;
42
- /** SHA-256 checksum */
40
+ /** Bundle download URL. Present for the 'zip' strategy; absent for 'deltas'. */
41
+ url?: string;
42
+ /** SHA-256 checksum: the zip hash for 'zip', the canonical filesHash for 'deltas'. */
43
43
  sha256: string;
44
- /** Bundle size in bytes */
44
+ /** Bundle size in bytes (total decompressed size for 'deltas') */
45
45
  size: number;
46
46
  /** Release history ID associated with this manifest */
47
47
  releaseId: string;
48
+ /** Update strategy this manifest was published with. Defaults to 'zip'. */
49
+ strategy?: 'zip' | 'deltas';
48
50
  /**
49
51
  * True when the release is marked force-immediate: automatic flows apply
50
52
  * and reload it on the next lifecycle event regardless of shadow or
@@ -1 +1 @@
1
- {"version":3,"file":"definitions.d.ts","sourceRoot":"","sources":["../../src/definitions.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"definitions.d.ts","sourceRoot":"","sources":["../../src/definitions.ts"],"names":[],"mappings":"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"}
@@ -42,6 +42,23 @@ final class BundleStore {
42
42
  return directory
43
43
  }()
44
44
 
45
+ /// Content-addressed file cache for the deltas strategy (`otakit_files/<sha256>`).
46
+ private(set) lazy var filesCacheDirectory: URL = {
47
+ let appSupport = fileManager.urls(
48
+ for: .applicationSupportDirectory,
49
+ in: .userDomainMask
50
+ ).first!
51
+ let directory = appSupport.appendingPathComponent(
52
+ "otakit_files",
53
+ isDirectory: true
54
+ )
55
+ try? fileManager.createDirectory(
56
+ at: directory,
57
+ withIntermediateDirectories: true
58
+ )
59
+ return directory
60
+ }()
61
+
45
62
  var builtinVersion: String {
46
63
  Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0.0.0"
47
64
  }
@@ -0,0 +1,316 @@
1
+ import CryptoKit
2
+ import Foundation
3
+
4
+ enum DeltaAssemblerError: Error, LocalizedError {
5
+ case missingFiles
6
+ case invalidPath(String)
7
+ case duplicatePath(String)
8
+ case fileCountExceeded(Int)
9
+ case totalSizeExceeded(UInt64)
10
+ case filesHashMismatch
11
+ case fileHashMismatch(String)
12
+ case missingIndexHtml
13
+ case invalidFileURL(String)
14
+
15
+ var errorDescription: String? {
16
+ switch self {
17
+ case .missingFiles:
18
+ return "Delta manifest has no files"
19
+ case let .invalidPath(path):
20
+ return "Invalid file path in delta manifest: \(path)"
21
+ case let .duplicatePath(path):
22
+ return "Duplicate file path in delta manifest: \(path)"
23
+ case let .fileCountExceeded(count):
24
+ return "Delta manifest exceeds file count limit: \(count)"
25
+ case let .totalSizeExceeded(size):
26
+ return "Delta manifest exceeds total size limit: \(size)"
27
+ case .filesHashMismatch:
28
+ return "Delta file list does not match the signed filesHash"
29
+ case let .fileHashMismatch(path):
30
+ return "Downloaded file hash mismatch: \(path)"
31
+ case .missingIndexHtml:
32
+ return "Delta bundle does not contain index.html"
33
+ case let .invalidFileURL(url):
34
+ return "Invalid file download URL: \(url)"
35
+ }
36
+ }
37
+ }
38
+
39
+ /// Assembles a delta-strategy bundle from per-file content-addressed objects.
40
+ ///
41
+ /// The content cache (`otakit_files/<sha256>`) is the device-side state:
42
+ /// previous bundles and the builtin seed populate it, and assembling a new
43
+ /// bundle downloads only the cache misses.
44
+ final class DeltaAssembler {
45
+ // Mirror ZipUtils' extraction limits.
46
+ private let maxFiles = 10_000
47
+ private let maxTotalSize: UInt64 = 500_000_000 // 500 MB
48
+
49
+ private let cacheDirectory: URL
50
+ private let downloader: Downloader
51
+ private let fileManager = FileManager.default
52
+
53
+ private static let builtinSeedMarkerName = "builtin_seed.json"
54
+
55
+ init(cacheDirectory: URL, downloader: Downloader) {
56
+ self.cacheDirectory = cacheDirectory
57
+ self.downloader = downloader
58
+ }
59
+
60
+ // MARK: - Canonical file list
61
+
62
+ /// Canonical file list hash — must match the server's computeFilesHash
63
+ /// (console/lib/delta-files.ts) and the Android mirror byte-for-byte:
64
+ /// entries sorted by UTF-8 bytes of path, lines "<path>:<sha256 lowercase>",
65
+ /// joined with "\n", hashed with SHA-256 (hex).
66
+ static func computeFilesHash(_ entries: [ManifestFileEntry]) -> String {
67
+ let sorted = entries.sorted { lhs, rhs in
68
+ let lhsBytes = Array(lhs.path.utf8)
69
+ let rhsBytes = Array(rhs.path.utf8)
70
+ for index in 0..<min(lhsBytes.count, rhsBytes.count) {
71
+ if lhsBytes[index] != rhsBytes[index] {
72
+ return lhsBytes[index] < rhsBytes[index]
73
+ }
74
+ }
75
+ return lhsBytes.count < rhsBytes.count
76
+ }
77
+ let canonical = sorted
78
+ .map { "\($0.path):\($0.sha256.lowercased())" }
79
+ .joined(separator: "\n")
80
+ let digest = SHA256.hash(data: Data(canonical.utf8))
81
+ return digest.map { String(format: "%02x", $0) }.joined()
82
+ }
83
+
84
+ // MARK: - Validation
85
+
86
+ private func isValidEntryPath(_ path: String) -> Bool {
87
+ if path.isEmpty || path.count > 512 {
88
+ return false
89
+ }
90
+ if path.hasPrefix("/") || path.contains("\\") {
91
+ return false
92
+ }
93
+ for segment in path.split(separator: "/", omittingEmptySubsequences: false) {
94
+ if segment.isEmpty || segment == "." || segment == ".." {
95
+ return false
96
+ }
97
+ }
98
+ for scalar in path.unicodeScalars {
99
+ if scalar.value < 0x20 || scalar.value == 0x7f {
100
+ return false
101
+ }
102
+ }
103
+ // Metadata files the plugin writes into the bundle directory; an app
104
+ // file with the same root-level name would be overwritten.
105
+ if path == "bundle.json" || path == "otakit_files.json" {
106
+ return false
107
+ }
108
+ return true
109
+ }
110
+
111
+ func validate(_ entries: [ManifestFileEntry], expectedFilesHash: String) throws {
112
+ guard !entries.isEmpty else {
113
+ throw DeltaAssemblerError.missingFiles
114
+ }
115
+ guard entries.count <= maxFiles else {
116
+ throw DeltaAssemblerError.fileCountExceeded(entries.count)
117
+ }
118
+
119
+ // Key on UTF-8 bytes, not String: Swift compares canonically-equivalent
120
+ // strings (NFC vs NFD) as equal, which would reject a manifest the
121
+ // server and Android both accept.
122
+ var seenPaths = Set<Data>()
123
+ var totalSize: UInt64 = 0
124
+ for entry in entries {
125
+ guard isValidEntryPath(entry.path) else {
126
+ throw DeltaAssemblerError.invalidPath(entry.path)
127
+ }
128
+ guard seenPaths.insert(Data(entry.path.utf8)).inserted else {
129
+ throw DeltaAssemblerError.duplicatePath(entry.path)
130
+ }
131
+ if let size = entry.size, size > 0 {
132
+ totalSize += UInt64(size)
133
+ if totalSize > maxTotalSize {
134
+ throw DeltaAssemblerError.totalSizeExceeded(totalSize)
135
+ }
136
+ }
137
+ }
138
+
139
+ guard seenPaths.contains(Data("index.html".utf8)) else {
140
+ throw DeltaAssemblerError.missingIndexHtml
141
+ }
142
+
143
+ // The signed manifest sha256 is the filesHash; recomputing it here is what
144
+ // extends signature coverage to every (path, sha256) pair.
145
+ guard DeltaAssembler.computeFilesHash(entries) == expectedFilesHash.lowercased() else {
146
+ throw DeltaAssemblerError.filesHashMismatch
147
+ }
148
+ }
149
+
150
+ // MARK: - Cache
151
+
152
+ private func cachePath(for sha256: String) -> URL {
153
+ cacheDirectory.appendingPathComponent(sha256.lowercased(), isDirectory: false)
154
+ }
155
+
156
+ private func isCached(_ sha256: String) -> Bool {
157
+ fileManager.fileExists(atPath: cachePath(for: sha256).path)
158
+ }
159
+
160
+ private func ensureCached(_ entry: ManifestFileEntry) async throws {
161
+ if isCached(entry.sha256) {
162
+ return
163
+ }
164
+
165
+ guard let url = URL(string: entry.url) else {
166
+ throw DeltaAssemblerError.invalidFileURL(entry.url)
167
+ }
168
+
169
+ let temporary = try await downloader.download(from: url)
170
+ defer { try? fileManager.removeItem(at: temporary) }
171
+
172
+ guard try HashUtils.verify(fileURL: temporary, expectedSha256: entry.sha256) else {
173
+ throw DeltaAssemblerError.fileHashMismatch(entry.path)
174
+ }
175
+
176
+ let destination = cachePath(for: entry.sha256)
177
+ if fileManager.fileExists(atPath: destination.path) {
178
+ return
179
+ }
180
+ // Write via temp + rename so a crash mid-copy can never leave a
181
+ // truncated file at a content-addressed path (exists() implies valid).
182
+ let staging = cacheDirectory.appendingPathComponent(
183
+ ".tmp-\(UUID().uuidString)",
184
+ isDirectory: false
185
+ )
186
+ try fileManager.copyItem(at: temporary, to: staging)
187
+ do {
188
+ try fileManager.moveItem(at: staging, to: destination)
189
+ } catch {
190
+ try? fileManager.removeItem(at: staging)
191
+ // A concurrent writer may have won the rename; that's fine.
192
+ if !fileManager.fileExists(atPath: destination.path) {
193
+ throw error
194
+ }
195
+ }
196
+ }
197
+
198
+ // MARK: - Assembly
199
+
200
+ /// Fill cache misses and lay out the bundle directory from the cache.
201
+ /// `destination` must be an empty/absent directory; entries must be
202
+ /// validated first.
203
+ func assemble(entries: [ManifestFileEntry], into destination: URL) async throws {
204
+ if fileManager.fileExists(atPath: destination.path) {
205
+ try fileManager.removeItem(at: destination)
206
+ }
207
+ try fileManager.createDirectory(at: destination, withIntermediateDirectories: true)
208
+
209
+ let destinationPrefix = destination.standardizedFileURL.path.hasSuffix("/")
210
+ ? destination.standardizedFileURL.path
211
+ : destination.standardizedFileURL.path + "/"
212
+
213
+ for entry in entries {
214
+ try await ensureCached(entry)
215
+
216
+ let target = destination.appendingPathComponent(entry.path, isDirectory: false)
217
+ // Defense in depth alongside isValidEntryPath (mirrors ZipUtils).
218
+ guard target.standardizedFileURL.path.hasPrefix(destinationPrefix) else {
219
+ throw DeltaAssemblerError.invalidPath(entry.path)
220
+ }
221
+ let parent = target.deletingLastPathComponent()
222
+ try fileManager.createDirectory(at: parent, withIntermediateDirectories: true)
223
+ try fileManager.copyItem(at: cachePath(for: entry.sha256), to: target)
224
+ }
225
+ }
226
+
227
+ // MARK: - Builtin seeding
228
+
229
+ private struct BuiltinSeed: Codable {
230
+ let nativeBuild: String
231
+ let hashes: [String]
232
+ }
233
+
234
+ private var builtinSeedURL: URL {
235
+ cacheDirectory.appendingPathComponent(DeltaAssembler.builtinSeedMarkerName, isDirectory: false)
236
+ }
237
+
238
+ private func readBuiltinSeed() -> BuiltinSeed? {
239
+ guard let data = try? Data(contentsOf: builtinSeedURL) else {
240
+ return nil
241
+ }
242
+ return try? JSONDecoder().decode(BuiltinSeed.self, from: data)
243
+ }
244
+
245
+ /// Hash the store-build web assets into the cache once per native build, so
246
+ /// the first OTA only downloads what changed relative to the binary.
247
+ /// Best-effort: failures only cost extra downloads.
248
+ func seedFromBuiltinIfNeeded(builtinDirectory: URL, nativeBuild: String) {
249
+ if let seed = readBuiltinSeed(), seed.nativeBuild == nativeBuild {
250
+ return
251
+ }
252
+
253
+ var isDirectory: ObjCBool = false
254
+ guard fileManager.fileExists(atPath: builtinDirectory.path, isDirectory: &isDirectory),
255
+ isDirectory.boolValue else {
256
+ return
257
+ }
258
+
259
+ var hashes: [String] = []
260
+ let enumerator = fileManager.enumerator(
261
+ at: builtinDirectory,
262
+ includingPropertiesForKeys: [.isRegularFileKey],
263
+ options: [.skipsHiddenFiles]
264
+ )
265
+ while let item = enumerator?.nextObject() as? URL {
266
+ guard let isRegular = try? item.resourceValues(forKeys: [.isRegularFileKey]).isRegularFile,
267
+ isRegular == true else {
268
+ continue
269
+ }
270
+ guard let sha256 = try? HashUtils.sha256(fileURL: item) else {
271
+ continue
272
+ }
273
+ let destination = cachePath(for: sha256)
274
+ if !fileManager.fileExists(atPath: destination.path) {
275
+ let staging = cacheDirectory.appendingPathComponent(
276
+ ".tmp-\(UUID().uuidString)",
277
+ isDirectory: false
278
+ )
279
+ if (try? fileManager.copyItem(at: item, to: staging)) != nil {
280
+ if (try? fileManager.moveItem(at: staging, to: destination)) == nil {
281
+ try? fileManager.removeItem(at: staging)
282
+ }
283
+ }
284
+ }
285
+ hashes.append(sha256)
286
+ }
287
+
288
+ let seed = BuiltinSeed(nativeBuild: nativeBuild, hashes: hashes)
289
+ if let data = try? JSONEncoder().encode(seed) {
290
+ try? data.write(to: builtinSeedURL, options: .atomic)
291
+ }
292
+ }
293
+
294
+ // MARK: - Eviction
295
+
296
+ /// Remove cache entries not referenced by any live bundle and not part of
297
+ /// the builtin seed. Best-effort.
298
+ func pruneCache(referencedHashes: Set<String>) {
299
+ var keep = Set(referencedHashes.map { $0.lowercased() })
300
+ if let seed = readBuiltinSeed() {
301
+ keep.formUnion(seed.hashes.map { $0.lowercased() })
302
+ }
303
+
304
+ guard let items = try? fileManager.contentsOfDirectory(atPath: cacheDirectory.path) else {
305
+ return
306
+ }
307
+ for item in items {
308
+ if item == DeltaAssembler.builtinSeedMarkerName || item.hasPrefix(".tmp-") {
309
+ continue
310
+ }
311
+ if !keep.contains(item.lowercased()) {
312
+ try? fileManager.removeItem(at: cacheDirectory.appendingPathComponent(item))
313
+ }
314
+ }
315
+ }
316
+ }
@@ -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
- let url: String
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
- guard let dlURL = URL(string: downloadUrl) else {
133
- throw ManifestClientError.invalidURL
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