@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.
@@ -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
- /** An XML property list, as some tools leave an app's Info.plist. */
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.replace(/<!--[\s\S]*?-->/gu, "").replace(/<\?[\s\S]*?\?>/gu, "").replace(/<!DOCTYPE[^>]*>/giu, "").split(/(<[^>]+>)/u);
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$1 = 1048576;
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$1,
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$1
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$1)} MB, more than any artifact's.`);
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([UUID_LENGTH, 64]);
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
- export { type ByteSource, type ImportableNativeBuild, type NativeArtifactCheck, NativeArtifactError, type NativeArtifactInfo, type NativeArtifactKind, type NativeArtifactProblem, type NativeArtifactProblemCode, type UnreadableValue, type ZipEntry, alreadyImportedProblem, blobSource, checkNativeArtifact, findZipEntry, openZipEntry, readNativeArtifact, readZipDirectory };
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
- export { type ByteSource, type ImportableNativeBuild, type NativeArtifactCheck, NativeArtifactError, type NativeArtifactInfo, type NativeArtifactKind, type NativeArtifactProblem, type NativeArtifactProblemCode, type UnreadableValue, type ZipEntry, alreadyImportedProblem, blobSource, checkNativeArtifact, findZipEntry, openZipEntry, readNativeArtifact, readZipDirectory };
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 };
@@ -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
- /** An XML property list, as some tools leave an app's Info.plist. */
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.replace(/<!--[\s\S]*?-->/gu, "").replace(/<\?[\s\S]*?\?>/gu, "").replace(/<!DOCTYPE[^>]*>/giu, "").split(/(<[^>]+>)/u);
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$1 = 1048576;
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$1,
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$1
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$1)} MB, more than any artifact's.`);
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([UUID_LENGTH, 64]);
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
- export { NativeArtifactError, alreadyImportedProblem, blobSource, checkNativeArtifact, findZipEntry, openZipEntry, readNativeArtifact, readZipDirectory };
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 };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hot-updater/protocol",
3
- "version": "1.2.0-rc.12",
3
+ "version": "1.2.0-rc.14",
4
4
  "type": "module",
5
5
  "description": "The data formats and pure computations that Hot Updater's app and server share",
6
6
  "sideEffects": false,