@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
|
@@ -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":"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,gFAAgF;IAChF,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,sFAAsF;IACtF,MAAM,EAAE,MAAM,CAAC;IACf,kEAAkE;IAClE,IAAI,EAAE,MAAM,CAAC;IACb,uDAAuD;IACvD,SAAS,EAAE,MAAM,CAAC;IAClB,2EAA2E;IAC3E,QAAQ,CAAC,EAAE,KAAK,GAAG,QAAQ,CAAC;IAC5B;;;;OAIG;IACH,cAAc,CAAC,EAAE,OAAO,CAAC;CAC1B;AAED,MAAM,WAAW,WAAW;IAC1B,OAAO,EAAE,UAAU,CAAC;IACpB,QAAQ,EAAE,UAAU,CAAC;IACrB,MAAM,EAAE,UAAU,GAAG,IAAI,CAAC;IAC1B,cAAc,EAAE,MAAM,CAAC;CACxB;AAED,MAAM,MAAM,YAAY,GAAG,KAAK,GAAG,QAAQ,GAAG,cAAc,GAAG,WAAW,CAAC;AAE3E,MAAM,WAAW,iBAAiB;IAChC,GAAG,EAAE,MAAM,CAAC;IACZ,GAAG,EAAE,MAAM,CAAC;CACb;AAED,MAAM,WAAW,eAAe;IAC9B,8FAA8F;IAC9F,GAAG,EAAE,MAAM,CAAC;IACZ,oFAAoF;IACpF,GAAG,EAAE,MAAM,CAAC;CACb;AAED,MAAM,WAAW,mBAAmB;IAClC,IAAI,EAAE,WAAW,CAAC;CACnB;AAED,MAAM,WAAW,wBAAwB;IACvC,IAAI,EAAE,gBAAgB,CAAC;IACvB,MAAM,EAAE,aAAa,CAAC;CACvB;AAED,MAAM,WAAW,0BAA0B;IACzC,IAAI,EAAE,kBAAkB,CAAC;IACzB,MAAM,EAAE,aAAa,CAAC;CACvB;AAED,MAAM,MAAM,WAAW,GACnB,mBAAmB,GACnB,wBAAwB,GACxB,0BAA0B,CAAC;AAE/B,MAAM,WAAW,sBAAsB;IACrC,IAAI,EAAE,WAAW,CAAC;CACnB;AAED,MAAM,WAAW,oBAAoB;IACnC,IAAI,EAAE,QAAQ,CAAC;IACf,MAAM,EAAE,UAAU,CAAC;CACpB;AAED,MAAM,MAAM,cAAc,GAAG,sBAAsB,GAAG,oBAAoB,CAAC;AAE3E,MAAM,WAAW,WAAW;IAC1B,mEAAmE;IACnE,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB,mFAAmF;IACnF,MAAM,EAAE,UAAU,GAAG,QAAQ,CAAC;CAC/B;AAED;;;GAGG;AACH,MAAM,WAAW,iBAAiB;IAChC,MAAM,EAAE,UAAU,CAAC;CACpB;AAED;;;GAGG;AACH,MAAM,WAAW,kBAAkB;IACjC,MAAM,EAAE,UAAU,CAAC;CACpB;AAED;;GAEG;AACH,MAAM,WAAW,iBAAiB;IAChC,OAAO,EAAE,MAAM,CAAC;IAChB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,gGAAgG;IAChG,MAAM,EAAE,MAAM,CAAC;CAChB;AAED;;;;;;;GAOG;AACH,MAAM,MAAM,eAAe,GACvB,iBAAiB,GACjB,cAAc,GACd,eAAe,GACf,gBAAgB,GAChB,UAAU,CAAC;AAEf;;GAEG;AACH,MAAM,WAAW,YAAY;IAC3B,gEAAgE;IAChE,KAAK,EAAE,MAAM,CAAC;IACd,kEAAkE;IAClE,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,uGAAuG;IACvG,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,2FAA2F;IAC3F,YAAY,CAAC,EAAE,YAAY,CAAC;IAC5B,oDAAoD;IACpD,YAAY,CAAC,EAAE,YAAY,CAAC;IAC5B,2GAA2G;IAC3G,aAAa,CAAC,EAAE,YAAY,CAAC;IAC7B;;;;;OAKG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,oEAAoE;IACpE,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,iFAAiF;IACjF,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,yHAAyH;IACzH,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,4DAA4D;IAC5D,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,yEAAyE;IACzE,YAAY,CAAC,EAAE,iBAAiB,EAAE,CAAC;IACnC;;;OAGG;IACH,UAAU,CAAC,EAAE,eAAe,EAAE,CAAC;IAC/B,oEAAoE;IACpE,iBAAiB,CAAC,EAAE,OAAO,CAAC;CAC7B;AAED,MAAM,WAAW,YAAY;IAC3B;;OAEG;IACH,QAAQ,IAAI,OAAO,CAAC,WAAW,CAAC,CAAC;IAEjC;;OAEG;IACH,KAAK,IAAI,OAAO,CAAC,WAAW,CAAC,CAAC;IAE9B;;OAEG;IACH,QAAQ,IAAI,OAAO,CAAC,cAAc,CAAC,CAAC;IAEpC;;;;;;OAMG;IACH,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IAEvB;;;;;;;;OAQG;IACH,MAAM,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IAExB;;;OAGG;IACH,cAAc,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IAEhC;;;OAGG;IACH,cAAc,IAAI,OAAO,CAAC,UAAU,GAAG,IAAI,CAAC,CAAC;IAE7C;;;;;;;OAOG;IACH,UAAU,CAAC,OAAO,EAAE;QAAE,OAAO,EAAE,MAAM,GAAG,IAAI,CAAA;KAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAE/D;;;OAGG;IACH,UAAU,IAAI,OAAO,CAAC,WAAW,CAAC,CAAC;IAEnC;;;;;;;;OAQG;IACH,WAAW,CACT,SAAS,EAAE,iBAAiB,EAC5B,YAAY,EAAE,CAAC,MAAM,EAAE,aAAa,KAAK,IAAI,GAC5C,OAAO,CAAC,oBAAoB,CAAC,CAAC;IACjC,WAAW,CACT,SAAS,EAAE,cAAc,EACzB,YAAY,EAAE,CAAC,KAAK,EAAE,iBAAiB,KAAK,IAAI,GAC/C,OAAO,CAAC,oBAAoB,CAAC,CAAC;IACjC,WAAW,CACT,SAAS,EAAE,eAAe,EAC1B,YAAY,EAAE,CAAC,KAAK,EAAE,kBAAkB,KAAK,IAAI,GAChD,OAAO,CAAC,oBAAoB,CAAC,CAAC;IACjC,WAAW,CACT,SAAS,EAAE,gBAAgB,EAC3B,YAAY,EAAE,CAAC,KAAK,EAAE,iBAAiB,KAAK,IAAI,GAC/C,OAAO,CAAC,oBAAoB,CAAC,CAAC;IACjC,WAAW,CACT,SAAS,EAAE,UAAU,EACrB,YAAY,EAAE,CAAC,KAAK,EAAE,iBAAiB,KAAK,IAAI,GAC/C,OAAO,CAAC,oBAAoB,CAAC,CAAC;IAEjC;;OAEG;IACH,kBAAkB,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACrC;AAED,MAAM,WAAW,kBAAkB;IACjC,QAAQ,IAAI,OAAO,CAAC,WAAW,CAAC,CAAC;IACjC,KAAK,IAAI,OAAO,CAAC,WAAW,CAAC,CAAC;IAC9B,QAAQ,IAAI,OAAO,CAAC,cAAc,CAAC,CAAC;IACpC,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IACvB,MAAM,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IACxB,cAAc,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IAChC,cAAc,IAAI,OAAO,CAAC,UAAU,GAAG,IAAI,CAAC,CAAC;IAC7C,UAAU,CAAC,OAAO,EAAE;QAAE,OAAO,EAAE,MAAM,GAAG,IAAI,CAAA;KAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC/D,UAAU,IAAI,OAAO,CAAC,WAAW,CAAC,CAAC;IACnC,WAAW,CACT,SAAS,EAAE,eAAe,EAC1B,YAAY,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,IAAI,GACrC,OAAO,CAAC,oBAAoB,CAAC,CAAC;IACjC,kBAAkB,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACrC"}
|
|
@@ -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,323 @@
|
|
|
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
|
+
// Resolve the destination's symlinks ONCE and derive every target from the
|
|
210
|
+
// resolved base. iOS's temp dir is /var/… (a symlink to /private/var/…);
|
|
211
|
+
// standardizedFileURL resolved it inconsistently between the base and the
|
|
212
|
+
// appended path, so the containment check compared /var vs /private/var and
|
|
213
|
+
// rejected every file. Building targets from the already-resolved base makes
|
|
214
|
+
// the prefix check exact (mirrors Android's getCanonicalPath() on both sides).
|
|
215
|
+
let canonicalDestination = destination.resolvingSymlinksInPath()
|
|
216
|
+
let destinationPrefix = canonicalDestination.path.hasSuffix("/")
|
|
217
|
+
? canonicalDestination.path
|
|
218
|
+
: canonicalDestination.path + "/"
|
|
219
|
+
|
|
220
|
+
for entry in entries {
|
|
221
|
+
try await ensureCached(entry)
|
|
222
|
+
|
|
223
|
+
let target = canonicalDestination.appendingPathComponent(entry.path, isDirectory: false)
|
|
224
|
+
// Defense in depth alongside isValidEntryPath (mirrors ZipUtils).
|
|
225
|
+
guard target.path.hasPrefix(destinationPrefix) else {
|
|
226
|
+
throw DeltaAssemblerError.invalidPath(entry.path)
|
|
227
|
+
}
|
|
228
|
+
let parent = target.deletingLastPathComponent()
|
|
229
|
+
try fileManager.createDirectory(at: parent, withIntermediateDirectories: true)
|
|
230
|
+
try fileManager.copyItem(at: cachePath(for: entry.sha256), to: target)
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// MARK: - Builtin seeding
|
|
235
|
+
|
|
236
|
+
private struct BuiltinSeed: Codable {
|
|
237
|
+
let nativeBuild: String
|
|
238
|
+
let hashes: [String]
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
private var builtinSeedURL: URL {
|
|
242
|
+
cacheDirectory.appendingPathComponent(DeltaAssembler.builtinSeedMarkerName, isDirectory: false)
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
private func readBuiltinSeed() -> BuiltinSeed? {
|
|
246
|
+
guard let data = try? Data(contentsOf: builtinSeedURL) else {
|
|
247
|
+
return nil
|
|
248
|
+
}
|
|
249
|
+
return try? JSONDecoder().decode(BuiltinSeed.self, from: data)
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/// Hash the store-build web assets into the cache once per native build, so
|
|
253
|
+
/// the first OTA only downloads what changed relative to the binary.
|
|
254
|
+
/// Best-effort: failures only cost extra downloads.
|
|
255
|
+
func seedFromBuiltinIfNeeded(builtinDirectory: URL, nativeBuild: String) {
|
|
256
|
+
if let seed = readBuiltinSeed(), seed.nativeBuild == nativeBuild {
|
|
257
|
+
return
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
var isDirectory: ObjCBool = false
|
|
261
|
+
guard fileManager.fileExists(atPath: builtinDirectory.path, isDirectory: &isDirectory),
|
|
262
|
+
isDirectory.boolValue else {
|
|
263
|
+
return
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
var hashes: [String] = []
|
|
267
|
+
let enumerator = fileManager.enumerator(
|
|
268
|
+
at: builtinDirectory,
|
|
269
|
+
includingPropertiesForKeys: [.isRegularFileKey],
|
|
270
|
+
options: [.skipsHiddenFiles]
|
|
271
|
+
)
|
|
272
|
+
while let item = enumerator?.nextObject() as? URL {
|
|
273
|
+
guard let isRegular = try? item.resourceValues(forKeys: [.isRegularFileKey]).isRegularFile,
|
|
274
|
+
isRegular == true else {
|
|
275
|
+
continue
|
|
276
|
+
}
|
|
277
|
+
guard let sha256 = try? HashUtils.sha256(fileURL: item) else {
|
|
278
|
+
continue
|
|
279
|
+
}
|
|
280
|
+
let destination = cachePath(for: sha256)
|
|
281
|
+
if !fileManager.fileExists(atPath: destination.path) {
|
|
282
|
+
let staging = cacheDirectory.appendingPathComponent(
|
|
283
|
+
".tmp-\(UUID().uuidString)",
|
|
284
|
+
isDirectory: false
|
|
285
|
+
)
|
|
286
|
+
if (try? fileManager.copyItem(at: item, to: staging)) != nil {
|
|
287
|
+
if (try? fileManager.moveItem(at: staging, to: destination)) == nil {
|
|
288
|
+
try? fileManager.removeItem(at: staging)
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
hashes.append(sha256)
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
let seed = BuiltinSeed(nativeBuild: nativeBuild, hashes: hashes)
|
|
296
|
+
if let data = try? JSONEncoder().encode(seed) {
|
|
297
|
+
try? data.write(to: builtinSeedURL, options: .atomic)
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// MARK: - Eviction
|
|
302
|
+
|
|
303
|
+
/// Remove cache entries not referenced by any live bundle and not part of
|
|
304
|
+
/// the builtin seed. Best-effort.
|
|
305
|
+
func pruneCache(referencedHashes: Set<String>) {
|
|
306
|
+
var keep = Set(referencedHashes.map { $0.lowercased() })
|
|
307
|
+
if let seed = readBuiltinSeed() {
|
|
308
|
+
keep.formUnion(seed.hashes.map { $0.lowercased() })
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
guard let items = try? fileManager.contentsOfDirectory(atPath: cacheDirectory.path) else {
|
|
312
|
+
return
|
|
313
|
+
}
|
|
314
|
+
for item in items {
|
|
315
|
+
if item == DeltaAssembler.builtinSeedMarkerName || item.hasPrefix(".tmp-") {
|
|
316
|
+
continue
|
|
317
|
+
}
|
|
318
|
+
if !keep.contains(item.lowercased()) {
|
|
319
|
+
try? fileManager.removeItem(at: cacheDirectory.appendingPathComponent(item))
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|