catalyst-core 1.2.0-canary.3 → 1.2.0-canary.4

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/changelog.md CHANGED
@@ -1,5 +1,39 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.2.0] - Unreleased
4
+
5
+ This release builds, runs and releases the Android and iOS apps from the CLI, without opening Android Studio or Xcode. The web runtime has no breaking changes. Universal apps should read the breaking changes below and the 1.1.x to 1.2.0 section of the Version Upgrades guide. After upgrading, run `npm audit fix`: the `body-parser` floor is raised to `^1.20.9`.
6
+
7
+ ### One command to run, one to release
8
+
9
+ New apps have four scripts. `npm run android` and `npm run ios` check the toolchain and install what is missing after asking, start the dev server when nothing answers on `NODE_SERVER_PORT`, and build, install and open the app on an emulator, simulator or phone. Android also saves the app as a debug APK in `build-output/android/debug/` that can be shared. `npm run android:release` writes a signed App Bundle, a signed APK and `upload_certificate.pem` to `build-output/android/<version>-<build>/`, and `npm run ios:release` writes a signed IPA and a zip of its dSYMs to `build-output/ios/<version>-<build>/`. The scripts run `catalyst buildApp:android` and `catalyst buildApp:ios`, with `--release` for the store builds, so older apps keep `buildApp:<platform>` and build a release with `npm run buildApp:<platform> -- --release`. `setupEmulator:android` and `setupEmulator:ios` still work and are no longer needed.
10
+
11
+ ### App config
12
+
13
+ A native app needs `WEBVIEW_CONFIG.appId`, `appName` and `url`. `appId` is the Android application id and the iOS bundle id, and `android.packageName` and `ios.bundleIdentifier` (then `ios.appBundleId`) win over it. `url` is the deployed site a release opens; debug builds load the dev server. New apps get `appId` and `appName` from the project name. A release run in a terminal asks once for an app id or website that is missing or a placeholder, and saves the answer to `config/config.json`.
14
+
15
+ ### Signing
16
+
17
+ Signing files live in `credentials/android/` and `credentials/ios/`, which new apps keep out of git. The first Android release in a terminal creates one upload key per app after asking; back it up, because Google Play only accepts updates signed with it. `npx catalyst credentials:export <platform>` prints the folder as one value for the `CATALYST_ANDROID_CREDENTIALS` or `CATALYST_IOS_CREDENTIALS` CI secret. On a Mac signed in to Xcode, an iOS release signs with the Apple account or an installed App Store profile and needs no files; the order the build tries is in the iOS release guide. The version comes from `package.json`, and the build number is `CATALYST_BUILD_NUMBER` or the minutes since 2024-01-01 UTC. Android apps target API 36 and align their native libraries for 16 KB memory pages.
18
+
19
+ ### Breaking changes
20
+
21
+ - An iOS bundle id with an underscore fails with `NATIVE-010`. Set `ios.bundleIdentifier` to an id without one.
22
+ - A release with a placeholder app id (any `com.example.` id or a template id) fails with `NATIVE-003`. A release whose start URL is `localhost`, a private or link-local address, a `.local` name or this machine fails with `NATIVE-001`, unless `WEBVIEW_CONFIG.allowPrivateHost` is `true`. Run in a terminal, the build asks for the value instead.
23
+ - A debug build no longer installs on a connected phone by itself. A terminal run asks once where to run the app and remembers the answer in `.catalyst/local.json`; without a terminal the emulator or simulator is used. Pass `--device` for the phone.
24
+ - The first debug build rewrites `config/config.json` once: a value equal to this machine's LAN address, and a private `LOCAL_IP` that `NODE_SERVER_HOSTNAME` also holds, go back to `localhost`. Each change is printed. It never runs for a release, in CI or with `allowPrivateHost: true`.
25
+ - Keystore passwords in `android.keystoreConfig` and `android.keystore` are no longer used. Apps that uploaded a build signed with the keystore shipped in the template, or with a key generated by an earlier release build, need an upload key reset in Play Console.
26
+
27
+ ### create-catalyst-app
28
+
29
+ New apps get a new starter page, an animated HTML splash screen that is on by default (`splashScreen.minDuration` `7000`), a restyled offline page, a placeholder `public/icon.png`, and a `server/index.js` with an empty stub for `preServerInit` and each per-request hook. Their `.gitignore` covers `credentials/`, `.catalyst/` and `build-output/`.
30
+
31
+ ### Fixes
32
+
33
+ - `MetaTag` waits for a `split()` route's chunk before it replaces the head tags, so the server's tags are no longer removed before the route's `setMetaData` is available.
34
+ - Startup warns when `server/index.js` puts a lifecycle hook inside `export default { ... }`, where Catalyst never calls it. Export each hook by name.
35
+ - `npm run start` prints a hint when `NODE_SERVER_HOSTNAME` is not an address of this machine.
36
+
3
37
  ## [1.1.0] - 2026-10-22
4
38
 
5
39
  This release contains everything in 1.0.2, namely the HTML splash screen, experimental WebMCP support and the new CLI output, plus one fix. There are no breaking API changes.
package/dist/cli/hints.js CHANGED
@@ -27,6 +27,11 @@ const HINTS = [
27
27
  : `Something is already on ${where}. Stop it, or set NODE_SERVER_PORT in config/config.json.`
28
28
  },
29
29
  },
30
+ {
31
+ test: /listen EADDRNOTAVAIL\b(?:.*?address not available\s+(\S+):\d+)?/i,
32
+ hint: (match) =>
33
+ `NODE_SERVER_HOSTNAME ${match[1] ? `${match[1]} ` : ""}is not an address of this machine; set it to localhost (or this machine's address) in config/config.json.`,
34
+ },
30
35
  {
31
36
  test: /SDK location not found|ANDROID_HOME/i,
32
37
  hint: () =>
@@ -330,9 +330,9 @@ export const ERROR_DEFINITIONS = {
330
330
  category: "IOS",
331
331
  defaultMessage: "iOS code signing is not set up",
332
332
  defaultDetails:
333
- "A device or release build needs a signing identity and provisioning profile. A release signs with the first of: a .p12 and its profile in credentials/ios/, an installed ios.provisioningProfile, an App Store Connect API key in credentials/ios/, an App Store profile installed on this Mac for the app id (with its certificate in the keychain), or the Apple account signed in to Xcode for WEBVIEW_CONFIG.ios.developmentTeam. xcodebuild reported a signing failure, or a release build has no team to sign for.",
333
+ "A device or release build needs a signing identity and provisioning profile. A release signs with the first of: a .p12 and its profile in credentials/ios/, an installed ios.provisioningProfile, an App Store Connect API key in credentials/ios/, an App Store profile installed on this Mac for the app id (with its certificate in the keychain), or the Apple account signed in to Xcode for WEBVIEW_CONFIG.ios.developmentTeam. xcodebuild reported a signing failure, or a release build has no team to sign for. When an App Store release finds no App Store profile for its bundle id and has nothing else to sign with, the error says so first.",
334
334
  suggestedAction:
335
- "Sign in to Xcode (Xcode > Settings > Accounts) with the Apple account that has your team, or install that team's App Store profile for your appId. In CI, put the signing files in credentials/ios/ and set CATALYST_IOS_CREDENTIALS to the output of catalyst credentials:export ios.",
335
+ "For an App Store release, install the App Store provisioning profile for your bundle id (download it from developer.apple.com, or with Xcode > Settings > Accounts > Download Manual Profiles) or set WEBVIEW_CONFIG.ios.provisioningProfile to an installed profile's name. Otherwise, and for a device build, sign in to Xcode (Xcode > Settings > Accounts) with the Apple account that has your company's team. In CI, put the signing files in credentials/ios/ and set CATALYST_IOS_CREDENTIALS to the output of catalyst credentials:export ios.",
336
336
  },
337
337
  [ERROR_CODES.IOS_ASC_API_KEY_INVALID]: {
338
338
  category: "IOS",
@@ -226,8 +226,8 @@
226
226
  "IOS-010": {
227
227
  "category": "IOS",
228
228
  "message": "iOS code signing is not set up",
229
- "details": "A device or release build needs a signing identity and provisioning profile. A release signs with the first of: a .p12 and its profile in credentials/ios/, an installed ios.provisioningProfile, an App Store Connect API key in credentials/ios/, an App Store profile installed on this Mac for the app id (with its certificate in the keychain), or the Apple account signed in to Xcode for WEBVIEW_CONFIG.ios.developmentTeam. xcodebuild reported a signing failure, or a release build has no team to sign for.",
230
- "suggestedAction": "Sign in to Xcode (Xcode > Settings > Accounts) with the Apple account that has your team, or install that team's App Store profile for your appId. In CI, put the signing files in credentials/ios/ and set CATALYST_IOS_CREDENTIALS to the output of catalyst credentials:export ios.",
229
+ "details": "A device or release build needs a signing identity and provisioning profile. A release signs with the first of: a .p12 and its profile in credentials/ios/, an installed ios.provisioningProfile, an App Store Connect API key in credentials/ios/, an App Store profile installed on this Mac for the app id (with its certificate in the keychain), or the Apple account signed in to Xcode for WEBVIEW_CONFIG.ios.developmentTeam. xcodebuild reported a signing failure, or a release build has no team to sign for. When an App Store release finds no App Store profile for its bundle id and has nothing else to sign with, the error says so first.",
230
+ "suggestedAction": "For an App Store release, install the App Store provisioning profile for your bundle id (download it from developer.apple.com, or with Xcode > Settings > Accounts > Download Manual Profiles) or set WEBVIEW_CONFIG.ios.provisioningProfile to an installed profile's name. Otherwise, and for a device build, sign in to Xcode (Xcode > Settings > Accounts) with the Apple account that has your company's team. In CI, put the signing files in credentials/ios/ and set CATALYST_IOS_CREDENTIALS to the output of catalyst credentials:export ios.",
231
231
  "docUrl": "https://github.com/tata1mg/catalyst-core/blob/main/errors/IOS/IOS-010.md"
232
232
  },
233
233
  "IOS-011": {
@@ -44,6 +44,7 @@ const {
44
44
  planSigningForConfig,
45
45
  readIosCredentials,
46
46
  resolveExportMethod,
47
+ settleInstalledProfile,
47
48
  settleXcodeTeam
48
49
  } = require("./buildIos/signing.js");
49
50
  const {
@@ -96,10 +97,11 @@ async function askAndSave(options) {
96
97
  /**
97
98
  * A release that will sign with the Apple account in Xcode needs
98
99
  * ios.developmentTeam. When it is unset, use the one team Xcode has that can
99
- * sign this export (or ask which, in a terminal). In a terminal the team is
100
- * saved to config/config.json; without one it is used for this build only.
101
- * Plans with planSigningForConfig, the same function createIosBuild signs
102
- * with. Returns the appConfig to build with.
100
+ * sign this export (or ask which, in a terminal). A release that auto-picked
101
+ * an installed App Store profile pins it as ios.provisioningProfile. In a
102
+ * terminal the team or profile is saved to config/config.json; without one
103
+ * it is used for this build only. Plans with planSigningForConfig, the same
104
+ * function createIosBuild signs with. Returns the appConfig to build with.
103
105
  *
104
106
  * Config and credential errors are left for createIosBuild, which reports
105
107
  * them the usual way.
@@ -140,6 +142,16 @@ async function ensureSigningTeam({
140
142
  found?.cleanup();
141
143
  }
142
144
  const file = path.join(cwd, "config", "config.json");
145
+ const profile = settleInstalledProfile({
146
+ plan,
147
+ log,
148
+ save: name => writeConfigKeys(file, {
149
+ "WEBVIEW_CONFIG.ios.provisioningProfile": name
150
+ }, {
151
+ log
152
+ })
153
+ });
154
+ if (profile?.saved) return loadAppConfig();
143
155
  const team = await settleXcodeTeam({
144
156
  plan,
145
157
  release,
@@ -193,7 +205,7 @@ async function main() {
193
205
  BUILD_OUTPUT_PATH
194
206
  };
195
207
  for (const change of migration.changes) {
196
- log(`config.json ${change.path}: ${change.from} -> ${change.to}`, "info");
208
+ log(`config.json ${change.path}: ${change.from} -> ${change.to}`, "note");
197
209
  }
198
210
  const release = flags.release || isReleaseBuildType(appConfig.WEBVIEW_CONFIG?.ios?.buildType);
199
211
  // A store build in a terminal asks once for a missing app id or
@@ -35,6 +35,7 @@ const {
35
35
  createProfileScanner,
36
36
  pickInstalledProfile,
37
37
  XCODE_TEAM_HINT,
38
+ missingProfileError,
38
39
  buildExportOptions,
39
40
  writeExportOptionsPlist,
40
41
  isSigningFailure,
@@ -186,7 +187,11 @@ function createIosBuild(config, {
186
187
  const apiKey = release ? resolveApiKey() : null;
187
188
  if (release && signingPlan.style === "automatic" && !signingPlan.teamId) {
188
189
  credentials?.cleanup();
189
- throw nativeError("IOS-010", "a signed release with automatic signing needs WEBVIEW_CONFIG.ios.developmentTeam", signingPlan.source === "Xcode account" ? {
190
+ const detail = "a signed release with automatic signing needs WEBVIEW_CONFIG.ios.developmentTeam";
191
+ const missing = missingProfileError(signingPlan.missingProfileFor, detail);
192
+ throw missing ? nativeError("IOS-010", missing.detail, {
193
+ hint: missing.hint
194
+ }) : nativeError("IOS-010", detail, signingPlan.source === "Xcode account" ? {
190
195
  hint: XCODE_TEAM_HINT
191
196
  } : {});
192
197
  }
@@ -611,7 +616,9 @@ function createIosBuild(config, {
611
616
  try {
612
617
  await runCommandStreaming("xcodebuild", ["archive", "-project", `${PROJECT_NAME}.xcodeproj`, "-scheme", SCHEME_NAME, "-configuration", "Release", "-destination", "generic/platform=iOS", "-archivePath", archivePath, ...signingArgs]);
613
618
  } catch (error) {
614
- const failure = isSigningFailure(error.output) ? signingSetupError("Xcode could not sign the release archive", error) : error;
619
+ const failure = isSigningFailure(error.output) ? signingSetupError("Xcode could not sign the release archive", error, {
620
+ missingProfileFor: signing.missingProfileFor
621
+ }) : error;
615
622
  failure.stage = "Archiving the release build";
616
623
  throw failure;
617
624
  }
@@ -629,7 +636,9 @@ function createIosBuild(config, {
629
636
  try {
630
637
  await runCommandStreaming("xcodebuild", ["-exportArchive", "-archivePath", archivePath, "-exportPath", exportPath, "-exportOptionsPlist", optionsPlist, ...(signing.style === "automatic" ? xcodeAuthArgs(ctx.apiKey) : [])]);
631
638
  } catch (error) {
632
- const failure = isSigningFailure(error.output) ? signingSetupError("Xcode could not sign the IPA for export", error) : error;
639
+ const failure = isSigningFailure(error.output) ? signingSetupError("Xcode could not sign the IPA for export", error, {
640
+ missingProfileFor: signing.missingProfileFor
641
+ }) : error;
633
642
  failure.stage = "Exporting the IPA";
634
643
  throw failure;
635
644
  }
@@ -10,6 +10,7 @@ exports.createProfileScanner = createProfileScanner;
10
10
  exports.findInstalledProfileIdentity = findInstalledProfileIdentity;
11
11
  exports.isReleaseBuildType = isReleaseBuildType;
12
12
  exports.isSigningFailure = isSigningFailure;
13
+ exports.missingProfileError = missingProfileError;
13
14
  exports.needsXcodeTeam = needsXcodeTeam;
14
15
  exports.parseIdentities = parseIdentities;
15
16
  exports.parseIdentity = parseIdentity;
@@ -27,6 +28,7 @@ exports.readProvisioningProfile = readProvisioningProfile;
27
28
  exports.readXcodeTeams = readXcodeTeams;
28
29
  exports.resolveExportMethod = resolveExportMethod;
29
30
  exports.scanInstalledProfiles = scanInstalledProfiles;
31
+ exports.settleInstalledProfile = settleInstalledProfile;
30
32
  exports.settleXcodeTeam = settleXcodeTeam;
31
33
  exports.setupP12Signing = setupP12Signing;
32
34
  exports.signingFailureLine = signingFailureLine;
@@ -296,13 +298,19 @@ function planIosSigning({
296
298
  if (release && exportMethod === "app-store-connect" && bundleId && scanProfiles) {
297
299
  const scan = scanProfiles();
298
300
  const {
299
- identity
301
+ identity,
302
+ missing
300
303
  } = pickInstalledProfile(scan, {
301
304
  bundleId,
302
305
  teamId,
303
306
  needsPush: notifications,
304
307
  now
305
308
  });
309
+ // Nothing else to sign with: IOS-010 leads with the missing profile.
310
+ if (missing) return {
311
+ ...automatic("Xcode account"),
312
+ missingProfileFor: bundleId
313
+ };
306
314
  if (identity) {
307
315
  // By name, as people know it, unless another installed profile shares it.
308
316
  const sameName = scan.profiles.filter(profile => profile.name === identity.profileName).length;
@@ -580,7 +588,9 @@ function isAppStoreProfile(profile) {
580
588
  * profile (.mobileprovision, no devices, get-task-allow false) for exactly
581
589
  * this bundle id, for `teamId` when set, with aps-environment when
582
590
  * `needsPush`. Expired profiles are skipped; the newest one whose
583
- * certificate is in the keychain wins. Without a match, `reason` says why.
591
+ * certificate is in the keychain wins. Without a match, `reason` says why,
592
+ * and `missing` is true when no App Store profile for the bundle id is
593
+ * installed at all.
584
594
  *
585
595
  * @param {ReturnType<typeof scanInstalledProfiles>} scan
586
596
  * @param {object} options
@@ -589,7 +599,7 @@ function isAppStoreProfile(profile) {
589
599
  * @param {string} [options.teamId]
590
600
  * @param {boolean} [options.needsPush]
591
601
  * @param {Date} [options.now]
592
- * @returns {{ identity: { sha1: string, name: string, teamId?: string, uuid: string, profileName?: string } | null, reason?: string }}
602
+ * @returns {{ identity: { sha1: string, name: string, teamId?: string, uuid: string, profileName?: string } | null, reason?: string, missing?: boolean }}
593
603
  */
594
604
  function pickInstalledProfile(scan, {
595
605
  profile,
@@ -611,13 +621,19 @@ function pickInstalledProfile(scan, {
611
621
  };
612
622
  let candidates = scan?.profiles || [];
613
623
  /** Keep the candidates that pass; when none does, that check is the reason. */
614
- const steps = wanted ? [[c => c.name === wanted || String(c.uuid).toLowerCase() === wanted.toLowerCase(), () => "no installed profile has that name or UUID"], [c => !(c.expires && c.expires <= now.getTime()), left => `it expired on ${left[0].expirationDate}`], [c => profileMatchesBundleId(c.applicationIdentifier, bundleId), left => `it is for ${appIdOf(left[0])}, not ${bundleId}`]] : [[c => c.file.endsWith(".mobileprovision") && c.applicationIdentifier && appIdOf(c) === bundleId, () => `no installed profile is for exactly ${bundleId}`], [isAppStoreProfile, () => `no installed App Store profile is for ${bundleId}`], [c => !teamId || c.teamId === teamId, () => `no installed App Store profile for ${bundleId} is for team ${teamId}`], [c => !needsPush || Boolean(c.apsEnvironment), () => `no installed App Store profile for ${bundleId} has push notifications`], [c => !(c.expires && c.expires <= now.getTime()), () => `every installed App Store profile for ${bundleId} has expired`]];
615
- for (const [keep, reason] of steps) {
624
+ const steps = wanted ? [[c => c.name === wanted || String(c.uuid).toLowerCase() === wanted.toLowerCase(), () => "no installed profile has that name or UUID"], [c => !(c.expires && c.expires <= now.getTime()), left => `it expired on ${left[0].expirationDate}`], [c => profileMatchesBundleId(c.applicationIdentifier, bundleId), left => `it is for ${appIdOf(left[0])}, not ${bundleId}`]] : [[c => c.file.endsWith(".mobileprovision") && c.applicationIdentifier && appIdOf(c) === bundleId, () => `no installed profile is for exactly ${bundleId}`, true], [isAppStoreProfile, () => `no installed App Store profile is for ${bundleId}`, true], [c => !teamId || c.teamId === teamId, () => `no installed App Store profile for ${bundleId} is for team ${teamId}`], [c => !needsPush || Boolean(c.apsEnvironment), () => `no installed App Store profile for ${bundleId} has push notifications`], [c => !(c.expires && c.expires <= now.getTime()), () => `every installed App Store profile for ${bundleId} has expired`]];
625
+ for (const [keep, reason, missing] of steps) {
616
626
  const left = candidates.filter(keep);
617
- if (!left.length) return {
618
- identity: null,
619
- reason: reason(candidates)
620
- };
627
+ if (!left.length) {
628
+ return missing ? {
629
+ identity: null,
630
+ reason: reason(candidates),
631
+ missing: true
632
+ } : {
633
+ identity: null,
634
+ reason: reason(candidates)
635
+ };
636
+ }
621
637
  candidates = left;
622
638
  }
623
639
  const newestFirst = [...candidates].sort((a, b) => b.expires - a.expires);
@@ -685,6 +701,25 @@ function findInstalledProfileIdentity({
685
701
 
686
702
  const XCODE_TEAM_HINT = exports.XCODE_TEAM_HINT = "Open Xcode > Settings > Accounts and sign in with the Apple account that has your company's team, or set WEBVIEW_CONFIG.ios.developmentTeam.";
687
703
 
704
+ /**
705
+ * IOS-010's detail and hint for an App Store release that found no installed
706
+ * App Store profile for its bundle id (plan.missingProfileFor) and has no
707
+ * other signing source: the missing profile first, then every way to sign,
708
+ * the Xcode account last. Null for any other plan.
709
+ *
710
+ * @param {string | undefined} bundleId - plan.missingProfileFor
711
+ * @param {string} [detail] - what failed after that, e.g. the Xcode account
712
+ * @returns {{ detail: string, hint: string } | null}
713
+ */
714
+ function missingProfileError(bundleId, detail) {
715
+ if (!bundleId) return null;
716
+ const missing = `no App Store provisioning profile for ${bundleId} is installed on this Mac`;
717
+ return {
718
+ detail: detail ? `${missing}, and ${detail}` : missing,
719
+ hint: `Install the App Store profile for ${bundleId} (download it from developer.apple.com, or with Xcode > Settings > Accounts > Download Manual Profiles), set WEBVIEW_CONFIG.ios.provisioningProfile to an installed profile's name, put a .p12 and its profile or an App Store Connect API key in credentials/ios/, or sign in to Xcode (Xcode > Settings > Accounts) with the Apple account that has your company's team.`
720
+ };
721
+ }
722
+
688
723
  /**
689
724
  * The teams in IDEProvisioningTeamByIdentifier (account id -> teams), one
690
725
  * per team id. `free` is isFreeProvisioningTeam, undefined when Xcode did
@@ -753,31 +788,35 @@ function needsXcodeTeam(plan, {
753
788
  * Store, release-testing or enterprise export, so it is left out unless the
754
789
  * export is for debugging; a team Xcode did not mark either way is kept.
755
790
  * One team is used; several are a question, NATIVE-005 without a terminal;
756
- * none is IOS-010.
791
+ * none is IOS-010, which leads with `missingProfileFor` when that is set.
757
792
  *
758
793
  * @param {object} options
759
794
  * @param {ReturnType<typeof parseXcodeTeams>} options.teams - from readXcodeTeams
760
795
  * @param {string} [options.exportMethod]
761
796
  * @param {boolean} [options.isTTY]
762
797
  * @param {typeof select} [options.choose]
798
+ * @param {string} [options.missingProfileFor] - plan.missingProfileFor
763
799
  * @returns {Promise<{ teamId: string, teamName: string, free: boolean | undefined }>}
764
800
  */
765
801
  async function chooseXcodeTeam({
766
802
  teams,
767
803
  exportMethod = "app-store-connect",
768
804
  isTTY,
769
- choose = _utils.select
805
+ choose = _utils.select,
806
+ missingProfileFor
770
807
  }) {
771
- if (!teams) {
772
- throw (0, _utils.nativeError)("IOS-010", "could not read which teams are signed in to Xcode", {
808
+ const noTeam = detail => {
809
+ const missing = missingProfileError(missingProfileFor, detail);
810
+ return missing ? (0, _utils.nativeError)("IOS-010", missing.detail, {
811
+ hint: missing.hint
812
+ }) : (0, _utils.nativeError)("IOS-010", detail, {
773
813
  hint: XCODE_TEAM_HINT
774
814
  });
775
- }
815
+ };
816
+ if (!teams) throw noTeam("could not read which teams are signed in to Xcode");
776
817
  const usable = exportMethod === "debugging" ? teams : teams.filter(team => team.free !== true);
777
818
  if (!usable.length) {
778
- throw (0, _utils.nativeError)("IOS-010", teams.length ? `Xcode is signed in with a personal team only, and a personal team cannot sign ${exportMethod} builds` : "no Apple account with a team is signed in to Xcode", {
779
- hint: XCODE_TEAM_HINT
780
- });
819
+ throw noTeam(teams.length ? `Xcode is signed in with a personal team only, and a personal team cannot sign ${exportMethod} builds` : "no Apple account with a team is signed in to Xcode");
781
820
  }
782
821
  if (usable.length === 1) return usable[0];
783
822
  const label = team => `${team.teamName || "Unnamed team"} (${team.teamId})`;
@@ -831,7 +870,8 @@ async function settleXcodeTeam({
831
870
  teams: readTeams(),
832
871
  exportMethod,
833
872
  isTTY: interactive,
834
- choose
873
+ choose,
874
+ missingProfileFor: plan.missingProfileFor
835
875
  });
836
876
  const label = `${team.teamName || "Unnamed team"} (${team.teamId})`;
837
877
  if (!interactive) {
@@ -849,6 +889,44 @@ async function settleXcodeTeam({
849
889
  };
850
890
  }
851
891
 
892
+ /**
893
+ * The profile step for a release that auto-picked an installed App Store
894
+ * profile (planIosSigning's "installed profile"): in a terminal save it as
895
+ * ios.provisioningProfile with `save`, so later builds sign with the same
896
+ * one; without a terminal (CI, agents) only use it for this build, since
897
+ * config.json is never written there. Null for any other plan.
898
+ *
899
+ * @param {object} options
900
+ * @param {ReturnType<typeof planIosSigning>} options.plan - from planSigningForConfig
901
+ * @param {boolean} [options.isTTY] - defaults to isInteractive()
902
+ * @param {(profile: string) => void} options.save - writes WEBVIEW_CONFIG.ios.provisioningProfile
903
+ * @param {(message: string, type?: string) => void} [options.log]
904
+ * @returns {{ provisioningProfile: string, saved: boolean } | null}
905
+ */
906
+ function settleInstalledProfile({
907
+ plan,
908
+ isTTY,
909
+ save,
910
+ log = () => {}
911
+ }) {
912
+ if (plan?.source !== "installed profile") return null;
913
+ // The name, or the UUID when another installed profile shares the name.
914
+ const profile = plan.provisioningProfile;
915
+ if (!(isTTY ?? (0, _utils.isInteractive)())) {
916
+ log(`Using the installed profile "${plan.profileName}". Set WEBVIEW_CONFIG.ios.provisioningProfile to keep it.`, "note");
917
+ return {
918
+ provisioningProfile: profile,
919
+ saved: false
920
+ };
921
+ }
922
+ log(`Signing with the installed profile "${plan.profileName}" (team ${plan.teamId})`, "info");
923
+ save(profile);
924
+ return {
925
+ provisioningProfile: profile,
926
+ saved: true
927
+ };
928
+ }
929
+
852
930
  /**
853
931
  * Local signing with credentials/ios/: import the .p12 into a temporary
854
932
  * keychain, put that keychain on the user search list, and install the
@@ -1103,15 +1181,24 @@ function signingFailureLine(output) {
1103
1181
 
1104
1182
  /**
1105
1183
  * The coded error for a signing failure, keeping xcodebuild's transcript for
1106
- * the diagnostic. The detail ends with the line that says what went wrong.
1184
+ * the diagnostic. The detail ends with the line that says what went wrong,
1185
+ * and starts with the missing App Store profile when `missingProfileFor` is set.
1107
1186
  *
1108
1187
  * @param {string} detail
1109
1188
  * @param {any} [cause]
1189
+ * @param {object} [options]
1190
+ * @param {string} [options.missingProfileFor] - plan.missingProfileFor
1110
1191
  * @returns {any}
1111
1192
  */
1112
- function signingSetupError(detail, cause) {
1193
+ function signingSetupError(detail, cause, {
1194
+ missingProfileFor
1195
+ } = {}) {
1113
1196
  const said = signingFailureLine(cause?.output);
1114
- const error = (0, _utils.nativeError)("IOS-010", said ? `${detail}: ${said}` : detail);
1197
+ const failed = said ? `${detail}: ${said}` : detail;
1198
+ const missing = missingProfileError(missingProfileFor, failed);
1199
+ const error = missing ? (0, _utils.nativeError)("IOS-010", missing.detail, {
1200
+ hint: missing.hint
1201
+ }) : (0, _utils.nativeError)("IOS-010", failed);
1115
1202
  if (cause) {
1116
1203
  error.cause = cause;
1117
1204
  if (cause.output) error.output = cause.output;
@@ -415,12 +415,14 @@ async function ensureDevServer({
415
415
  // One-time migration: older setups wrote this machine's LAN address into
416
416
  // config.json (every "localhost", and any http://<ip>), which makes Express
417
417
  // bind only that address and breaks adb reverse. Put localhost back, but only
418
- // for this machine's own addresses: a container IP, 0.0.0.0 or a loopback
419
- // address is the app's own setting.
418
+ // for this machine's own addresses (or the one it had on another network):
419
+ // a container IP, 0.0.0.0 or a loopback address is the app's own setting.
420
420
  // ---------------------------------------------------------------------------
421
421
 
422
422
  const MIGRATION_KEY = "lanAddresses";
423
423
  const IPV4 = /^\d{1,3}(?:\.\d{1,3}){3}$/;
424
+ // 10/8, 172.16/12, 192.168/16 and link-local 169.254/16.
425
+ const PRIVATE_LAN_IPV4 = /^(?:10|172\.(?:1[6-9]|2\d|3[01])|192\.168|169\.254)\./;
424
426
  const isCI = env => {
425
427
  const value = env.CI;
426
428
  return value !== undefined && value !== "" && value !== "0" && String(value).toLowerCase() !== "false";
@@ -429,10 +431,13 @@ const isCI = env => {
429
431
  /**
430
432
  * The changes the migration would make, without writing anything. Only a
431
433
  * value equal to one of this machine's current IPv4 addresses, or an
432
- * http:// URL on one of them, goes back to localhost. Any other address,
433
- * private ones included (a LOCAL_IP pointing at a VPN or LAN staging server),
434
- * is the app's own setting. WEBVIEW_CONFIG.url is never touched, and nothing
435
- * is when WEBVIEW_CONFIG.allowPrivateHost is true.
434
+ * http:// URL on one of them, goes back to localhost. So does a private
435
+ * LOCAL_IP that NODE_SERVER_HOSTNAME also holds: an earlier setup wrote this
436
+ * machine's address on another network into both, and the dev server can't
437
+ * bind it here. Any other address, private ones included (a LOCAL_IP
438
+ * pointing at a VPN or LAN staging server), is the app's own setting.
439
+ * WEBVIEW_CONFIG.url is never touched, and nothing is when
440
+ * WEBVIEW_CONFIG.allowPrivateHost is true.
436
441
  *
437
442
  * @param {object} config - the parsed config.json (not modified)
438
443
  * @param {object} [options]
@@ -451,6 +456,11 @@ function planLanAddressMigration(config, {
451
456
  };
452
457
  // This machine's IPv4 addresses, without loopback and 0.0.0.0/8.
453
458
  const saved = new Set([...localHosts].filter(host => IPV4.test(host) && !/^(?:127|0)\./.test(host)));
459
+ // The LAN address this machine had when LOCAL_IP was written.
460
+ const leftover = typeof wv?.LOCAL_IP === "string" ? wv.LOCAL_IP.trim() : "";
461
+ if (IPV4.test(leftover) && PRIVATE_LAN_IPV4.test(leftover) && String(config.NODE_SERVER_HOSTNAME ?? "").trim() === leftover) {
462
+ saved.add(leftover);
463
+ }
454
464
  if (!saved.size) return {
455
465
  config,
456
466
  changes
@@ -532,8 +542,8 @@ function migrateLanAddresses({
532
542
  } : undefined).changes;
533
543
  const changes = planned.length ? writeConfigKeys(file, Object.fromEntries(planned.map(change => [change.path, change.to]))) : [];
534
544
  if (changes.length) {
535
- log(`Put localhost back in config/config.json where an earlier setup saved this machine's LAN address:`, "info");
536
- for (const change of changes) log(` ${change.path}: ${change.from} -> ${change.to}`, "info");
545
+ log(`Put localhost back in config/config.json where an earlier setup saved this machine's LAN address:`, "note");
546
+ for (const change of changes) log(` ${change.path}: ${change.from} -> ${change.to}`, "note");
537
547
  }
538
548
  const state = (0, _localState.readLocalState)({
539
549
  cwd
@@ -508,7 +508,7 @@ const NATIVE_ERRORS = exports.NATIVE_ERRORS = {
508
508
  },
509
509
  "IOS-010": {
510
510
  message: "iOS code signing is not set up",
511
- hint: "Sign in to Xcode (Xcode > Settings > Accounts) with the Apple account that has your team, or install that team's App Store profile for your appId. In CI, put the signing files in credentials/ios/ and set CATALYST_IOS_CREDENTIALS to the output of catalyst credentials:export ios."
511
+ hint: "For an App Store release, install the App Store provisioning profile for your bundle id (download it from developer.apple.com, or with Xcode > Settings > Accounts > Download Manual Profiles) or set WEBVIEW_CONFIG.ios.provisioningProfile to an installed profile's name. Otherwise, and for a device build, sign in to Xcode (Xcode > Settings > Accounts) with the Apple account that has your company's team. In CI, put the signing files in credentials/ios/ and set CATALYST_IOS_CREDENTIALS to the output of catalyst credentials:export ios."
512
512
  },
513
513
  "IOS-011": {
514
514
  message: "App Store Connect API key settings are incomplete or wrong",
@@ -358,26 +358,33 @@ async function createServer() {
358
358
  if (!res.headersSent) res.status(500).send("Internal Server Error");
359
359
  }
360
360
  });
361
- app.listen({
361
+
362
+ // Express 4 never hands a bind failure to the listen callback: the
363
+ // http.Server emits it. Unhandled, an address this machine does not have
364
+ // (EADDRNOTAVAIL) left a raw uncaught exception and a server that never
365
+ // listened. Report it, then exit. Only until the server listens: a later
366
+ // error goes to the uncaughtException handler as before.
367
+ const onListenError = error => {
368
+ const known = hintFor(error.message);
369
+ process.stderr.write(diagnostic({
370
+ message: `Could not start the server: ${error.message}`,
371
+ scope: process.env.NODE_ENV === "development" ? "dev server" : "server",
372
+ hint: known?.hint,
373
+ docs: known?.docs
374
+ }));
375
+ safeCall(onServerError);
376
+ process.exit(1);
377
+ };
378
+ const server = app.listen({
362
379
  port,
363
380
  host
364
- }, error => {
381
+ }, () => {
382
+ server.removeListener("error", onListenError);
365
383
  const {
366
384
  NODE_SERVER_HOSTNAME,
367
385
  NODE_SERVER_PORT
368
386
  } = process.env;
369
387
  const isDev = process.env.NODE_ENV === "development";
370
- if (error) {
371
- const known = hintFor(error.message);
372
- process.stderr.write(diagnostic({
373
- message: `Could not start the server: ${error.message}`,
374
- scope: isDev ? "dev server" : "server",
375
- hint: known?.hint,
376
- docs: known?.docs
377
- }));
378
- safeCall(onServerError);
379
- return;
380
- }
381
388
 
382
389
  // Reached from the listen callback, so this means a port was bound --
383
390
  // it says nothing about whether anything compiled. Claiming a
@@ -409,6 +416,7 @@ async function createServer() {
409
416
  process.stdout.write(`${wrapped ? NEXT_STEP : ""}${GUTTER}${t.dim(isDev ? "Watching for changes. " : "Serving ")}` + `${isDev ? "" : t.accent(`${process.env.BUILD_OUTPUT_PATH || "build"}/`)}` + `${t.dim(isDev ? "Press Ctrl+C to stop." : ". Press Ctrl+C to stop.")}\n`);
410
417
  if (wrapped) process.stdout.write(`${BANNER_END}\n`);
411
418
  });
419
+ server.once("error", onListenError);
412
420
  }
413
421
  createServer().catch(err => {
414
422
  console.error(err?.stack || err);