@hot-updater/protocol 1.2.0-rc.12 → 1.2.0-rc.14
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/dist/native-artifact.cjs +351 -15
- package/dist/native-artifact.d.cts +127 -2
- package/dist/native-artifact.d.mts +127 -2
- package/dist/native-artifact.mjs +338 -17
- package/package.json +1 -1
package/dist/native-artifact.cjs
CHANGED
|
@@ -284,6 +284,8 @@ const VERSION_NAME = 16843292;
|
|
|
284
284
|
const NAME = 16842755;
|
|
285
285
|
const VALUE = 16842788;
|
|
286
286
|
const RESOURCE = 16842789;
|
|
287
|
+
const SCHEME = 16842791;
|
|
288
|
+
const COMPONENTS = /* @__PURE__ */ new Set(["activity", "activity-alias"]);
|
|
287
289
|
const attributeOf = (element, resourceId, name) => element.attributes.find((attribute) => attribute.resourceId !== 0 ? attribute.resourceId === resourceId : attribute.name === name)?.value ?? null;
|
|
288
290
|
/** The values of a parsed manifest that import reads. */
|
|
289
291
|
const readAndroidManifest = (manifest) => {
|
|
@@ -292,7 +294,29 @@ const readAndroidManifest = (manifest) => {
|
|
|
292
294
|
const versionCode = attributeOf(manifest, VERSION_CODE, "versionCode");
|
|
293
295
|
const application = manifest.children.find(({ name }) => name === "application");
|
|
294
296
|
const metaData = /* @__PURE__ */ new Map();
|
|
297
|
+
const launchers = [];
|
|
298
|
+
const urlSchemes = /* @__PURE__ */ new Map();
|
|
299
|
+
const stringAttribute = (element, id, name) => {
|
|
300
|
+
const value = attributeOf(element, id, name);
|
|
301
|
+
return value?.type === "string" ? value.value : null;
|
|
302
|
+
};
|
|
295
303
|
for (const element of application?.children ?? []) {
|
|
304
|
+
if (COMPONENTS.has(element.name)) {
|
|
305
|
+
const component = stringAttribute(element, NAME, "name");
|
|
306
|
+
if (component === null) continue;
|
|
307
|
+
for (const filter of element.children) {
|
|
308
|
+
if (filter.name !== "intent-filter") continue;
|
|
309
|
+
const named = (child) => filter.children.filter(({ name }) => name === child).map((part) => stringAttribute(part, NAME, "name"));
|
|
310
|
+
if (named("action").includes("android.intent.action.MAIN") && named("category").includes("android.intent.category.LAUNCHER") && !launchers.includes(component)) launchers.push(component);
|
|
311
|
+
for (const data of filter.children) {
|
|
312
|
+
if (data.name !== "data") continue;
|
|
313
|
+
const scheme = stringAttribute(data, SCHEME, "scheme");
|
|
314
|
+
if (scheme === null) continue;
|
|
315
|
+
urlSchemes.set(component, [...urlSchemes.get(component) ?? [], scheme]);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
296
320
|
if (element.name !== "meta-data") continue;
|
|
297
321
|
const name = attributeOf(element, NAME, "name");
|
|
298
322
|
const value = attributeOf(element, VALUE, "value") ?? attributeOf(element, RESOURCE, "resource");
|
|
@@ -302,7 +326,9 @@ const readAndroidManifest = (manifest) => {
|
|
|
302
326
|
packageName: packageValue?.type === "string" ? packageValue.value : null,
|
|
303
327
|
versionName: attributeOf(manifest, VERSION_NAME, "versionName"),
|
|
304
328
|
versionCode: versionCode?.type === "int" ? versionCode.value : null,
|
|
305
|
-
metaData
|
|
329
|
+
metaData,
|
|
330
|
+
launchers,
|
|
331
|
+
urlSchemes
|
|
306
332
|
};
|
|
307
333
|
};
|
|
308
334
|
//#endregion
|
|
@@ -443,13 +469,30 @@ const ENTITIES = {
|
|
|
443
469
|
const decodeEntities = (text) => text.replace(/&(#x[0-9a-f]+|#\d+|\w+);/giu, (whole, entity) => {
|
|
444
470
|
if (entity[0] === "#") {
|
|
445
471
|
const code = entity[1] === "x" || entity[1] === "X" ? Number.parseInt(entity.slice(2), 16) : Number.parseInt(entity.slice(1), 10);
|
|
472
|
+
if (code > 1114111) throw corrupt();
|
|
446
473
|
return String.fromCodePoint(code);
|
|
447
474
|
}
|
|
448
475
|
return ENTITIES[entity] ?? whole;
|
|
449
476
|
});
|
|
450
|
-
/**
|
|
477
|
+
/**
|
|
478
|
+
* `text` without each `open`, what follows it, and its `close`. A scan, because
|
|
479
|
+
* a regular expression retries from every `open` that never closes, which on a
|
|
480
|
+
* crafted file takes minutes.
|
|
481
|
+
*/
|
|
482
|
+
const withoutSpans = (text, open, close) => {
|
|
483
|
+
let kept = "";
|
|
484
|
+
let position = 0;
|
|
485
|
+
for (;;) {
|
|
486
|
+
const start = text.indexOf(open, position);
|
|
487
|
+
const end = start === -1 ? -1 : text.indexOf(close, start + open.length);
|
|
488
|
+
if (end === -1) return kept + text.slice(position);
|
|
489
|
+
kept += text.slice(position, start);
|
|
490
|
+
position = end + close.length;
|
|
491
|
+
}
|
|
492
|
+
};
|
|
493
|
+
/** An XML property list, as a provisioning profile holds one. */
|
|
451
494
|
const parseXmlPlist = (text) => {
|
|
452
|
-
const tokens = text
|
|
495
|
+
const tokens = withoutSpans(withoutSpans(text, "<!--", "-->"), "<?", "?>").replace(/<!DOCTYPE[^<>]*>/giu, "").split(/(<[^<>]+>)/u);
|
|
453
496
|
let i = 0;
|
|
454
497
|
const nextTag = () => {
|
|
455
498
|
while (i < tokens.length) {
|
|
@@ -511,6 +554,91 @@ const parsePlist = (bytes) => {
|
|
|
511
554
|
return parseXmlPlist(new TextDecoder().decode(bytes));
|
|
512
555
|
};
|
|
513
556
|
//#endregion
|
|
557
|
+
//#region src/nativeArtifact/sandbox.ts
|
|
558
|
+
/**
|
|
559
|
+
* What a sandbox build is, wherever it is built or read: `sandbox: true` on a
|
|
560
|
+
* `nativeBuild` scheme adds a native layer to the app, which a normal build
|
|
561
|
+
* never compiles.
|
|
562
|
+
*/
|
|
563
|
+
/**
|
|
564
|
+
* The literal the sandbox layer's code holds, in `ios/HotUpdater/Sandbox` and
|
|
565
|
+
* `android/src/sandbox`: reading an artifact's strings finds it. Its length
|
|
566
|
+
* is its own among the lengths those reads keep.
|
|
567
|
+
*/
|
|
568
|
+
const SANDBOX_CODE_MARKER = "HOT_UPDATER_SANDBOX_NATIVE_LAYER";
|
|
569
|
+
/** The Info.plist key an iOS sandbox build declares itself with. */
|
|
570
|
+
const SANDBOX_INFO_PLIST_KEY = "HOT_UPDATER_SANDBOX";
|
|
571
|
+
/** The `<meta-data>` an Android sandbox build declares itself with. */
|
|
572
|
+
const SANDBOX_META_DATA = "com.hotupdater.SANDBOX";
|
|
573
|
+
/** The activity that takes an Android sandbox build's launcher entry and links. */
|
|
574
|
+
const SANDBOX_ACTIVITY = "com.hotupdater.HotUpdaterSandboxActivity";
|
|
575
|
+
/** The `applicationIdSuffix` an Android sandbox build appends. */
|
|
576
|
+
const SANDBOX_APPLICATION_ID_SUFFIX = ".sandbox";
|
|
577
|
+
const SANDBOX_CHANNEL_PREFIX = "sandbox-";
|
|
578
|
+
const SANDBOX_URL_SCHEME_PREFIX = "hu-sandbox-";
|
|
579
|
+
/**
|
|
580
|
+
* The channel a scheme's sandbox build registers on. `sandbox-*` is reserved:
|
|
581
|
+
* it is how the registry tells a sandbox build, with no column of its own.
|
|
582
|
+
*/
|
|
583
|
+
const sandboxChannelOf = (scheme) => `${SANDBOX_CHANNEL_PREFIX}${scheme}`;
|
|
584
|
+
/** Whether a channel is reserved for sandbox builds. */
|
|
585
|
+
const isSandboxChannel = (channel) => channel.startsWith(SANDBOX_CHANNEL_PREFIX);
|
|
586
|
+
/**
|
|
587
|
+
* The URL scheme only a sandbox build registers, derived from the app's ID:
|
|
588
|
+
* the app's own scheme would ask which app to open beside the store app.
|
|
589
|
+
*/
|
|
590
|
+
const sandboxUrlSchemeOf = (appId) => `${SANDBOX_URL_SCHEME_PREFIX}${appId.toLowerCase().replace(/[^a-z0-9]+/gu, "-")}`;
|
|
591
|
+
/** Whether a URL scheme is one a sandbox build registers. */
|
|
592
|
+
const isSandboxUrlScheme = (scheme) => scheme.toLowerCase().startsWith(SANDBOX_URL_SCHEME_PREFIX);
|
|
593
|
+
/**
|
|
594
|
+
* The link a preview QR holds. `appId` is the scheme's `bundleIdentifier` or
|
|
595
|
+
* `packageName`: the ID before the Android sandbox's `.sandbox` suffix.
|
|
596
|
+
*/
|
|
597
|
+
const createPreviewLink = ({ appId, channel, fingerprintHash }) => {
|
|
598
|
+
require_uuid.assertCanonicalChannelName(channel);
|
|
599
|
+
return `${sandboxUrlSchemeOf(appId)}://hot-updater/preview?channel=${encodeURIComponent(channel)}&fingerprint=${encodeURIComponent(fingerprintHash)}`;
|
|
600
|
+
};
|
|
601
|
+
const PREVIEW_LINK = /^(hu-sandbox-[a-z0-9-]+):\/\/hot-updater\/preview\?([^#]*)$/iu;
|
|
602
|
+
const MAX_FINGERPRINT_LENGTH = 256;
|
|
603
|
+
/**
|
|
604
|
+
* What a preview link says, or null for anything else: another shape, a
|
|
605
|
+
* repeated or missing parameter, or a channel no release can target. Native
|
|
606
|
+
* code parses it the same way before any JavaScript runs, in
|
|
607
|
+
* HotUpdaterSandboxLink, and shares the vectors in test-utils'
|
|
608
|
+
* `sandbox-preview-links.json`.
|
|
609
|
+
*/
|
|
610
|
+
const parsePreviewLink = (link) => {
|
|
611
|
+
const match = PREVIEW_LINK.exec(link);
|
|
612
|
+
if (match === null) return null;
|
|
613
|
+
const parameters = /* @__PURE__ */ new Map();
|
|
614
|
+
for (const parameter of match[2].split("&")) {
|
|
615
|
+
const separator = parameter.indexOf("=");
|
|
616
|
+
if (separator === -1) return null;
|
|
617
|
+
const name = parameter.slice(0, separator);
|
|
618
|
+
if (parameters.has(name)) return null;
|
|
619
|
+
let value;
|
|
620
|
+
try {
|
|
621
|
+
value = decodeURIComponent(parameter.slice(separator + 1));
|
|
622
|
+
} catch {
|
|
623
|
+
return null;
|
|
624
|
+
}
|
|
625
|
+
parameters.set(name, value);
|
|
626
|
+
}
|
|
627
|
+
const channel = parameters.get("channel");
|
|
628
|
+
const fingerprintHash = parameters.get("fingerprint");
|
|
629
|
+
if (channel === void 0 || fingerprintHash === void 0 || fingerprintHash.length === 0 || fingerprintHash.length > MAX_FINGERPRINT_LENGTH) return null;
|
|
630
|
+
try {
|
|
631
|
+
require_uuid.assertCanonicalChannelName(channel);
|
|
632
|
+
} catch {
|
|
633
|
+
return null;
|
|
634
|
+
}
|
|
635
|
+
return {
|
|
636
|
+
urlScheme: match[1].toLowerCase(),
|
|
637
|
+
channel,
|
|
638
|
+
fingerprintHash
|
|
639
|
+
};
|
|
640
|
+
};
|
|
641
|
+
//#endregion
|
|
514
642
|
//#region src/nativeArtifact/strings.ts
|
|
515
643
|
const view$1 = (bytes) => new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
516
644
|
/** Bytes that are all ASCII, as text; null otherwise. */
|
|
@@ -656,7 +784,7 @@ const UINT32_MAX = 4294967295;
|
|
|
656
784
|
const EOCD_SEARCH_LENGTH = 65557;
|
|
657
785
|
/** How much compressed data one read takes. */
|
|
658
786
|
const READ_CHUNK = 1048576;
|
|
659
|
-
const MiB$
|
|
787
|
+
const MiB$2 = 1048576;
|
|
660
788
|
/**
|
|
661
789
|
* What an archive may hold before it is refused as no artifact: an upload is
|
|
662
790
|
* untrusted, so a crafted directory or entry (a zip bomb) stops here, not
|
|
@@ -664,10 +792,10 @@ const MiB$1 = 1048576;
|
|
|
664
792
|
*/
|
|
665
793
|
const ZIP_LIMITS = {
|
|
666
794
|
/** The central directory. */
|
|
667
|
-
directoryBytes: 64 * MiB$
|
|
795
|
+
directoryBytes: 64 * MiB$2,
|
|
668
796
|
entries: 5e5,
|
|
669
797
|
/** An entry streamed out whole, such as the built-in bundle. */
|
|
670
|
-
entryBytes: 1024 * MiB$
|
|
798
|
+
entryBytes: 1024 * MiB$2
|
|
671
799
|
};
|
|
672
800
|
const UNIX_HOST = 3;
|
|
673
801
|
const S_IFMT = 61440;
|
|
@@ -775,7 +903,7 @@ const filterZipEntries = (entries, match) => {
|
|
|
775
903
|
const assertReadable = (entry) => {
|
|
776
904
|
if (entry.symlink) throw new NativeArtifactError("UNREADABLE", `The archive's ${entry.name} is a symbolic link, not a file.`);
|
|
777
905
|
};
|
|
778
|
-
const tooLarge = (entry, limit) => new NativeArtifactError("UNREADABLE", `The archive's ${entry.name} is larger than ${Math.round(limit / MiB$
|
|
906
|
+
const tooLarge = (entry, limit) => new NativeArtifactError("UNREADABLE", `The archive's ${entry.name} is larger than ${Math.round(limit / MiB$2)} MB, more than any artifact's.`);
|
|
779
907
|
/** Where an entry's data starts, past its local header. */
|
|
780
908
|
const dataOffsetOf = async (source, entry) => {
|
|
781
909
|
const local = view(await source.read(entry.localHeaderOffset, 30));
|
|
@@ -860,13 +988,13 @@ const readZipEntry = (source, entry, maxBytes, budget) => {
|
|
|
860
988
|
};
|
|
861
989
|
//#endregion
|
|
862
990
|
//#region src/nativeArtifact/readNativeArtifact.ts
|
|
863
|
-
const MiB = 1048576;
|
|
991
|
+
const MiB$1 = 1048576;
|
|
864
992
|
/** The most a manifest or an Info.plist holds. */
|
|
865
|
-
const METADATA_BYTES = 16 * MiB;
|
|
993
|
+
const METADATA_BYTES$1 = 16 * MiB$1;
|
|
866
994
|
/** The most one dex file holds. */
|
|
867
|
-
const DEX_BYTES = 256 * MiB;
|
|
995
|
+
const DEX_BYTES = 256 * MiB$1;
|
|
868
996
|
/** The most reading one artifact inflates, across every entry it reads. */
|
|
869
|
-
const READ_BUDGET_BYTES = 2048 * MiB;
|
|
997
|
+
const READ_BUDGET_BYTES$1 = 2048 * MiB$1;
|
|
870
998
|
/** How long reading one artifact may take. */
|
|
871
999
|
const DEFAULT_TIMEOUT_MS = 12e4;
|
|
872
1000
|
/** Where each kind of artifact packs its built-in bundle. */
|
|
@@ -879,7 +1007,11 @@ const nativeArtifactKindOf = (fileName) => {
|
|
|
879
1007
|
};
|
|
880
1008
|
const UUID_LENGTH = 36;
|
|
881
1009
|
const SHA256_HEX = /^[0-9a-f]{64}$/u;
|
|
882
|
-
const LENGTHS = /* @__PURE__ */ new Set([
|
|
1010
|
+
const LENGTHS = /* @__PURE__ */ new Set([
|
|
1011
|
+
UUID_LENGTH,
|
|
1012
|
+
64,
|
|
1013
|
+
32
|
|
1014
|
+
]);
|
|
883
1015
|
const unique = (values) => [...new Set(values)].sort();
|
|
884
1016
|
const candidatesOf = (strings) => {
|
|
885
1017
|
const all = [...strings];
|
|
@@ -908,7 +1040,7 @@ const androidChannelOf = (value) => {
|
|
|
908
1040
|
const readAndroid = async (source, kind, entries, budget) => {
|
|
909
1041
|
const manifestEntry = findZipEntry(entries, kind === "aab" ? "base/manifest/AndroidManifest.xml" : "AndroidManifest.xml");
|
|
910
1042
|
if (manifestEntry === void 0) throw new NativeArtifactError("UNREADABLE", `The ${kind === "aab" ? ".aab has no base/manifest/AndroidManifest.xml" : ".apk has no AndroidManifest.xml"}.`);
|
|
911
|
-
const manifestBytes = await readZipEntry(source, manifestEntry, METADATA_BYTES, budget);
|
|
1043
|
+
const manifestBytes = await readZipEntry(source, manifestEntry, METADATA_BYTES$1, budget);
|
|
912
1044
|
const manifest = readAndroidManifest(kind === "aab" ? parseProtoManifest(manifestBytes) : parseBinaryManifest(manifestBytes));
|
|
913
1045
|
const strings = [];
|
|
914
1046
|
const dexes = filterZipEntries(entries, (name) => kind === "aab" ? /^base\/dex\/classes\d*\.dex$/u.test(name) : /^classes\d*\.dex$/u.test(name));
|
|
@@ -924,19 +1056,38 @@ const readAndroid = async (source, kind, entries, budget) => {
|
|
|
924
1056
|
channel: androidChannelOf(manifest.metaData.get("com.hotupdater.CHANNEL")),
|
|
925
1057
|
minBundleIdCandidates: minBundleIds,
|
|
926
1058
|
fingerprintCandidates: fingerprints,
|
|
1059
|
+
sandbox: {
|
|
1060
|
+
code: strings.includes(SANDBOX_CODE_MARKER),
|
|
1061
|
+
declared: manifest.metaData.has(SANDBOX_META_DATA)
|
|
1062
|
+
},
|
|
1063
|
+
entry: {
|
|
1064
|
+
launchers: manifest.launchers,
|
|
1065
|
+
sandboxUrlScheme: manifest.urlSchemes.get("com.hotupdater.HotUpdaterSandboxActivity")?.find(isSandboxUrlScheme) ?? null
|
|
1066
|
+
},
|
|
927
1067
|
builtInBundle: bundle === void 0 ? null : {
|
|
928
1068
|
path: bundle.name,
|
|
929
1069
|
byteSize: bundle.uncompressedSize
|
|
930
1070
|
}
|
|
931
1071
|
};
|
|
932
1072
|
};
|
|
1073
|
+
/** The sandbox URL scheme in an Info.plist's `CFBundleURLTypes`, or null. */
|
|
1074
|
+
const sandboxUrlSchemeIn = (info) => {
|
|
1075
|
+
const types = info["CFBundleURLTypes"];
|
|
1076
|
+
if (!Array.isArray(types)) return null;
|
|
1077
|
+
for (const type of types) {
|
|
1078
|
+
const schemes = type?.CFBundleURLSchemes;
|
|
1079
|
+
if (!Array.isArray(schemes)) continue;
|
|
1080
|
+
for (const scheme of schemes) if (typeof scheme === "string" && isSandboxUrlScheme(scheme)) return scheme;
|
|
1081
|
+
}
|
|
1082
|
+
return null;
|
|
1083
|
+
};
|
|
933
1084
|
const stringValue = (value) => typeof value === "string" && value !== "" ? value : null;
|
|
934
1085
|
const readIos = async (source, kind, entries, budget) => {
|
|
935
1086
|
const infoPlist = kind === "ipa" ? /^Payload\/[^/]+\.app\/Info\.plist$/u : /^[^/]+\.app\/Info\.plist$/u;
|
|
936
1087
|
const plistEntry = findZipEntry(entries, (name) => infoPlist.test(name));
|
|
937
1088
|
if (plistEntry === void 0) throw new NativeArtifactError("UNREADABLE", kind === "ipa" ? "The .ipa has no Payload/<App>.app/Info.plist." : "The .app.zip has no <App>.app/Info.plist at its root.");
|
|
938
1089
|
const appDirectory = plistEntry.name.slice(0, -10);
|
|
939
|
-
const plist = parsePlist(await readZipEntry(source, plistEntry, METADATA_BYTES, budget));
|
|
1090
|
+
const plist = parsePlist(await readZipEntry(source, plistEntry, METADATA_BYTES$1, budget));
|
|
940
1091
|
if (plist === null || typeof plist !== "object" || Array.isArray(plist)) throw new NativeArtifactError("UNREADABLE", "The app's Info.plist is not a dictionary.");
|
|
941
1092
|
const info = plist;
|
|
942
1093
|
const machO = (entry) => readMachOStrings((start, end) => readZipEntryRange(source, entry, start, end, budget), LENGTHS);
|
|
@@ -964,6 +1115,14 @@ const readIos = async (source, kind, entries, budget) => {
|
|
|
964
1115
|
channel: channel === void 0 || channel === "" ? null : typeof channel === "string" ? channel : { unreadable: "HOT_UPDATER_CHANNEL is not a string" },
|
|
965
1116
|
minBundleIdCandidates: minBundleIds,
|
|
966
1117
|
fingerprintCandidates: fingerprints,
|
|
1118
|
+
sandbox: {
|
|
1119
|
+
code: strings.includes(SANDBOX_CODE_MARKER),
|
|
1120
|
+
declared: info[SANDBOX_INFO_PLIST_KEY] !== void 0
|
|
1121
|
+
},
|
|
1122
|
+
entry: {
|
|
1123
|
+
launchers: [],
|
|
1124
|
+
sandboxUrlScheme: sandboxUrlSchemeIn(info)
|
|
1125
|
+
},
|
|
967
1126
|
builtInBundle: bundle === void 0 ? null : {
|
|
968
1127
|
path: bundle.name,
|
|
969
1128
|
byteSize: bundle.uncompressedSize
|
|
@@ -981,7 +1140,7 @@ const readNativeArtifact = async (source, fileName, { timeoutMs = DEFAULT_TIMEOU
|
|
|
981
1140
|
const kind = nativeArtifactKindOf(fileName);
|
|
982
1141
|
if (kind === null) throw new NativeArtifactError("UNSUPPORTED_FILE", `${fileName} is not an .ipa, .aab, .apk or a simulator .app. Import the artifact of a release build.`);
|
|
983
1142
|
const budget = {
|
|
984
|
-
remaining: READ_BUDGET_BYTES,
|
|
1143
|
+
remaining: READ_BUDGET_BYTES$1,
|
|
985
1144
|
deadline: Date.now() + timeoutMs
|
|
986
1145
|
};
|
|
987
1146
|
const timed = {
|
|
@@ -1003,6 +1162,34 @@ const readNativeArtifact = async (source, fileName, { timeoutMs = DEFAULT_TIMEOU
|
|
|
1003
1162
|
//#region src/nativeArtifact/checkNativeArtifact.ts
|
|
1004
1163
|
/** The channel an app without a default channel reports. */
|
|
1005
1164
|
const DEFAULT_CHANNEL = "production";
|
|
1165
|
+
/**
|
|
1166
|
+
* Why an artifact can't register on `channel`: the sandbox layer must be whole
|
|
1167
|
+
* or absent, and only a sandbox build registers on a `sandbox-*` channel.
|
|
1168
|
+
*/
|
|
1169
|
+
const sandboxArtifactProblem = ({ kind, platform, sandbox, entry }, channel) => {
|
|
1170
|
+
const declaration = platform === "ios" ? "Info.plist key" : "manifest meta-data";
|
|
1171
|
+
if (sandbox.code !== sandbox.declared) return {
|
|
1172
|
+
code: "SANDBOX_MISMATCH",
|
|
1173
|
+
message: sandbox.code ? `The .${kind} has the sandbox layer's code, but no sandbox ${declaration}.\nBuild it again with hot-updater native-build.` : `The .${kind} has a sandbox ${declaration}, but not the sandbox layer's code.\nBuild it again with hot-updater native-build.`
|
|
1174
|
+
};
|
|
1175
|
+
if (sandbox.code && entry.sandboxUrlScheme === null) return {
|
|
1176
|
+
code: "SANDBOX_MISMATCH",
|
|
1177
|
+
message: `The .${kind} is a sandbox build with no sandbox URL scheme, so no link can open it.\nBuild it again with hot-updater native-build.`
|
|
1178
|
+
};
|
|
1179
|
+
if (sandbox.code && platform === "android" && (entry.launchers.length !== 1 || entry.launchers[0] !== "com.hotupdater.HotUpdaterSandboxActivity")) return {
|
|
1180
|
+
code: "SANDBOX_MISMATCH",
|
|
1181
|
+
message: `The .${kind} is a sandbox build, but its launcher is ${entry.launchers.join(", ") || "missing"}.\nBuild it again with hot-updater native-build.`
|
|
1182
|
+
};
|
|
1183
|
+
if (sandbox.code && !isSandboxChannel(channel)) return {
|
|
1184
|
+
code: "SANDBOX_MISMATCH",
|
|
1185
|
+
message: `The .${kind} is a sandbox build, which can't register on channel ${channel}.\nUse a sandbox-<scheme> channel.`
|
|
1186
|
+
};
|
|
1187
|
+
if (!sandbox.code && isSandboxChannel(channel)) return {
|
|
1188
|
+
code: "SANDBOX_MISMATCH",
|
|
1189
|
+
message: `Channel ${channel} is reserved for sandbox builds, and the .${kind} isn't one.\nUse another channel, or build it with sandbox: true.`
|
|
1190
|
+
};
|
|
1191
|
+
return null;
|
|
1192
|
+
};
|
|
1006
1193
|
const isUnreadable = (value) => typeof value === "object" && value !== null && "unreadable" in value;
|
|
1007
1194
|
/**
|
|
1008
1195
|
* Whether a read artifact can be imported as a native build, whose built-in
|
|
@@ -1053,6 +1240,8 @@ const checkNativeArtifact = (info, { updateStrategy, appIds = [], channel: chann
|
|
|
1053
1240
|
refuse("INVALID_SCOPE", `The app's channel ${JSON.stringify(channel)} is no channel a release can target: ${error instanceof Error ? error.message : String(error)}.`);
|
|
1054
1241
|
}
|
|
1055
1242
|
}
|
|
1243
|
+
const sandboxProblem = sandboxArtifactProblem(info, channel);
|
|
1244
|
+
if (sandboxProblem !== null) problems.push(sandboxProblem);
|
|
1056
1245
|
if (appIds.length > 0 && info.appId !== null && !appIds.includes(info.appId)) warnings.push({
|
|
1057
1246
|
code: "APP_ID_MISMATCH",
|
|
1058
1247
|
message: `The app is ${info.appId}, which no ${info.platform} scheme in nativeBuild names (${appIds.join(", ")}): check that it is this project's build.`
|
|
@@ -1088,11 +1277,158 @@ const alreadyImportedProblem = (minBundleId, nativeBuildId) => ({
|
|
|
1088
1277
|
message: nativeBuildId === null ? `The built-in bundle ${minBundleId} is already registered, as a bundle that is no native build's.` : `This build is already registered as native build ${nativeBuildId} (built-in bundle ${minBundleId}).`
|
|
1089
1278
|
});
|
|
1090
1279
|
//#endregion
|
|
1280
|
+
//#region src/nativeArtifact/iosInstall.ts
|
|
1281
|
+
const MiB = 1048576;
|
|
1282
|
+
/** The most an app's Info.plist or a provisioning profile holds here. */
|
|
1283
|
+
const METADATA_BYTES = 1 * MiB;
|
|
1284
|
+
/** The most reading what an install needs inflates, across every entry. */
|
|
1285
|
+
const READ_BUDGET_BYTES = 8 * MiB;
|
|
1286
|
+
/** How long reading what an install needs may take. */
|
|
1287
|
+
const READ_TIMEOUT_MS = 3e4;
|
|
1288
|
+
/** The extensions an app may carry, each with a profile to read. */
|
|
1289
|
+
const MAX_NESTED_PROFILES = 16;
|
|
1290
|
+
const APP_INFO_PLIST = /^Payload\/[^/]+\.app\/Info\.plist$/u;
|
|
1291
|
+
/**
|
|
1292
|
+
* What installs with the app and must list the device too: not a Watch app's
|
|
1293
|
+
* profile, which lists watches and installs on one.
|
|
1294
|
+
*/
|
|
1295
|
+
const NESTED_PROFILE = /^Payload\/[^/]+\.app\/(?:PlugIns|Extensions|AppClips)\/[^/]+\.(?:appex|app)\/embedded\.mobileprovision$/u;
|
|
1296
|
+
/** A device's UDID: 40 hex digits, or 8 and 16 joined by a hyphen. */
|
|
1297
|
+
const UDID = /^(?:[0-9a-f]{40}|[0-9a-f]{8}-[0-9a-f]{16})$/iu;
|
|
1298
|
+
/**
|
|
1299
|
+
* The form two UDIDs compare in, or null for text that isn't one. Apple writes
|
|
1300
|
+
* them in either case, and App Store Connect takes the device's own.
|
|
1301
|
+
*/
|
|
1302
|
+
const normalizeIosUdid = (value) => {
|
|
1303
|
+
const trimmed = value.trim();
|
|
1304
|
+
return UDID.test(trimmed) ? trimmed.toLowerCase() : null;
|
|
1305
|
+
};
|
|
1306
|
+
/**
|
|
1307
|
+
* The bytes from `<?xml` through `</plist>`, or null without both: a CMS
|
|
1308
|
+
* message, as a provisioning profile and a Profile Service answer are, holds
|
|
1309
|
+
* its plist as plain text among binary bytes.
|
|
1310
|
+
*/
|
|
1311
|
+
const sliceEmbeddedPlist = (bytes) => {
|
|
1312
|
+
const text = new TextDecoder("latin1").decode(bytes);
|
|
1313
|
+
const start = text.indexOf("<?xml");
|
|
1314
|
+
if (start === -1) return null;
|
|
1315
|
+
const end = text.indexOf("</plist>", start);
|
|
1316
|
+
return end === -1 ? null : bytes.subarray(start, end + 8);
|
|
1317
|
+
};
|
|
1318
|
+
/**
|
|
1319
|
+
* The UDID in a Profile Service answer's body, or null when it has no one
|
|
1320
|
+
* well-formed UDID. It only finds the text: nothing here verifies the signature
|
|
1321
|
+
* around it, so nothing automated may trust the result.
|
|
1322
|
+
*/
|
|
1323
|
+
const readProfileServiceUdid = (body) => {
|
|
1324
|
+
const plist = sliceEmbeddedPlist(body);
|
|
1325
|
+
if (plist === null) return null;
|
|
1326
|
+
const matches = [...new TextDecoder().decode(plist).matchAll(/<key>\s*UDID\s*<\/key>\s*<string>([^<]*)<\/string>/gu)];
|
|
1327
|
+
if (matches.length !== 1) return null;
|
|
1328
|
+
const udid = matches[0][1].trim();
|
|
1329
|
+
return normalizeIosUdid(udid) === null ? null : udid;
|
|
1330
|
+
};
|
|
1331
|
+
const unreadable = (message) => new NativeArtifactError("UNREADABLE", message);
|
|
1332
|
+
const isDict = (value) => typeof value === "object" && value !== null && !Array.isArray(value) && !(value instanceof Uint8Array);
|
|
1333
|
+
const readProfile = (bytes, name) => {
|
|
1334
|
+
const plistBytes = sliceEmbeddedPlist(bytes);
|
|
1335
|
+
if (plistBytes === null) throw unreadable(`The archive's ${name} holds no property list.`);
|
|
1336
|
+
const plist = parsePlist(plistBytes);
|
|
1337
|
+
if (!isDict(plist)) throw unreadable(`The archive's ${name} is corrupt.`);
|
|
1338
|
+
const listed = plist["ProvisionedDevices"];
|
|
1339
|
+
if (listed !== void 0 && !Array.isArray(listed)) throw unreadable(`The archive's ${name} is corrupt.`);
|
|
1340
|
+
const devices = listed?.map((device) => {
|
|
1341
|
+
if (typeof device !== "string") throw unreadable(`The archive's ${name} is corrupt.`);
|
|
1342
|
+
return device.trim().toLowerCase();
|
|
1343
|
+
});
|
|
1344
|
+
const expiration = plist["ExpirationDate"];
|
|
1345
|
+
const expiresAtMs = typeof expiration === "string" ? Date.parse(expiration) : null;
|
|
1346
|
+
if (expiresAtMs !== null && Number.isNaN(expiresAtMs)) throw unreadable(`The archive's ${name} is corrupt.`);
|
|
1347
|
+
const entitlements = plist["Entitlements"];
|
|
1348
|
+
const applicationId = isDict(entitlements) ? entitlements["application-identifier"] : void 0;
|
|
1349
|
+
const bundleId = typeof applicationId === "string" ? applicationId.slice(applicationId.indexOf(".") + 1) : null;
|
|
1350
|
+
return {
|
|
1351
|
+
allDevices: plist["ProvisionsAllDevices"] === true,
|
|
1352
|
+
devices: devices === void 0 ? null : new Set(devices),
|
|
1353
|
+
getTaskAllow: isDict(entitlements) && entitlements["get-task-allow"] === true,
|
|
1354
|
+
expiresAtMs,
|
|
1355
|
+
bundleId: bundleId === null || bundleId.includes("*") ? null : bundleId
|
|
1356
|
+
};
|
|
1357
|
+
};
|
|
1358
|
+
const distributionOf = (profile) => {
|
|
1359
|
+
if (profile === null) return "store";
|
|
1360
|
+
if (profile.allDevices) return "enterprise";
|
|
1361
|
+
if (profile.devices === null) return "store";
|
|
1362
|
+
return profile.getTaskAllow ? "development" : "ad-hoc";
|
|
1363
|
+
};
|
|
1364
|
+
/**
|
|
1365
|
+
* What an `.ipa` installs as, reading only its directory, its app's Info.plist
|
|
1366
|
+
* and its provisioning profiles: a few requests of a remote file, whatever its
|
|
1367
|
+
* size. The `.ipa` is untrusted, and each part is bounded before it is read.
|
|
1368
|
+
* Throws `NativeArtifactError` for one it can't read.
|
|
1369
|
+
*/
|
|
1370
|
+
const readIpaInstallInfo = async (source) => {
|
|
1371
|
+
const entries = await readZipDirectory(source);
|
|
1372
|
+
const infoPlists = filterZipEntries(entries, (name) => APP_INFO_PLIST.test(name));
|
|
1373
|
+
const infoEntry = infoPlists[0];
|
|
1374
|
+
if (infoEntry === void 0 || infoPlists.length > 1) throw unreadable("The .ipa has no one Payload/<App>.app/Info.plist.");
|
|
1375
|
+
const budget = {
|
|
1376
|
+
remaining: READ_BUDGET_BYTES,
|
|
1377
|
+
deadline: Date.now() + READ_TIMEOUT_MS
|
|
1378
|
+
};
|
|
1379
|
+
const read = (entry) => readZipEntry(source, entry, METADATA_BYTES, budget);
|
|
1380
|
+
const info = parsePlist(await read(infoEntry));
|
|
1381
|
+
if (!isDict(info)) throw unreadable("The app's Info.plist is corrupt.");
|
|
1382
|
+
const bundleId = info["CFBundleIdentifier"];
|
|
1383
|
+
const bundleVersion = info["CFBundleVersion"];
|
|
1384
|
+
if (typeof bundleId !== "string" || bundleId === "") throw unreadable("The app's Info.plist has no CFBundleIdentifier.");
|
|
1385
|
+
if (typeof bundleVersion !== "string" || bundleVersion === "") throw unreadable("The app's Info.plist has no CFBundleVersion.");
|
|
1386
|
+
const displayName = info["CFBundleDisplayName"];
|
|
1387
|
+
const bundleName = info["CFBundleName"];
|
|
1388
|
+
const title = typeof displayName === "string" && displayName !== "" ? displayName : typeof bundleName === "string" && bundleName !== "" ? bundleName : bundleId;
|
|
1389
|
+
const appDirectory = infoEntry.name.slice(0, -10);
|
|
1390
|
+
const profileEntry = findZipEntry(entries, `${appDirectory}embedded.mobileprovision`);
|
|
1391
|
+
const nestedEntries = filterZipEntries(entries, (name) => name.startsWith(appDirectory) && NESTED_PROFILE.test(name));
|
|
1392
|
+
if (nestedEntries.length > MAX_NESTED_PROFILES) throw unreadable(`The app has more than ${MAX_NESTED_PROFILES} extensions with profiles.`);
|
|
1393
|
+
const profile = profileEntry === void 0 ? null : readProfile(await read(profileEntry), profileEntry.name);
|
|
1394
|
+
const nested = [];
|
|
1395
|
+
for (const entry of nestedEntries) nested.push(readProfile(await read(entry), entry.name));
|
|
1396
|
+
const distribution = distributionOf(profile);
|
|
1397
|
+
const profiles = profile === null ? nested : [profile, ...nested];
|
|
1398
|
+
const listDevices = distribution === "ad-hoc" || distribution === "development";
|
|
1399
|
+
const lists = profiles.flatMap(({ allDevices, devices }) => allDevices ? [] : [devices ?? /* @__PURE__ */ new Set()]);
|
|
1400
|
+
const expiries = profiles.flatMap(({ expiresAtMs }) => expiresAtMs === null ? [] : [expiresAtMs]);
|
|
1401
|
+
return {
|
|
1402
|
+
bundleId,
|
|
1403
|
+
bundleVersion,
|
|
1404
|
+
title,
|
|
1405
|
+
distribution,
|
|
1406
|
+
devices: listDevices ? [...lists[0] ?? []].filter((udid) => lists.every((list) => list.has(udid))).sort() : [],
|
|
1407
|
+
expiresAtMs: expiries.length === 0 ? null : Math.min(...expiries),
|
|
1408
|
+
profileBundleIds: [.../* @__PURE__ */ new Set([bundleId, ...profiles.flatMap((item) => item.bundleId ?? [])])]
|
|
1409
|
+
};
|
|
1410
|
+
};
|
|
1411
|
+
//#endregion
|
|
1091
1412
|
exports.NativeArtifactError = NativeArtifactError;
|
|
1413
|
+
exports.SANDBOX_ACTIVITY = SANDBOX_ACTIVITY;
|
|
1414
|
+
exports.SANDBOX_APPLICATION_ID_SUFFIX = SANDBOX_APPLICATION_ID_SUFFIX;
|
|
1415
|
+
exports.SANDBOX_CODE_MARKER = SANDBOX_CODE_MARKER;
|
|
1416
|
+
exports.SANDBOX_INFO_PLIST_KEY = SANDBOX_INFO_PLIST_KEY;
|
|
1417
|
+
exports.SANDBOX_META_DATA = SANDBOX_META_DATA;
|
|
1092
1418
|
exports.alreadyImportedProblem = alreadyImportedProblem;
|
|
1093
1419
|
exports.blobSource = blobSource;
|
|
1094
1420
|
exports.checkNativeArtifact = checkNativeArtifact;
|
|
1421
|
+
exports.createPreviewLink = createPreviewLink;
|
|
1095
1422
|
exports.findZipEntry = findZipEntry;
|
|
1423
|
+
exports.isSandboxChannel = isSandboxChannel;
|
|
1424
|
+
exports.isSandboxUrlScheme = isSandboxUrlScheme;
|
|
1425
|
+
exports.normalizeIosUdid = normalizeIosUdid;
|
|
1096
1426
|
exports.openZipEntry = openZipEntry;
|
|
1427
|
+
exports.parsePreviewLink = parsePreviewLink;
|
|
1428
|
+
exports.readIpaInstallInfo = readIpaInstallInfo;
|
|
1097
1429
|
exports.readNativeArtifact = readNativeArtifact;
|
|
1430
|
+
exports.readProfileServiceUdid = readProfileServiceUdid;
|
|
1098
1431
|
exports.readZipDirectory = readZipDirectory;
|
|
1432
|
+
exports.sandboxArtifactProblem = sandboxArtifactProblem;
|
|
1433
|
+
exports.sandboxChannelOf = sandboxChannelOf;
|
|
1434
|
+
exports.sandboxUrlSchemeOf = sandboxUrlSchemeOf;
|
|
@@ -22,7 +22,7 @@ declare class NativeArtifactError extends Error {
|
|
|
22
22
|
* What can keep an artifact from being imported as a native build: the
|
|
23
23
|
* codes `native-build import --json` reports.
|
|
24
24
|
*/
|
|
25
|
-
type NativeArtifactProblemCode = "UNSUPPORTED_FILE" | "UNREADABLE" | "NO_BUILT_IN_BUNDLE" | "NO_MIN_BUNDLE_ID" | "AMBIGUOUS_MIN_BUNDLE_ID" | "NO_FINGERPRINT" | "AMBIGUOUS_FINGERPRINT" | "NO_APP_VERSION" | "INVALID_SCOPE" | "APP_ID_MISMATCH" | "ALREADY_IMPORTED";
|
|
25
|
+
type NativeArtifactProblemCode = "UNSUPPORTED_FILE" | "UNREADABLE" | "NO_BUILT_IN_BUNDLE" | "NO_MIN_BUNDLE_ID" | "AMBIGUOUS_MIN_BUNDLE_ID" | "NO_FINGERPRINT" | "AMBIGUOUS_FINGERPRINT" | "NO_APP_VERSION" | "INVALID_SCOPE" | "SANDBOX_MISMATCH" | "APP_ID_MISMATCH" | "ALREADY_IMPORTED";
|
|
26
26
|
//#endregion
|
|
27
27
|
//#region src/nativeArtifact/readNativeArtifact.d.ts
|
|
28
28
|
/**
|
|
@@ -63,6 +63,23 @@ interface NativeArtifactInfo {
|
|
|
63
63
|
* with fingerprint.json compiles its fingerprint in as one.
|
|
64
64
|
*/
|
|
65
65
|
readonly fingerprintCandidates: readonly string[];
|
|
66
|
+
/**
|
|
67
|
+
* The sandbox layer in the artifact: `code` when the app's code holds its
|
|
68
|
+
* literal, and `declared` when the Info.plist or the manifest says so. A
|
|
69
|
+
* sandbox build has both, and a normal build neither.
|
|
70
|
+
*/
|
|
71
|
+
readonly sandbox: {
|
|
72
|
+
readonly code: boolean;
|
|
73
|
+
readonly declared: boolean;
|
|
74
|
+
};
|
|
75
|
+
/**
|
|
76
|
+
* How the app is entered, which a sandbox build changes: its launcher
|
|
77
|
+
* components (empty on iOS), and the sandbox URL scheme it registers.
|
|
78
|
+
*/
|
|
79
|
+
readonly entry: {
|
|
80
|
+
readonly launchers: readonly string[];
|
|
81
|
+
readonly sandboxUrlScheme: string | null;
|
|
82
|
+
};
|
|
66
83
|
/** The JavaScript bundle the build packed, the built-in bundle. */
|
|
67
84
|
readonly builtInBundle: {
|
|
68
85
|
readonly path: string;
|
|
@@ -116,6 +133,11 @@ type NativeArtifactCheck = ({
|
|
|
116
133
|
*/
|
|
117
134
|
readonly warnings: readonly NativeArtifactProblem[];
|
|
118
135
|
};
|
|
136
|
+
/**
|
|
137
|
+
* Why an artifact can't register on `channel`: the sandbox layer must be whole
|
|
138
|
+
* or absent, and only a sandbox build registers on a `sandbox-*` channel.
|
|
139
|
+
*/
|
|
140
|
+
declare const sandboxArtifactProblem: ({ kind, platform, sandbox, entry }: NativeArtifactInfo, channel: string) => NativeArtifactProblem | null;
|
|
119
141
|
/**
|
|
120
142
|
* Whether a read artifact can be imported as a native build, whose built-in
|
|
121
143
|
* bundle OTAs patch from, and what it registers as. It refuses an artifact
|
|
@@ -142,6 +164,54 @@ declare const checkNativeArtifact: (info: NativeArtifactInfo, { updateStrategy,
|
|
|
142
164
|
*/
|
|
143
165
|
declare const alreadyImportedProblem: (minBundleId: string, nativeBuildId: string | null) => NativeArtifactProblem;
|
|
144
166
|
//#endregion
|
|
167
|
+
//#region src/nativeArtifact/iosInstall.d.ts
|
|
168
|
+
/**
|
|
169
|
+
* How an `.ipa`'s profile lets it install: on the devices it lists (`ad-hoc`,
|
|
170
|
+
* `development`), on any device of the team's company (`enterprise`), or only
|
|
171
|
+
* through a store, as an App Store export's has no device list (`store`).
|
|
172
|
+
*/
|
|
173
|
+
type IpaDistribution = "ad-hoc" | "development" | "enterprise" | "store";
|
|
174
|
+
/** What installing an `.ipa` over the air needs to know, read from the `.ipa`. */
|
|
175
|
+
interface IpaInstallInfo {
|
|
176
|
+
/** `CFBundleIdentifier`. */
|
|
177
|
+
readonly bundleId: string;
|
|
178
|
+
/** `CFBundleVersion`. */
|
|
179
|
+
readonly bundleVersion: string;
|
|
180
|
+
/** The name an install shows: `CFBundleDisplayName`, or `CFBundleName`. */
|
|
181
|
+
readonly title: string;
|
|
182
|
+
readonly distribution: IpaDistribution;
|
|
183
|
+
/**
|
|
184
|
+
* The normalized UDIDs that every profile of the app lists, so a device
|
|
185
|
+
* here installs all of it. Empty for `enterprise` and `store`.
|
|
186
|
+
*/
|
|
187
|
+
readonly devices: readonly string[];
|
|
188
|
+
/** When the first of the app's profiles expires, or null with none. */
|
|
189
|
+
readonly expiresAtMs: number | null;
|
|
190
|
+
/**
|
|
191
|
+
* The bundle ID of the app and of each extension with a profile of its own,
|
|
192
|
+
* the app's first: what signing a new `.ipa` needs a profile for.
|
|
193
|
+
*/
|
|
194
|
+
readonly profileBundleIds: readonly string[];
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* The form two UDIDs compare in, or null for text that isn't one. Apple writes
|
|
198
|
+
* them in either case, and App Store Connect takes the device's own.
|
|
199
|
+
*/
|
|
200
|
+
declare const normalizeIosUdid: (value: string) => string | null;
|
|
201
|
+
/**
|
|
202
|
+
* The UDID in a Profile Service answer's body, or null when it has no one
|
|
203
|
+
* well-formed UDID. It only finds the text: nothing here verifies the signature
|
|
204
|
+
* around it, so nothing automated may trust the result.
|
|
205
|
+
*/
|
|
206
|
+
declare const readProfileServiceUdid: (body: Uint8Array) => string | null;
|
|
207
|
+
/**
|
|
208
|
+
* What an `.ipa` installs as, reading only its directory, its app's Info.plist
|
|
209
|
+
* and its provisioning profiles: a few requests of a remote file, whatever its
|
|
210
|
+
* size. The `.ipa` is untrusted, and each part is bounded before it is read.
|
|
211
|
+
* Throws `NativeArtifactError` for one it can't read.
|
|
212
|
+
*/
|
|
213
|
+
declare const readIpaInstallInfo: (source: ByteSource) => Promise<IpaInstallInfo>;
|
|
214
|
+
//#endregion
|
|
145
215
|
//#region src/nativeArtifact/zip.d.ts
|
|
146
216
|
/** One file of a zip archive, from its central directory. */
|
|
147
217
|
interface ZipEntry {
|
|
@@ -176,4 +246,59 @@ declare const openZipEntry: (source: ByteSource, entry: ZipEntry, { maxBytes }?:
|
|
|
176
246
|
readonly maxBytes?: number;
|
|
177
247
|
}) => Promise<ReadableStream<Uint8Array>>;
|
|
178
248
|
//#endregion
|
|
179
|
-
|
|
249
|
+
//#region src/nativeArtifact/sandbox.d.ts
|
|
250
|
+
/**
|
|
251
|
+
* The literal the sandbox layer's code holds, in `ios/HotUpdater/Sandbox` and
|
|
252
|
+
* `android/src/sandbox`: reading an artifact's strings finds it. Its length
|
|
253
|
+
* is its own among the lengths those reads keep.
|
|
254
|
+
*/
|
|
255
|
+
declare const SANDBOX_CODE_MARKER = "HOT_UPDATER_SANDBOX_NATIVE_LAYER";
|
|
256
|
+
/** The Info.plist key an iOS sandbox build declares itself with. */
|
|
257
|
+
declare const SANDBOX_INFO_PLIST_KEY = "HOT_UPDATER_SANDBOX";
|
|
258
|
+
/** The `<meta-data>` an Android sandbox build declares itself with. */
|
|
259
|
+
declare const SANDBOX_META_DATA = "com.hotupdater.SANDBOX";
|
|
260
|
+
/** The activity that takes an Android sandbox build's launcher entry and links. */
|
|
261
|
+
declare const SANDBOX_ACTIVITY = "com.hotupdater.HotUpdaterSandboxActivity";
|
|
262
|
+
/** The `applicationIdSuffix` an Android sandbox build appends. */
|
|
263
|
+
declare const SANDBOX_APPLICATION_ID_SUFFIX = ".sandbox";
|
|
264
|
+
/**
|
|
265
|
+
* The channel a scheme's sandbox build registers on. `sandbox-*` is reserved:
|
|
266
|
+
* it is how the registry tells a sandbox build, with no column of its own.
|
|
267
|
+
*/
|
|
268
|
+
declare const sandboxChannelOf: (scheme: string) => string;
|
|
269
|
+
/** Whether a channel is reserved for sandbox builds. */
|
|
270
|
+
declare const isSandboxChannel: (channel: string) => boolean;
|
|
271
|
+
/**
|
|
272
|
+
* The URL scheme only a sandbox build registers, derived from the app's ID:
|
|
273
|
+
* the app's own scheme would ask which app to open beside the store app.
|
|
274
|
+
*/
|
|
275
|
+
declare const sandboxUrlSchemeOf: (appId: string) => string;
|
|
276
|
+
/** Whether a URL scheme is one a sandbox build registers. */
|
|
277
|
+
declare const isSandboxUrlScheme: (scheme: string) => boolean;
|
|
278
|
+
/** What a preview link asks a sandbox build to run. */
|
|
279
|
+
interface PreviewLink {
|
|
280
|
+
/** The sandbox's URL scheme, in lower case. */
|
|
281
|
+
readonly urlScheme: string;
|
|
282
|
+
readonly channel: string;
|
|
283
|
+
/** The fingerprint the channel's bundles need; another build refuses them. */
|
|
284
|
+
readonly fingerprintHash: string;
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* The link a preview QR holds. `appId` is the scheme's `bundleIdentifier` or
|
|
288
|
+
* `packageName`: the ID before the Android sandbox's `.sandbox` suffix.
|
|
289
|
+
*/
|
|
290
|
+
declare const createPreviewLink: ({ appId, channel, fingerprintHash }: {
|
|
291
|
+
readonly appId: string;
|
|
292
|
+
readonly channel: string;
|
|
293
|
+
readonly fingerprintHash: string;
|
|
294
|
+
}) => string;
|
|
295
|
+
/**
|
|
296
|
+
* What a preview link says, or null for anything else: another shape, a
|
|
297
|
+
* repeated or missing parameter, or a channel no release can target. Native
|
|
298
|
+
* code parses it the same way before any JavaScript runs, in
|
|
299
|
+
* HotUpdaterSandboxLink, and shares the vectors in test-utils'
|
|
300
|
+
* `sandbox-preview-links.json`.
|
|
301
|
+
*/
|
|
302
|
+
declare const parsePreviewLink: (link: string) => PreviewLink | null;
|
|
303
|
+
//#endregion
|
|
304
|
+
export { type ByteSource, type ImportableNativeBuild, type IpaDistribution, type IpaInstallInfo, type NativeArtifactCheck, NativeArtifactError, type NativeArtifactInfo, type NativeArtifactKind, type NativeArtifactProblem, type NativeArtifactProblemCode, type PreviewLink, SANDBOX_ACTIVITY, SANDBOX_APPLICATION_ID_SUFFIX, SANDBOX_CODE_MARKER, SANDBOX_INFO_PLIST_KEY, SANDBOX_META_DATA, type UnreadableValue, type ZipEntry, alreadyImportedProblem, blobSource, checkNativeArtifact, createPreviewLink, findZipEntry, isSandboxChannel, isSandboxUrlScheme, normalizeIosUdid, openZipEntry, parsePreviewLink, readIpaInstallInfo, readNativeArtifact, readProfileServiceUdid, readZipDirectory, sandboxArtifactProblem, sandboxChannelOf, sandboxUrlSchemeOf };
|
|
@@ -22,7 +22,7 @@ declare class NativeArtifactError extends Error {
|
|
|
22
22
|
* What can keep an artifact from being imported as a native build: the
|
|
23
23
|
* codes `native-build import --json` reports.
|
|
24
24
|
*/
|
|
25
|
-
type NativeArtifactProblemCode = "UNSUPPORTED_FILE" | "UNREADABLE" | "NO_BUILT_IN_BUNDLE" | "NO_MIN_BUNDLE_ID" | "AMBIGUOUS_MIN_BUNDLE_ID" | "NO_FINGERPRINT" | "AMBIGUOUS_FINGERPRINT" | "NO_APP_VERSION" | "INVALID_SCOPE" | "APP_ID_MISMATCH" | "ALREADY_IMPORTED";
|
|
25
|
+
type NativeArtifactProblemCode = "UNSUPPORTED_FILE" | "UNREADABLE" | "NO_BUILT_IN_BUNDLE" | "NO_MIN_BUNDLE_ID" | "AMBIGUOUS_MIN_BUNDLE_ID" | "NO_FINGERPRINT" | "AMBIGUOUS_FINGERPRINT" | "NO_APP_VERSION" | "INVALID_SCOPE" | "SANDBOX_MISMATCH" | "APP_ID_MISMATCH" | "ALREADY_IMPORTED";
|
|
26
26
|
//#endregion
|
|
27
27
|
//#region src/nativeArtifact/readNativeArtifact.d.ts
|
|
28
28
|
/**
|
|
@@ -63,6 +63,23 @@ interface NativeArtifactInfo {
|
|
|
63
63
|
* with fingerprint.json compiles its fingerprint in as one.
|
|
64
64
|
*/
|
|
65
65
|
readonly fingerprintCandidates: readonly string[];
|
|
66
|
+
/**
|
|
67
|
+
* The sandbox layer in the artifact: `code` when the app's code holds its
|
|
68
|
+
* literal, and `declared` when the Info.plist or the manifest says so. A
|
|
69
|
+
* sandbox build has both, and a normal build neither.
|
|
70
|
+
*/
|
|
71
|
+
readonly sandbox: {
|
|
72
|
+
readonly code: boolean;
|
|
73
|
+
readonly declared: boolean;
|
|
74
|
+
};
|
|
75
|
+
/**
|
|
76
|
+
* How the app is entered, which a sandbox build changes: its launcher
|
|
77
|
+
* components (empty on iOS), and the sandbox URL scheme it registers.
|
|
78
|
+
*/
|
|
79
|
+
readonly entry: {
|
|
80
|
+
readonly launchers: readonly string[];
|
|
81
|
+
readonly sandboxUrlScheme: string | null;
|
|
82
|
+
};
|
|
66
83
|
/** The JavaScript bundle the build packed, the built-in bundle. */
|
|
67
84
|
readonly builtInBundle: {
|
|
68
85
|
readonly path: string;
|
|
@@ -116,6 +133,11 @@ type NativeArtifactCheck = ({
|
|
|
116
133
|
*/
|
|
117
134
|
readonly warnings: readonly NativeArtifactProblem[];
|
|
118
135
|
};
|
|
136
|
+
/**
|
|
137
|
+
* Why an artifact can't register on `channel`: the sandbox layer must be whole
|
|
138
|
+
* or absent, and only a sandbox build registers on a `sandbox-*` channel.
|
|
139
|
+
*/
|
|
140
|
+
declare const sandboxArtifactProblem: ({ kind, platform, sandbox, entry }: NativeArtifactInfo, channel: string) => NativeArtifactProblem | null;
|
|
119
141
|
/**
|
|
120
142
|
* Whether a read artifact can be imported as a native build, whose built-in
|
|
121
143
|
* bundle OTAs patch from, and what it registers as. It refuses an artifact
|
|
@@ -142,6 +164,54 @@ declare const checkNativeArtifact: (info: NativeArtifactInfo, { updateStrategy,
|
|
|
142
164
|
*/
|
|
143
165
|
declare const alreadyImportedProblem: (minBundleId: string, nativeBuildId: string | null) => NativeArtifactProblem;
|
|
144
166
|
//#endregion
|
|
167
|
+
//#region src/nativeArtifact/iosInstall.d.ts
|
|
168
|
+
/**
|
|
169
|
+
* How an `.ipa`'s profile lets it install: on the devices it lists (`ad-hoc`,
|
|
170
|
+
* `development`), on any device of the team's company (`enterprise`), or only
|
|
171
|
+
* through a store, as an App Store export's has no device list (`store`).
|
|
172
|
+
*/
|
|
173
|
+
type IpaDistribution = "ad-hoc" | "development" | "enterprise" | "store";
|
|
174
|
+
/** What installing an `.ipa` over the air needs to know, read from the `.ipa`. */
|
|
175
|
+
interface IpaInstallInfo {
|
|
176
|
+
/** `CFBundleIdentifier`. */
|
|
177
|
+
readonly bundleId: string;
|
|
178
|
+
/** `CFBundleVersion`. */
|
|
179
|
+
readonly bundleVersion: string;
|
|
180
|
+
/** The name an install shows: `CFBundleDisplayName`, or `CFBundleName`. */
|
|
181
|
+
readonly title: string;
|
|
182
|
+
readonly distribution: IpaDistribution;
|
|
183
|
+
/**
|
|
184
|
+
* The normalized UDIDs that every profile of the app lists, so a device
|
|
185
|
+
* here installs all of it. Empty for `enterprise` and `store`.
|
|
186
|
+
*/
|
|
187
|
+
readonly devices: readonly string[];
|
|
188
|
+
/** When the first of the app's profiles expires, or null with none. */
|
|
189
|
+
readonly expiresAtMs: number | null;
|
|
190
|
+
/**
|
|
191
|
+
* The bundle ID of the app and of each extension with a profile of its own,
|
|
192
|
+
* the app's first: what signing a new `.ipa` needs a profile for.
|
|
193
|
+
*/
|
|
194
|
+
readonly profileBundleIds: readonly string[];
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* The form two UDIDs compare in, or null for text that isn't one. Apple writes
|
|
198
|
+
* them in either case, and App Store Connect takes the device's own.
|
|
199
|
+
*/
|
|
200
|
+
declare const normalizeIosUdid: (value: string) => string | null;
|
|
201
|
+
/**
|
|
202
|
+
* The UDID in a Profile Service answer's body, or null when it has no one
|
|
203
|
+
* well-formed UDID. It only finds the text: nothing here verifies the signature
|
|
204
|
+
* around it, so nothing automated may trust the result.
|
|
205
|
+
*/
|
|
206
|
+
declare const readProfileServiceUdid: (body: Uint8Array) => string | null;
|
|
207
|
+
/**
|
|
208
|
+
* What an `.ipa` installs as, reading only its directory, its app's Info.plist
|
|
209
|
+
* and its provisioning profiles: a few requests of a remote file, whatever its
|
|
210
|
+
* size. The `.ipa` is untrusted, and each part is bounded before it is read.
|
|
211
|
+
* Throws `NativeArtifactError` for one it can't read.
|
|
212
|
+
*/
|
|
213
|
+
declare const readIpaInstallInfo: (source: ByteSource) => Promise<IpaInstallInfo>;
|
|
214
|
+
//#endregion
|
|
145
215
|
//#region src/nativeArtifact/zip.d.ts
|
|
146
216
|
/** One file of a zip archive, from its central directory. */
|
|
147
217
|
interface ZipEntry {
|
|
@@ -176,4 +246,59 @@ declare const openZipEntry: (source: ByteSource, entry: ZipEntry, { maxBytes }?:
|
|
|
176
246
|
readonly maxBytes?: number;
|
|
177
247
|
}) => Promise<ReadableStream<Uint8Array>>;
|
|
178
248
|
//#endregion
|
|
179
|
-
|
|
249
|
+
//#region src/nativeArtifact/sandbox.d.ts
|
|
250
|
+
/**
|
|
251
|
+
* The literal the sandbox layer's code holds, in `ios/HotUpdater/Sandbox` and
|
|
252
|
+
* `android/src/sandbox`: reading an artifact's strings finds it. Its length
|
|
253
|
+
* is its own among the lengths those reads keep.
|
|
254
|
+
*/
|
|
255
|
+
declare const SANDBOX_CODE_MARKER = "HOT_UPDATER_SANDBOX_NATIVE_LAYER";
|
|
256
|
+
/** The Info.plist key an iOS sandbox build declares itself with. */
|
|
257
|
+
declare const SANDBOX_INFO_PLIST_KEY = "HOT_UPDATER_SANDBOX";
|
|
258
|
+
/** The `<meta-data>` an Android sandbox build declares itself with. */
|
|
259
|
+
declare const SANDBOX_META_DATA = "com.hotupdater.SANDBOX";
|
|
260
|
+
/** The activity that takes an Android sandbox build's launcher entry and links. */
|
|
261
|
+
declare const SANDBOX_ACTIVITY = "com.hotupdater.HotUpdaterSandboxActivity";
|
|
262
|
+
/** The `applicationIdSuffix` an Android sandbox build appends. */
|
|
263
|
+
declare const SANDBOX_APPLICATION_ID_SUFFIX = ".sandbox";
|
|
264
|
+
/**
|
|
265
|
+
* The channel a scheme's sandbox build registers on. `sandbox-*` is reserved:
|
|
266
|
+
* it is how the registry tells a sandbox build, with no column of its own.
|
|
267
|
+
*/
|
|
268
|
+
declare const sandboxChannelOf: (scheme: string) => string;
|
|
269
|
+
/** Whether a channel is reserved for sandbox builds. */
|
|
270
|
+
declare const isSandboxChannel: (channel: string) => boolean;
|
|
271
|
+
/**
|
|
272
|
+
* The URL scheme only a sandbox build registers, derived from the app's ID:
|
|
273
|
+
* the app's own scheme would ask which app to open beside the store app.
|
|
274
|
+
*/
|
|
275
|
+
declare const sandboxUrlSchemeOf: (appId: string) => string;
|
|
276
|
+
/** Whether a URL scheme is one a sandbox build registers. */
|
|
277
|
+
declare const isSandboxUrlScheme: (scheme: string) => boolean;
|
|
278
|
+
/** What a preview link asks a sandbox build to run. */
|
|
279
|
+
interface PreviewLink {
|
|
280
|
+
/** The sandbox's URL scheme, in lower case. */
|
|
281
|
+
readonly urlScheme: string;
|
|
282
|
+
readonly channel: string;
|
|
283
|
+
/** The fingerprint the channel's bundles need; another build refuses them. */
|
|
284
|
+
readonly fingerprintHash: string;
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* The link a preview QR holds. `appId` is the scheme's `bundleIdentifier` or
|
|
288
|
+
* `packageName`: the ID before the Android sandbox's `.sandbox` suffix.
|
|
289
|
+
*/
|
|
290
|
+
declare const createPreviewLink: ({ appId, channel, fingerprintHash }: {
|
|
291
|
+
readonly appId: string;
|
|
292
|
+
readonly channel: string;
|
|
293
|
+
readonly fingerprintHash: string;
|
|
294
|
+
}) => string;
|
|
295
|
+
/**
|
|
296
|
+
* What a preview link says, or null for anything else: another shape, a
|
|
297
|
+
* repeated or missing parameter, or a channel no release can target. Native
|
|
298
|
+
* code parses it the same way before any JavaScript runs, in
|
|
299
|
+
* HotUpdaterSandboxLink, and shares the vectors in test-utils'
|
|
300
|
+
* `sandbox-preview-links.json`.
|
|
301
|
+
*/
|
|
302
|
+
declare const parsePreviewLink: (link: string) => PreviewLink | null;
|
|
303
|
+
//#endregion
|
|
304
|
+
export { type ByteSource, type ImportableNativeBuild, type IpaDistribution, type IpaInstallInfo, type NativeArtifactCheck, NativeArtifactError, type NativeArtifactInfo, type NativeArtifactKind, type NativeArtifactProblem, type NativeArtifactProblemCode, type PreviewLink, SANDBOX_ACTIVITY, SANDBOX_APPLICATION_ID_SUFFIX, SANDBOX_CODE_MARKER, SANDBOX_INFO_PLIST_KEY, SANDBOX_META_DATA, type UnreadableValue, type ZipEntry, alreadyImportedProblem, blobSource, checkNativeArtifact, createPreviewLink, findZipEntry, isSandboxChannel, isSandboxUrlScheme, normalizeIosUdid, openZipEntry, parsePreviewLink, readIpaInstallInfo, readNativeArtifact, readProfileServiceUdid, readZipDirectory, sandboxArtifactProblem, sandboxChannelOf, sandboxUrlSchemeOf };
|
package/dist/native-artifact.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { l as canonicalizeAppVersion, n as isUUIDv7, o as encodeChannelKey } from "./uuid-kZvLA0Ly.mjs";
|
|
1
|
+
import { l as canonicalizeAppVersion, n as isUUIDv7, o as encodeChannelKey, r as assertCanonicalChannelName } from "./uuid-kZvLA0Ly.mjs";
|
|
2
2
|
//#region src/nativeArtifact/byteSource.ts
|
|
3
3
|
/** A `Blob`, such as Node's `fs.openAsBlob`, read by slices. */
|
|
4
4
|
const blobSource = (blob) => ({
|
|
@@ -283,6 +283,8 @@ const VERSION_NAME = 16843292;
|
|
|
283
283
|
const NAME = 16842755;
|
|
284
284
|
const VALUE = 16842788;
|
|
285
285
|
const RESOURCE = 16842789;
|
|
286
|
+
const SCHEME = 16842791;
|
|
287
|
+
const COMPONENTS = /* @__PURE__ */ new Set(["activity", "activity-alias"]);
|
|
286
288
|
const attributeOf = (element, resourceId, name) => element.attributes.find((attribute) => attribute.resourceId !== 0 ? attribute.resourceId === resourceId : attribute.name === name)?.value ?? null;
|
|
287
289
|
/** The values of a parsed manifest that import reads. */
|
|
288
290
|
const readAndroidManifest = (manifest) => {
|
|
@@ -291,7 +293,29 @@ const readAndroidManifest = (manifest) => {
|
|
|
291
293
|
const versionCode = attributeOf(manifest, VERSION_CODE, "versionCode");
|
|
292
294
|
const application = manifest.children.find(({ name }) => name === "application");
|
|
293
295
|
const metaData = /* @__PURE__ */ new Map();
|
|
296
|
+
const launchers = [];
|
|
297
|
+
const urlSchemes = /* @__PURE__ */ new Map();
|
|
298
|
+
const stringAttribute = (element, id, name) => {
|
|
299
|
+
const value = attributeOf(element, id, name);
|
|
300
|
+
return value?.type === "string" ? value.value : null;
|
|
301
|
+
};
|
|
294
302
|
for (const element of application?.children ?? []) {
|
|
303
|
+
if (COMPONENTS.has(element.name)) {
|
|
304
|
+
const component = stringAttribute(element, NAME, "name");
|
|
305
|
+
if (component === null) continue;
|
|
306
|
+
for (const filter of element.children) {
|
|
307
|
+
if (filter.name !== "intent-filter") continue;
|
|
308
|
+
const named = (child) => filter.children.filter(({ name }) => name === child).map((part) => stringAttribute(part, NAME, "name"));
|
|
309
|
+
if (named("action").includes("android.intent.action.MAIN") && named("category").includes("android.intent.category.LAUNCHER") && !launchers.includes(component)) launchers.push(component);
|
|
310
|
+
for (const data of filter.children) {
|
|
311
|
+
if (data.name !== "data") continue;
|
|
312
|
+
const scheme = stringAttribute(data, SCHEME, "scheme");
|
|
313
|
+
if (scheme === null) continue;
|
|
314
|
+
urlSchemes.set(component, [...urlSchemes.get(component) ?? [], scheme]);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
295
319
|
if (element.name !== "meta-data") continue;
|
|
296
320
|
const name = attributeOf(element, NAME, "name");
|
|
297
321
|
const value = attributeOf(element, VALUE, "value") ?? attributeOf(element, RESOURCE, "resource");
|
|
@@ -301,7 +325,9 @@ const readAndroidManifest = (manifest) => {
|
|
|
301
325
|
packageName: packageValue?.type === "string" ? packageValue.value : null,
|
|
302
326
|
versionName: attributeOf(manifest, VERSION_NAME, "versionName"),
|
|
303
327
|
versionCode: versionCode?.type === "int" ? versionCode.value : null,
|
|
304
|
-
metaData
|
|
328
|
+
metaData,
|
|
329
|
+
launchers,
|
|
330
|
+
urlSchemes
|
|
305
331
|
};
|
|
306
332
|
};
|
|
307
333
|
//#endregion
|
|
@@ -442,13 +468,30 @@ const ENTITIES = {
|
|
|
442
468
|
const decodeEntities = (text) => text.replace(/&(#x[0-9a-f]+|#\d+|\w+);/giu, (whole, entity) => {
|
|
443
469
|
if (entity[0] === "#") {
|
|
444
470
|
const code = entity[1] === "x" || entity[1] === "X" ? Number.parseInt(entity.slice(2), 16) : Number.parseInt(entity.slice(1), 10);
|
|
471
|
+
if (code > 1114111) throw corrupt();
|
|
445
472
|
return String.fromCodePoint(code);
|
|
446
473
|
}
|
|
447
474
|
return ENTITIES[entity] ?? whole;
|
|
448
475
|
});
|
|
449
|
-
/**
|
|
476
|
+
/**
|
|
477
|
+
* `text` without each `open`, what follows it, and its `close`. A scan, because
|
|
478
|
+
* a regular expression retries from every `open` that never closes, which on a
|
|
479
|
+
* crafted file takes minutes.
|
|
480
|
+
*/
|
|
481
|
+
const withoutSpans = (text, open, close) => {
|
|
482
|
+
let kept = "";
|
|
483
|
+
let position = 0;
|
|
484
|
+
for (;;) {
|
|
485
|
+
const start = text.indexOf(open, position);
|
|
486
|
+
const end = start === -1 ? -1 : text.indexOf(close, start + open.length);
|
|
487
|
+
if (end === -1) return kept + text.slice(position);
|
|
488
|
+
kept += text.slice(position, start);
|
|
489
|
+
position = end + close.length;
|
|
490
|
+
}
|
|
491
|
+
};
|
|
492
|
+
/** An XML property list, as a provisioning profile holds one. */
|
|
450
493
|
const parseXmlPlist = (text) => {
|
|
451
|
-
const tokens = text
|
|
494
|
+
const tokens = withoutSpans(withoutSpans(text, "<!--", "-->"), "<?", "?>").replace(/<!DOCTYPE[^<>]*>/giu, "").split(/(<[^<>]+>)/u);
|
|
452
495
|
let i = 0;
|
|
453
496
|
const nextTag = () => {
|
|
454
497
|
while (i < tokens.length) {
|
|
@@ -510,6 +553,91 @@ const parsePlist = (bytes) => {
|
|
|
510
553
|
return parseXmlPlist(new TextDecoder().decode(bytes));
|
|
511
554
|
};
|
|
512
555
|
//#endregion
|
|
556
|
+
//#region src/nativeArtifact/sandbox.ts
|
|
557
|
+
/**
|
|
558
|
+
* What a sandbox build is, wherever it is built or read: `sandbox: true` on a
|
|
559
|
+
* `nativeBuild` scheme adds a native layer to the app, which a normal build
|
|
560
|
+
* never compiles.
|
|
561
|
+
*/
|
|
562
|
+
/**
|
|
563
|
+
* The literal the sandbox layer's code holds, in `ios/HotUpdater/Sandbox` and
|
|
564
|
+
* `android/src/sandbox`: reading an artifact's strings finds it. Its length
|
|
565
|
+
* is its own among the lengths those reads keep.
|
|
566
|
+
*/
|
|
567
|
+
const SANDBOX_CODE_MARKER = "HOT_UPDATER_SANDBOX_NATIVE_LAYER";
|
|
568
|
+
/** The Info.plist key an iOS sandbox build declares itself with. */
|
|
569
|
+
const SANDBOX_INFO_PLIST_KEY = "HOT_UPDATER_SANDBOX";
|
|
570
|
+
/** The `<meta-data>` an Android sandbox build declares itself with. */
|
|
571
|
+
const SANDBOX_META_DATA = "com.hotupdater.SANDBOX";
|
|
572
|
+
/** The activity that takes an Android sandbox build's launcher entry and links. */
|
|
573
|
+
const SANDBOX_ACTIVITY = "com.hotupdater.HotUpdaterSandboxActivity";
|
|
574
|
+
/** The `applicationIdSuffix` an Android sandbox build appends. */
|
|
575
|
+
const SANDBOX_APPLICATION_ID_SUFFIX = ".sandbox";
|
|
576
|
+
const SANDBOX_CHANNEL_PREFIX = "sandbox-";
|
|
577
|
+
const SANDBOX_URL_SCHEME_PREFIX = "hu-sandbox-";
|
|
578
|
+
/**
|
|
579
|
+
* The channel a scheme's sandbox build registers on. `sandbox-*` is reserved:
|
|
580
|
+
* it is how the registry tells a sandbox build, with no column of its own.
|
|
581
|
+
*/
|
|
582
|
+
const sandboxChannelOf = (scheme) => `${SANDBOX_CHANNEL_PREFIX}${scheme}`;
|
|
583
|
+
/** Whether a channel is reserved for sandbox builds. */
|
|
584
|
+
const isSandboxChannel = (channel) => channel.startsWith(SANDBOX_CHANNEL_PREFIX);
|
|
585
|
+
/**
|
|
586
|
+
* The URL scheme only a sandbox build registers, derived from the app's ID:
|
|
587
|
+
* the app's own scheme would ask which app to open beside the store app.
|
|
588
|
+
*/
|
|
589
|
+
const sandboxUrlSchemeOf = (appId) => `${SANDBOX_URL_SCHEME_PREFIX}${appId.toLowerCase().replace(/[^a-z0-9]+/gu, "-")}`;
|
|
590
|
+
/** Whether a URL scheme is one a sandbox build registers. */
|
|
591
|
+
const isSandboxUrlScheme = (scheme) => scheme.toLowerCase().startsWith(SANDBOX_URL_SCHEME_PREFIX);
|
|
592
|
+
/**
|
|
593
|
+
* The link a preview QR holds. `appId` is the scheme's `bundleIdentifier` or
|
|
594
|
+
* `packageName`: the ID before the Android sandbox's `.sandbox` suffix.
|
|
595
|
+
*/
|
|
596
|
+
const createPreviewLink = ({ appId, channel, fingerprintHash }) => {
|
|
597
|
+
assertCanonicalChannelName(channel);
|
|
598
|
+
return `${sandboxUrlSchemeOf(appId)}://hot-updater/preview?channel=${encodeURIComponent(channel)}&fingerprint=${encodeURIComponent(fingerprintHash)}`;
|
|
599
|
+
};
|
|
600
|
+
const PREVIEW_LINK = /^(hu-sandbox-[a-z0-9-]+):\/\/hot-updater\/preview\?([^#]*)$/iu;
|
|
601
|
+
const MAX_FINGERPRINT_LENGTH = 256;
|
|
602
|
+
/**
|
|
603
|
+
* What a preview link says, or null for anything else: another shape, a
|
|
604
|
+
* repeated or missing parameter, or a channel no release can target. Native
|
|
605
|
+
* code parses it the same way before any JavaScript runs, in
|
|
606
|
+
* HotUpdaterSandboxLink, and shares the vectors in test-utils'
|
|
607
|
+
* `sandbox-preview-links.json`.
|
|
608
|
+
*/
|
|
609
|
+
const parsePreviewLink = (link) => {
|
|
610
|
+
const match = PREVIEW_LINK.exec(link);
|
|
611
|
+
if (match === null) return null;
|
|
612
|
+
const parameters = /* @__PURE__ */ new Map();
|
|
613
|
+
for (const parameter of match[2].split("&")) {
|
|
614
|
+
const separator = parameter.indexOf("=");
|
|
615
|
+
if (separator === -1) return null;
|
|
616
|
+
const name = parameter.slice(0, separator);
|
|
617
|
+
if (parameters.has(name)) return null;
|
|
618
|
+
let value;
|
|
619
|
+
try {
|
|
620
|
+
value = decodeURIComponent(parameter.slice(separator + 1));
|
|
621
|
+
} catch {
|
|
622
|
+
return null;
|
|
623
|
+
}
|
|
624
|
+
parameters.set(name, value);
|
|
625
|
+
}
|
|
626
|
+
const channel = parameters.get("channel");
|
|
627
|
+
const fingerprintHash = parameters.get("fingerprint");
|
|
628
|
+
if (channel === void 0 || fingerprintHash === void 0 || fingerprintHash.length === 0 || fingerprintHash.length > MAX_FINGERPRINT_LENGTH) return null;
|
|
629
|
+
try {
|
|
630
|
+
assertCanonicalChannelName(channel);
|
|
631
|
+
} catch {
|
|
632
|
+
return null;
|
|
633
|
+
}
|
|
634
|
+
return {
|
|
635
|
+
urlScheme: match[1].toLowerCase(),
|
|
636
|
+
channel,
|
|
637
|
+
fingerprintHash
|
|
638
|
+
};
|
|
639
|
+
};
|
|
640
|
+
//#endregion
|
|
513
641
|
//#region src/nativeArtifact/strings.ts
|
|
514
642
|
const view$1 = (bytes) => new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
515
643
|
/** Bytes that are all ASCII, as text; null otherwise. */
|
|
@@ -655,7 +783,7 @@ const UINT32_MAX = 4294967295;
|
|
|
655
783
|
const EOCD_SEARCH_LENGTH = 65557;
|
|
656
784
|
/** How much compressed data one read takes. */
|
|
657
785
|
const READ_CHUNK = 1048576;
|
|
658
|
-
const MiB$
|
|
786
|
+
const MiB$2 = 1048576;
|
|
659
787
|
/**
|
|
660
788
|
* What an archive may hold before it is refused as no artifact: an upload is
|
|
661
789
|
* untrusted, so a crafted directory or entry (a zip bomb) stops here, not
|
|
@@ -663,10 +791,10 @@ const MiB$1 = 1048576;
|
|
|
663
791
|
*/
|
|
664
792
|
const ZIP_LIMITS = {
|
|
665
793
|
/** The central directory. */
|
|
666
|
-
directoryBytes: 64 * MiB$
|
|
794
|
+
directoryBytes: 64 * MiB$2,
|
|
667
795
|
entries: 5e5,
|
|
668
796
|
/** An entry streamed out whole, such as the built-in bundle. */
|
|
669
|
-
entryBytes: 1024 * MiB$
|
|
797
|
+
entryBytes: 1024 * MiB$2
|
|
670
798
|
};
|
|
671
799
|
const UNIX_HOST = 3;
|
|
672
800
|
const S_IFMT = 61440;
|
|
@@ -774,7 +902,7 @@ const filterZipEntries = (entries, match) => {
|
|
|
774
902
|
const assertReadable = (entry) => {
|
|
775
903
|
if (entry.symlink) throw new NativeArtifactError("UNREADABLE", `The archive's ${entry.name} is a symbolic link, not a file.`);
|
|
776
904
|
};
|
|
777
|
-
const tooLarge = (entry, limit) => new NativeArtifactError("UNREADABLE", `The archive's ${entry.name} is larger than ${Math.round(limit / MiB$
|
|
905
|
+
const tooLarge = (entry, limit) => new NativeArtifactError("UNREADABLE", `The archive's ${entry.name} is larger than ${Math.round(limit / MiB$2)} MB, more than any artifact's.`);
|
|
778
906
|
/** Where an entry's data starts, past its local header. */
|
|
779
907
|
const dataOffsetOf = async (source, entry) => {
|
|
780
908
|
const local = view(await source.read(entry.localHeaderOffset, 30));
|
|
@@ -859,13 +987,13 @@ const readZipEntry = (source, entry, maxBytes, budget) => {
|
|
|
859
987
|
};
|
|
860
988
|
//#endregion
|
|
861
989
|
//#region src/nativeArtifact/readNativeArtifact.ts
|
|
862
|
-
const MiB = 1048576;
|
|
990
|
+
const MiB$1 = 1048576;
|
|
863
991
|
/** The most a manifest or an Info.plist holds. */
|
|
864
|
-
const METADATA_BYTES = 16 * MiB;
|
|
992
|
+
const METADATA_BYTES$1 = 16 * MiB$1;
|
|
865
993
|
/** The most one dex file holds. */
|
|
866
|
-
const DEX_BYTES = 256 * MiB;
|
|
994
|
+
const DEX_BYTES = 256 * MiB$1;
|
|
867
995
|
/** The most reading one artifact inflates, across every entry it reads. */
|
|
868
|
-
const READ_BUDGET_BYTES = 2048 * MiB;
|
|
996
|
+
const READ_BUDGET_BYTES$1 = 2048 * MiB$1;
|
|
869
997
|
/** How long reading one artifact may take. */
|
|
870
998
|
const DEFAULT_TIMEOUT_MS = 12e4;
|
|
871
999
|
/** Where each kind of artifact packs its built-in bundle. */
|
|
@@ -878,7 +1006,11 @@ const nativeArtifactKindOf = (fileName) => {
|
|
|
878
1006
|
};
|
|
879
1007
|
const UUID_LENGTH = 36;
|
|
880
1008
|
const SHA256_HEX = /^[0-9a-f]{64}$/u;
|
|
881
|
-
const LENGTHS = /* @__PURE__ */ new Set([
|
|
1009
|
+
const LENGTHS = /* @__PURE__ */ new Set([
|
|
1010
|
+
UUID_LENGTH,
|
|
1011
|
+
64,
|
|
1012
|
+
32
|
|
1013
|
+
]);
|
|
882
1014
|
const unique = (values) => [...new Set(values)].sort();
|
|
883
1015
|
const candidatesOf = (strings) => {
|
|
884
1016
|
const all = [...strings];
|
|
@@ -907,7 +1039,7 @@ const androidChannelOf = (value) => {
|
|
|
907
1039
|
const readAndroid = async (source, kind, entries, budget) => {
|
|
908
1040
|
const manifestEntry = findZipEntry(entries, kind === "aab" ? "base/manifest/AndroidManifest.xml" : "AndroidManifest.xml");
|
|
909
1041
|
if (manifestEntry === void 0) throw new NativeArtifactError("UNREADABLE", `The ${kind === "aab" ? ".aab has no base/manifest/AndroidManifest.xml" : ".apk has no AndroidManifest.xml"}.`);
|
|
910
|
-
const manifestBytes = await readZipEntry(source, manifestEntry, METADATA_BYTES, budget);
|
|
1042
|
+
const manifestBytes = await readZipEntry(source, manifestEntry, METADATA_BYTES$1, budget);
|
|
911
1043
|
const manifest = readAndroidManifest(kind === "aab" ? parseProtoManifest(manifestBytes) : parseBinaryManifest(manifestBytes));
|
|
912
1044
|
const strings = [];
|
|
913
1045
|
const dexes = filterZipEntries(entries, (name) => kind === "aab" ? /^base\/dex\/classes\d*\.dex$/u.test(name) : /^classes\d*\.dex$/u.test(name));
|
|
@@ -923,19 +1055,38 @@ const readAndroid = async (source, kind, entries, budget) => {
|
|
|
923
1055
|
channel: androidChannelOf(manifest.metaData.get("com.hotupdater.CHANNEL")),
|
|
924
1056
|
minBundleIdCandidates: minBundleIds,
|
|
925
1057
|
fingerprintCandidates: fingerprints,
|
|
1058
|
+
sandbox: {
|
|
1059
|
+
code: strings.includes(SANDBOX_CODE_MARKER),
|
|
1060
|
+
declared: manifest.metaData.has(SANDBOX_META_DATA)
|
|
1061
|
+
},
|
|
1062
|
+
entry: {
|
|
1063
|
+
launchers: manifest.launchers,
|
|
1064
|
+
sandboxUrlScheme: manifest.urlSchemes.get("com.hotupdater.HotUpdaterSandboxActivity")?.find(isSandboxUrlScheme) ?? null
|
|
1065
|
+
},
|
|
926
1066
|
builtInBundle: bundle === void 0 ? null : {
|
|
927
1067
|
path: bundle.name,
|
|
928
1068
|
byteSize: bundle.uncompressedSize
|
|
929
1069
|
}
|
|
930
1070
|
};
|
|
931
1071
|
};
|
|
1072
|
+
/** The sandbox URL scheme in an Info.plist's `CFBundleURLTypes`, or null. */
|
|
1073
|
+
const sandboxUrlSchemeIn = (info) => {
|
|
1074
|
+
const types = info["CFBundleURLTypes"];
|
|
1075
|
+
if (!Array.isArray(types)) return null;
|
|
1076
|
+
for (const type of types) {
|
|
1077
|
+
const schemes = type?.CFBundleURLSchemes;
|
|
1078
|
+
if (!Array.isArray(schemes)) continue;
|
|
1079
|
+
for (const scheme of schemes) if (typeof scheme === "string" && isSandboxUrlScheme(scheme)) return scheme;
|
|
1080
|
+
}
|
|
1081
|
+
return null;
|
|
1082
|
+
};
|
|
932
1083
|
const stringValue = (value) => typeof value === "string" && value !== "" ? value : null;
|
|
933
1084
|
const readIos = async (source, kind, entries, budget) => {
|
|
934
1085
|
const infoPlist = kind === "ipa" ? /^Payload\/[^/]+\.app\/Info\.plist$/u : /^[^/]+\.app\/Info\.plist$/u;
|
|
935
1086
|
const plistEntry = findZipEntry(entries, (name) => infoPlist.test(name));
|
|
936
1087
|
if (plistEntry === void 0) throw new NativeArtifactError("UNREADABLE", kind === "ipa" ? "The .ipa has no Payload/<App>.app/Info.plist." : "The .app.zip has no <App>.app/Info.plist at its root.");
|
|
937
1088
|
const appDirectory = plistEntry.name.slice(0, -10);
|
|
938
|
-
const plist = parsePlist(await readZipEntry(source, plistEntry, METADATA_BYTES, budget));
|
|
1089
|
+
const plist = parsePlist(await readZipEntry(source, plistEntry, METADATA_BYTES$1, budget));
|
|
939
1090
|
if (plist === null || typeof plist !== "object" || Array.isArray(plist)) throw new NativeArtifactError("UNREADABLE", "The app's Info.plist is not a dictionary.");
|
|
940
1091
|
const info = plist;
|
|
941
1092
|
const machO = (entry) => readMachOStrings((start, end) => readZipEntryRange(source, entry, start, end, budget), LENGTHS);
|
|
@@ -963,6 +1114,14 @@ const readIos = async (source, kind, entries, budget) => {
|
|
|
963
1114
|
channel: channel === void 0 || channel === "" ? null : typeof channel === "string" ? channel : { unreadable: "HOT_UPDATER_CHANNEL is not a string" },
|
|
964
1115
|
minBundleIdCandidates: minBundleIds,
|
|
965
1116
|
fingerprintCandidates: fingerprints,
|
|
1117
|
+
sandbox: {
|
|
1118
|
+
code: strings.includes(SANDBOX_CODE_MARKER),
|
|
1119
|
+
declared: info[SANDBOX_INFO_PLIST_KEY] !== void 0
|
|
1120
|
+
},
|
|
1121
|
+
entry: {
|
|
1122
|
+
launchers: [],
|
|
1123
|
+
sandboxUrlScheme: sandboxUrlSchemeIn(info)
|
|
1124
|
+
},
|
|
966
1125
|
builtInBundle: bundle === void 0 ? null : {
|
|
967
1126
|
path: bundle.name,
|
|
968
1127
|
byteSize: bundle.uncompressedSize
|
|
@@ -980,7 +1139,7 @@ const readNativeArtifact = async (source, fileName, { timeoutMs = DEFAULT_TIMEOU
|
|
|
980
1139
|
const kind = nativeArtifactKindOf(fileName);
|
|
981
1140
|
if (kind === null) throw new NativeArtifactError("UNSUPPORTED_FILE", `${fileName} is not an .ipa, .aab, .apk or a simulator .app. Import the artifact of a release build.`);
|
|
982
1141
|
const budget = {
|
|
983
|
-
remaining: READ_BUDGET_BYTES,
|
|
1142
|
+
remaining: READ_BUDGET_BYTES$1,
|
|
984
1143
|
deadline: Date.now() + timeoutMs
|
|
985
1144
|
};
|
|
986
1145
|
const timed = {
|
|
@@ -1002,6 +1161,34 @@ const readNativeArtifact = async (source, fileName, { timeoutMs = DEFAULT_TIMEOU
|
|
|
1002
1161
|
//#region src/nativeArtifact/checkNativeArtifact.ts
|
|
1003
1162
|
/** The channel an app without a default channel reports. */
|
|
1004
1163
|
const DEFAULT_CHANNEL = "production";
|
|
1164
|
+
/**
|
|
1165
|
+
* Why an artifact can't register on `channel`: the sandbox layer must be whole
|
|
1166
|
+
* or absent, and only a sandbox build registers on a `sandbox-*` channel.
|
|
1167
|
+
*/
|
|
1168
|
+
const sandboxArtifactProblem = ({ kind, platform, sandbox, entry }, channel) => {
|
|
1169
|
+
const declaration = platform === "ios" ? "Info.plist key" : "manifest meta-data";
|
|
1170
|
+
if (sandbox.code !== sandbox.declared) return {
|
|
1171
|
+
code: "SANDBOX_MISMATCH",
|
|
1172
|
+
message: sandbox.code ? `The .${kind} has the sandbox layer's code, but no sandbox ${declaration}.\nBuild it again with hot-updater native-build.` : `The .${kind} has a sandbox ${declaration}, but not the sandbox layer's code.\nBuild it again with hot-updater native-build.`
|
|
1173
|
+
};
|
|
1174
|
+
if (sandbox.code && entry.sandboxUrlScheme === null) return {
|
|
1175
|
+
code: "SANDBOX_MISMATCH",
|
|
1176
|
+
message: `The .${kind} is a sandbox build with no sandbox URL scheme, so no link can open it.\nBuild it again with hot-updater native-build.`
|
|
1177
|
+
};
|
|
1178
|
+
if (sandbox.code && platform === "android" && (entry.launchers.length !== 1 || entry.launchers[0] !== "com.hotupdater.HotUpdaterSandboxActivity")) return {
|
|
1179
|
+
code: "SANDBOX_MISMATCH",
|
|
1180
|
+
message: `The .${kind} is a sandbox build, but its launcher is ${entry.launchers.join(", ") || "missing"}.\nBuild it again with hot-updater native-build.`
|
|
1181
|
+
};
|
|
1182
|
+
if (sandbox.code && !isSandboxChannel(channel)) return {
|
|
1183
|
+
code: "SANDBOX_MISMATCH",
|
|
1184
|
+
message: `The .${kind} is a sandbox build, which can't register on channel ${channel}.\nUse a sandbox-<scheme> channel.`
|
|
1185
|
+
};
|
|
1186
|
+
if (!sandbox.code && isSandboxChannel(channel)) return {
|
|
1187
|
+
code: "SANDBOX_MISMATCH",
|
|
1188
|
+
message: `Channel ${channel} is reserved for sandbox builds, and the .${kind} isn't one.\nUse another channel, or build it with sandbox: true.`
|
|
1189
|
+
};
|
|
1190
|
+
return null;
|
|
1191
|
+
};
|
|
1005
1192
|
const isUnreadable = (value) => typeof value === "object" && value !== null && "unreadable" in value;
|
|
1006
1193
|
/**
|
|
1007
1194
|
* Whether a read artifact can be imported as a native build, whose built-in
|
|
@@ -1052,6 +1239,8 @@ const checkNativeArtifact = (info, { updateStrategy, appIds = [], channel: chann
|
|
|
1052
1239
|
refuse("INVALID_SCOPE", `The app's channel ${JSON.stringify(channel)} is no channel a release can target: ${error instanceof Error ? error.message : String(error)}.`);
|
|
1053
1240
|
}
|
|
1054
1241
|
}
|
|
1242
|
+
const sandboxProblem = sandboxArtifactProblem(info, channel);
|
|
1243
|
+
if (sandboxProblem !== null) problems.push(sandboxProblem);
|
|
1055
1244
|
if (appIds.length > 0 && info.appId !== null && !appIds.includes(info.appId)) warnings.push({
|
|
1056
1245
|
code: "APP_ID_MISMATCH",
|
|
1057
1246
|
message: `The app is ${info.appId}, which no ${info.platform} scheme in nativeBuild names (${appIds.join(", ")}): check that it is this project's build.`
|
|
@@ -1087,4 +1276,136 @@ const alreadyImportedProblem = (minBundleId, nativeBuildId) => ({
|
|
|
1087
1276
|
message: nativeBuildId === null ? `The built-in bundle ${minBundleId} is already registered, as a bundle that is no native build's.` : `This build is already registered as native build ${nativeBuildId} (built-in bundle ${minBundleId}).`
|
|
1088
1277
|
});
|
|
1089
1278
|
//#endregion
|
|
1090
|
-
|
|
1279
|
+
//#region src/nativeArtifact/iosInstall.ts
|
|
1280
|
+
const MiB = 1048576;
|
|
1281
|
+
/** The most an app's Info.plist or a provisioning profile holds here. */
|
|
1282
|
+
const METADATA_BYTES = 1 * MiB;
|
|
1283
|
+
/** The most reading what an install needs inflates, across every entry. */
|
|
1284
|
+
const READ_BUDGET_BYTES = 8 * MiB;
|
|
1285
|
+
/** How long reading what an install needs may take. */
|
|
1286
|
+
const READ_TIMEOUT_MS = 3e4;
|
|
1287
|
+
/** The extensions an app may carry, each with a profile to read. */
|
|
1288
|
+
const MAX_NESTED_PROFILES = 16;
|
|
1289
|
+
const APP_INFO_PLIST = /^Payload\/[^/]+\.app\/Info\.plist$/u;
|
|
1290
|
+
/**
|
|
1291
|
+
* What installs with the app and must list the device too: not a Watch app's
|
|
1292
|
+
* profile, which lists watches and installs on one.
|
|
1293
|
+
*/
|
|
1294
|
+
const NESTED_PROFILE = /^Payload\/[^/]+\.app\/(?:PlugIns|Extensions|AppClips)\/[^/]+\.(?:appex|app)\/embedded\.mobileprovision$/u;
|
|
1295
|
+
/** A device's UDID: 40 hex digits, or 8 and 16 joined by a hyphen. */
|
|
1296
|
+
const UDID = /^(?:[0-9a-f]{40}|[0-9a-f]{8}-[0-9a-f]{16})$/iu;
|
|
1297
|
+
/**
|
|
1298
|
+
* The form two UDIDs compare in, or null for text that isn't one. Apple writes
|
|
1299
|
+
* them in either case, and App Store Connect takes the device's own.
|
|
1300
|
+
*/
|
|
1301
|
+
const normalizeIosUdid = (value) => {
|
|
1302
|
+
const trimmed = value.trim();
|
|
1303
|
+
return UDID.test(trimmed) ? trimmed.toLowerCase() : null;
|
|
1304
|
+
};
|
|
1305
|
+
/**
|
|
1306
|
+
* The bytes from `<?xml` through `</plist>`, or null without both: a CMS
|
|
1307
|
+
* message, as a provisioning profile and a Profile Service answer are, holds
|
|
1308
|
+
* its plist as plain text among binary bytes.
|
|
1309
|
+
*/
|
|
1310
|
+
const sliceEmbeddedPlist = (bytes) => {
|
|
1311
|
+
const text = new TextDecoder("latin1").decode(bytes);
|
|
1312
|
+
const start = text.indexOf("<?xml");
|
|
1313
|
+
if (start === -1) return null;
|
|
1314
|
+
const end = text.indexOf("</plist>", start);
|
|
1315
|
+
return end === -1 ? null : bytes.subarray(start, end + 8);
|
|
1316
|
+
};
|
|
1317
|
+
/**
|
|
1318
|
+
* The UDID in a Profile Service answer's body, or null when it has no one
|
|
1319
|
+
* well-formed UDID. It only finds the text: nothing here verifies the signature
|
|
1320
|
+
* around it, so nothing automated may trust the result.
|
|
1321
|
+
*/
|
|
1322
|
+
const readProfileServiceUdid = (body) => {
|
|
1323
|
+
const plist = sliceEmbeddedPlist(body);
|
|
1324
|
+
if (plist === null) return null;
|
|
1325
|
+
const matches = [...new TextDecoder().decode(plist).matchAll(/<key>\s*UDID\s*<\/key>\s*<string>([^<]*)<\/string>/gu)];
|
|
1326
|
+
if (matches.length !== 1) return null;
|
|
1327
|
+
const udid = matches[0][1].trim();
|
|
1328
|
+
return normalizeIosUdid(udid) === null ? null : udid;
|
|
1329
|
+
};
|
|
1330
|
+
const unreadable = (message) => new NativeArtifactError("UNREADABLE", message);
|
|
1331
|
+
const isDict = (value) => typeof value === "object" && value !== null && !Array.isArray(value) && !(value instanceof Uint8Array);
|
|
1332
|
+
const readProfile = (bytes, name) => {
|
|
1333
|
+
const plistBytes = sliceEmbeddedPlist(bytes);
|
|
1334
|
+
if (plistBytes === null) throw unreadable(`The archive's ${name} holds no property list.`);
|
|
1335
|
+
const plist = parsePlist(plistBytes);
|
|
1336
|
+
if (!isDict(plist)) throw unreadable(`The archive's ${name} is corrupt.`);
|
|
1337
|
+
const listed = plist["ProvisionedDevices"];
|
|
1338
|
+
if (listed !== void 0 && !Array.isArray(listed)) throw unreadable(`The archive's ${name} is corrupt.`);
|
|
1339
|
+
const devices = listed?.map((device) => {
|
|
1340
|
+
if (typeof device !== "string") throw unreadable(`The archive's ${name} is corrupt.`);
|
|
1341
|
+
return device.trim().toLowerCase();
|
|
1342
|
+
});
|
|
1343
|
+
const expiration = plist["ExpirationDate"];
|
|
1344
|
+
const expiresAtMs = typeof expiration === "string" ? Date.parse(expiration) : null;
|
|
1345
|
+
if (expiresAtMs !== null && Number.isNaN(expiresAtMs)) throw unreadable(`The archive's ${name} is corrupt.`);
|
|
1346
|
+
const entitlements = plist["Entitlements"];
|
|
1347
|
+
const applicationId = isDict(entitlements) ? entitlements["application-identifier"] : void 0;
|
|
1348
|
+
const bundleId = typeof applicationId === "string" ? applicationId.slice(applicationId.indexOf(".") + 1) : null;
|
|
1349
|
+
return {
|
|
1350
|
+
allDevices: plist["ProvisionsAllDevices"] === true,
|
|
1351
|
+
devices: devices === void 0 ? null : new Set(devices),
|
|
1352
|
+
getTaskAllow: isDict(entitlements) && entitlements["get-task-allow"] === true,
|
|
1353
|
+
expiresAtMs,
|
|
1354
|
+
bundleId: bundleId === null || bundleId.includes("*") ? null : bundleId
|
|
1355
|
+
};
|
|
1356
|
+
};
|
|
1357
|
+
const distributionOf = (profile) => {
|
|
1358
|
+
if (profile === null) return "store";
|
|
1359
|
+
if (profile.allDevices) return "enterprise";
|
|
1360
|
+
if (profile.devices === null) return "store";
|
|
1361
|
+
return profile.getTaskAllow ? "development" : "ad-hoc";
|
|
1362
|
+
};
|
|
1363
|
+
/**
|
|
1364
|
+
* What an `.ipa` installs as, reading only its directory, its app's Info.plist
|
|
1365
|
+
* and its provisioning profiles: a few requests of a remote file, whatever its
|
|
1366
|
+
* size. The `.ipa` is untrusted, and each part is bounded before it is read.
|
|
1367
|
+
* Throws `NativeArtifactError` for one it can't read.
|
|
1368
|
+
*/
|
|
1369
|
+
const readIpaInstallInfo = async (source) => {
|
|
1370
|
+
const entries = await readZipDirectory(source);
|
|
1371
|
+
const infoPlists = filterZipEntries(entries, (name) => APP_INFO_PLIST.test(name));
|
|
1372
|
+
const infoEntry = infoPlists[0];
|
|
1373
|
+
if (infoEntry === void 0 || infoPlists.length > 1) throw unreadable("The .ipa has no one Payload/<App>.app/Info.plist.");
|
|
1374
|
+
const budget = {
|
|
1375
|
+
remaining: READ_BUDGET_BYTES,
|
|
1376
|
+
deadline: Date.now() + READ_TIMEOUT_MS
|
|
1377
|
+
};
|
|
1378
|
+
const read = (entry) => readZipEntry(source, entry, METADATA_BYTES, budget);
|
|
1379
|
+
const info = parsePlist(await read(infoEntry));
|
|
1380
|
+
if (!isDict(info)) throw unreadable("The app's Info.plist is corrupt.");
|
|
1381
|
+
const bundleId = info["CFBundleIdentifier"];
|
|
1382
|
+
const bundleVersion = info["CFBundleVersion"];
|
|
1383
|
+
if (typeof bundleId !== "string" || bundleId === "") throw unreadable("The app's Info.plist has no CFBundleIdentifier.");
|
|
1384
|
+
if (typeof bundleVersion !== "string" || bundleVersion === "") throw unreadable("The app's Info.plist has no CFBundleVersion.");
|
|
1385
|
+
const displayName = info["CFBundleDisplayName"];
|
|
1386
|
+
const bundleName = info["CFBundleName"];
|
|
1387
|
+
const title = typeof displayName === "string" && displayName !== "" ? displayName : typeof bundleName === "string" && bundleName !== "" ? bundleName : bundleId;
|
|
1388
|
+
const appDirectory = infoEntry.name.slice(0, -10);
|
|
1389
|
+
const profileEntry = findZipEntry(entries, `${appDirectory}embedded.mobileprovision`);
|
|
1390
|
+
const nestedEntries = filterZipEntries(entries, (name) => name.startsWith(appDirectory) && NESTED_PROFILE.test(name));
|
|
1391
|
+
if (nestedEntries.length > MAX_NESTED_PROFILES) throw unreadable(`The app has more than ${MAX_NESTED_PROFILES} extensions with profiles.`);
|
|
1392
|
+
const profile = profileEntry === void 0 ? null : readProfile(await read(profileEntry), profileEntry.name);
|
|
1393
|
+
const nested = [];
|
|
1394
|
+
for (const entry of nestedEntries) nested.push(readProfile(await read(entry), entry.name));
|
|
1395
|
+
const distribution = distributionOf(profile);
|
|
1396
|
+
const profiles = profile === null ? nested : [profile, ...nested];
|
|
1397
|
+
const listDevices = distribution === "ad-hoc" || distribution === "development";
|
|
1398
|
+
const lists = profiles.flatMap(({ allDevices, devices }) => allDevices ? [] : [devices ?? /* @__PURE__ */ new Set()]);
|
|
1399
|
+
const expiries = profiles.flatMap(({ expiresAtMs }) => expiresAtMs === null ? [] : [expiresAtMs]);
|
|
1400
|
+
return {
|
|
1401
|
+
bundleId,
|
|
1402
|
+
bundleVersion,
|
|
1403
|
+
title,
|
|
1404
|
+
distribution,
|
|
1405
|
+
devices: listDevices ? [...lists[0] ?? []].filter((udid) => lists.every((list) => list.has(udid))).sort() : [],
|
|
1406
|
+
expiresAtMs: expiries.length === 0 ? null : Math.min(...expiries),
|
|
1407
|
+
profileBundleIds: [.../* @__PURE__ */ new Set([bundleId, ...profiles.flatMap((item) => item.bundleId ?? [])])]
|
|
1408
|
+
};
|
|
1409
|
+
};
|
|
1410
|
+
//#endregion
|
|
1411
|
+
export { NativeArtifactError, SANDBOX_ACTIVITY, SANDBOX_APPLICATION_ID_SUFFIX, SANDBOX_CODE_MARKER, SANDBOX_INFO_PLIST_KEY, SANDBOX_META_DATA, alreadyImportedProblem, blobSource, checkNativeArtifact, createPreviewLink, findZipEntry, isSandboxChannel, isSandboxUrlScheme, normalizeIosUdid, openZipEntry, parsePreviewLink, readIpaInstallInfo, readNativeArtifact, readProfileServiceUdid, readZipDirectory, sandboxArtifactProblem, sandboxChannelOf, sandboxUrlSchemeOf };
|