@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.
- 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 +316 -0
- package/ios/Sources/UpdaterPlugin/ManifestClient.swift +54 -6
- package/ios/Sources/UpdaterPlugin/UpdaterPlugin.swift +166 -2
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
|
-
/**
|
|
41
|
-
url
|
|
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":"AAAA,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,iBAAiB,CAAC;AAE5D;;GAEG;AACH,oBAAY,YAAY;IACtB,+BAA+B;IAC/B,OAAO,YAAY;IACnB,iDAAiD;IACjD,OAAO,YAAY;IACnB,mCAAmC;IACnC,KAAK,UAAU;IACf,wBAAwB;IACxB,OAAO,YAAY;IACnB,yDAAyD;IACzD,KAAK,UAAU;CAChB;AAED,MAAM,WAAW,UAAU;IACzB,+BAA+B;IAC/B,EAAE,EAAE,MAAM,CAAC;IACX,8BAA8B;IAC9B,OAAO,EAAE,MAAM,CAAC;IAChB,iDAAiD;IACjD,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,mCAAmC;IACnC,MAAM,EAAE,YAAY,CAAC;IACrB,+CAA+C;IAC/C,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,iCAAiC;IACjC,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,qDAAqD;IACrD,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,gEAAgE;IAChE,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,WAAW,aAAa;IAC5B,qBAAqB;IACrB,OAAO,EAAE,MAAM,CAAC;IAChB,iDAAiD;IACjD,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,
|
|
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
|
-
|
|
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
|