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

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. A debug build for an iPhone signs with WEBVIEW_CONFIG.ios.developmentTeam from config/config.json, and with the installed development profile ios.provisioningProfile names when that is set. 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, sign in to Xcode (Xcode > Settings > Accounts) with the Apple account that has your company's team. For a debug build on an iPhone, set WEBVIEW_CONFIG.ios.developmentTeam in config/config.json to your 10-character team ID (with Xcode signed in to that team, Xcode signs automatically), or also set ios.provisioningProfile to the name of an installed development profile that includes this iPhone. When ios.provisioningProfile is set, check that the profile includes this iPhone, matches the bundle id, and that its certificate is in your keychain. 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. A debug build for an iPhone signs with WEBVIEW_CONFIG.ios.developmentTeam from config/config.json, and with the installed development profile ios.provisioningProfile names when that is set. 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, sign in to Xcode (Xcode > Settings > Accounts) with the Apple account that has your company's team. For a debug build on an iPhone, set WEBVIEW_CONFIG.ios.developmentTeam in config/config.json to your 10-character team ID (with Xcode signed in to that team, Xcode signs automatically), or also set ios.provisioningProfile to the name of an installed development profile that includes this iPhone. When ios.provisioningProfile is set, check that the profile includes this iPhone, matches the bundle id, and that its certificate is in your keychain. 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
@@ -11,6 +11,7 @@ const {
11
11
  runInteractiveCommand
12
12
  } = require("../utils.js");
13
13
  const {
14
+ DEBUG_BUILD_SETTINGS,
14
15
  xcodeAuthArgs
15
16
  } = require("./signing.js");
16
17
  module.exports = function createBuildPhase(ctx) {
@@ -138,6 +139,7 @@ module.exports = function createBuildPhase(ctx) {
138
139
  CODE_SIGNING_REQUIRED=NO \
139
140
  CODE_SIGNING_ALLOWED=NO \
140
141
  ONLY_ACTIVE_ARCH=YES \
142
+ ${DEBUG_BUILD_SETTINGS.join(" ")} \
141
143
  BUILD_DIR="${derivedDataPath}/${projectName}-Build/Build/Products" \
142
144
  CONFIGURATION_BUILD_DIR="${derivedDataPath}/${projectName}-Build/Build/Products/Debug-iphonesimulator" \
143
145
  OS_ACTIVITY_MODE=debug \
@@ -319,7 +321,7 @@ module.exports = function createBuildPhase(ctx) {
319
321
  const signingArgs = ctx.signingPlan?.style === "manual" ? [] : xcodeAuthArgs(ctx.apiKey);
320
322
  // The streamed output is what tells you how it is going; argv, not a
321
323
  // shell string, so a key path with spaces needs no quoting.
322
- return runCommandStreaming("xcodebuild", ["-scheme", scheme, "-sdk", "iphoneos", "-configuration", "Debug", "-destination", `platform=iOS,id=${device.udid}`, `PRODUCT_BUNDLE_IDENTIFIER=${bundleId}`, "ONLY_ACTIVE_ARCH=YES", ...signingArgs, "build"]);
324
+ return runCommandStreaming("xcodebuild", ["-scheme", scheme, "-sdk", "iphoneos", "-configuration", "Debug", "-destination", `platform=iOS,id=${device.udid}`, `PRODUCT_BUNDLE_IDENTIFIER=${bundleId}`, "ONLY_ACTIVE_ARCH=YES", ...DEBUG_BUILD_SETTINGS, ...signingArgs, "build"]);
323
325
  }
324
326
  async function findPhysicalDeviceAppPath() {
325
327
  const DERIVED_DATA_DIR = path.join(os.homedir(), "Library/Developer/Xcode/DerivedData");
@@ -35,6 +35,8 @@ const {
35
35
  createProfileScanner,
36
36
  pickInstalledProfile,
37
37
  XCODE_TEAM_HINT,
38
+ deviceSigningHint,
39
+ missingProfileError,
38
40
  buildExportOptions,
39
41
  writeExportOptionsPlist,
40
42
  isSigningFailure,
@@ -186,7 +188,11 @@ function createIosBuild(config, {
186
188
  const apiKey = release ? resolveApiKey() : null;
187
189
  if (release && signingPlan.style === "automatic" && !signingPlan.teamId) {
188
190
  credentials?.cleanup();
189
- throw nativeError("IOS-010", "a signed release with automatic signing needs WEBVIEW_CONFIG.ios.developmentTeam", signingPlan.source === "Xcode account" ? {
191
+ const detail = "a signed release with automatic signing needs WEBVIEW_CONFIG.ios.developmentTeam";
192
+ const missing = missingProfileError(signingPlan.missingProfileFor, detail);
193
+ throw missing ? nativeError("IOS-010", missing.detail, {
194
+ hint: missing.hint
195
+ }) : nativeError("IOS-010", detail, signingPlan.source === "Xcode account" ? {
190
196
  hint: XCODE_TEAM_HINT
191
197
  } : {});
192
198
  }
@@ -464,10 +470,12 @@ function createIosBuild(config, {
464
470
  progress.complete("build");
465
471
  } catch (error) {
466
472
  // The diagnostic in the catch below shows what xcodebuild
467
- // said. A signing failure gets IOS-010, which names both
468
- // ways to set signing up. Not checked up front: an Apple
469
- // account signed in to Xcode signs fine without either.
470
- const failure = isSigningFailure(error.output) ? signingSetupError("Xcode could not sign the device build", error) : error;
473
+ // said. A signing failure gets IOS-010 with the
474
+ // development hint (ios.developmentTeam, or what to check
475
+ // in ios.provisioningProfile), not the App Store one.
476
+ const failure = isSigningFailure(error.output) ? signingSetupError("Xcode could not sign the device build", error, {
477
+ hint: deviceSigningHint(signingPlan)
478
+ }) : error;
471
479
  failure.stage = failure.stage || "Building for the device";
472
480
  throw failure;
473
481
  }
@@ -611,7 +619,9 @@ function createIosBuild(config, {
611
619
  try {
612
620
  await runCommandStreaming("xcodebuild", ["archive", "-project", `${PROJECT_NAME}.xcodeproj`, "-scheme", SCHEME_NAME, "-configuration", "Release", "-destination", "generic/platform=iOS", "-archivePath", archivePath, ...signingArgs]);
613
621
  } catch (error) {
614
- const failure = isSigningFailure(error.output) ? signingSetupError("Xcode could not sign the release archive", error) : error;
622
+ const failure = isSigningFailure(error.output) ? signingSetupError("Xcode could not sign the release archive", error, {
623
+ missingProfileFor: signing.missingProfileFor
624
+ }) : error;
615
625
  failure.stage = "Archiving the release build";
616
626
  throw failure;
617
627
  }
@@ -629,7 +639,9 @@ function createIosBuild(config, {
629
639
  try {
630
640
  await runCommandStreaming("xcodebuild", ["-exportArchive", "-archivePath", archivePath, "-exportPath", exportPath, "-exportOptionsPlist", optionsPlist, ...(signing.style === "automatic" ? xcodeAuthArgs(ctx.apiKey) : [])]);
631
641
  } catch (error) {
632
- const failure = isSigningFailure(error.output) ? signingSetupError("Xcode could not sign the IPA for export", error) : error;
642
+ const failure = isSigningFailure(error.output) ? signingSetupError("Xcode could not sign the IPA for export", error, {
643
+ missingProfileFor: signing.missingProfileFor
644
+ }) : error;
633
645
  failure.stage = "Exporting the IPA";
634
646
  throw failure;
635
647
  }
@@ -3,13 +3,15 @@
3
3
  Object.defineProperty(exports, "__esModule", {
4
4
  value: true
5
5
  });
6
- exports.XCODE_TEAM_HINT = exports.SIGNING_PROPERTIES = exports.EXPORT_METHODS = void 0;
6
+ exports.XCODE_TEAM_HINT = exports.SIGNING_PROPERTIES = exports.EXPORT_METHODS = exports.DEVICE_TEAM_HINT = exports.DEBUG_BUILD_SETTINGS = void 0;
7
7
  exports.buildExportOptions = buildExportOptions;
8
8
  exports.chooseXcodeTeam = chooseXcodeTeam;
9
9
  exports.createProfileScanner = createProfileScanner;
10
+ exports.deviceSigningHint = deviceSigningHint;
10
11
  exports.findInstalledProfileIdentity = findInstalledProfileIdentity;
11
12
  exports.isReleaseBuildType = isReleaseBuildType;
12
13
  exports.isSigningFailure = isSigningFailure;
14
+ exports.missingProfileError = missingProfileError;
13
15
  exports.needsXcodeTeam = needsXcodeTeam;
14
16
  exports.parseIdentities = parseIdentities;
15
17
  exports.parseIdentity = parseIdentity;
@@ -27,6 +29,7 @@ exports.readProvisioningProfile = readProvisioningProfile;
27
29
  exports.readXcodeTeams = readXcodeTeams;
28
30
  exports.resolveExportMethod = resolveExportMethod;
29
31
  exports.scanInstalledProfiles = scanInstalledProfiles;
32
+ exports.settleInstalledProfile = settleInstalledProfile;
30
33
  exports.settleXcodeTeam = settleXcodeTeam;
31
34
  exports.setupP12Signing = setupP12Signing;
32
35
  exports.signingFailureLine = signingFailureLine;
@@ -220,6 +223,15 @@ function readIosCredentials(dir, {
220
223
  };
221
224
  }
222
225
 
226
+ /**
227
+ * xcodebuild build settings for every debug build, simulator and iPhone.
228
+ * ENABLE_DEBUG_DYLIB=NO: for Xcode's Previews a Debug app is split into the
229
+ * app, <app>.debug.dylib and __preview.dylib, each signed on its own, so an
230
+ * iPhone build asked for the Keychain password three times. A command line
231
+ * build has no use for Previews. The release archive does not use them.
232
+ */
233
+ const DEBUG_BUILD_SETTINGS = exports.DEBUG_BUILD_SETTINGS = ["ENABLE_DEBUG_DYLIB=NO"];
234
+
223
235
  /**
224
236
  * xcodebuild flags that let automatic signing create and fetch certificates
225
237
  * and profiles: with the API key when there is one (cloud signing),
@@ -296,13 +308,19 @@ function planIosSigning({
296
308
  if (release && exportMethod === "app-store-connect" && bundleId && scanProfiles) {
297
309
  const scan = scanProfiles();
298
310
  const {
299
- identity
311
+ identity,
312
+ missing
300
313
  } = pickInstalledProfile(scan, {
301
314
  bundleId,
302
315
  teamId,
303
316
  needsPush: notifications,
304
317
  now
305
318
  });
319
+ // Nothing else to sign with: IOS-010 leads with the missing profile.
320
+ if (missing) return {
321
+ ...automatic("Xcode account"),
322
+ missingProfileFor: bundleId
323
+ };
306
324
  if (identity) {
307
325
  // By name, as people know it, unless another installed profile shares it.
308
326
  const sameName = scan.profiles.filter(profile => profile.name === identity.profileName).length;
@@ -580,7 +598,9 @@ function isAppStoreProfile(profile) {
580
598
  * profile (.mobileprovision, no devices, get-task-allow false) for exactly
581
599
  * this bundle id, for `teamId` when set, with aps-environment when
582
600
  * `needsPush`. Expired profiles are skipped; the newest one whose
583
- * certificate is in the keychain wins. Without a match, `reason` says why.
601
+ * certificate is in the keychain wins. Without a match, `reason` says why,
602
+ * and `missing` is true when no App Store profile for the bundle id is
603
+ * installed at all.
584
604
  *
585
605
  * @param {ReturnType<typeof scanInstalledProfiles>} scan
586
606
  * @param {object} options
@@ -589,7 +609,7 @@ function isAppStoreProfile(profile) {
589
609
  * @param {string} [options.teamId]
590
610
  * @param {boolean} [options.needsPush]
591
611
  * @param {Date} [options.now]
592
- * @returns {{ identity: { sha1: string, name: string, teamId?: string, uuid: string, profileName?: string } | null, reason?: string }}
612
+ * @returns {{ identity: { sha1: string, name: string, teamId?: string, uuid: string, profileName?: string } | null, reason?: string, missing?: boolean }}
593
613
  */
594
614
  function pickInstalledProfile(scan, {
595
615
  profile,
@@ -611,13 +631,19 @@ function pickInstalledProfile(scan, {
611
631
  };
612
632
  let candidates = scan?.profiles || [];
613
633
  /** 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) {
634
+ 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`]];
635
+ for (const [keep, reason, missing] of steps) {
616
636
  const left = candidates.filter(keep);
617
- if (!left.length) return {
618
- identity: null,
619
- reason: reason(candidates)
620
- };
637
+ if (!left.length) {
638
+ return missing ? {
639
+ identity: null,
640
+ reason: reason(candidates),
641
+ missing: true
642
+ } : {
643
+ identity: null,
644
+ reason: reason(candidates)
645
+ };
646
+ }
621
647
  candidates = left;
622
648
  }
623
649
  const newestFirst = [...candidates].sort((a, b) => b.expires - a.expires);
@@ -685,6 +711,44 @@ function findInstalledProfileIdentity({
685
711
 
686
712
  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
713
 
714
+ /**
715
+ * IOS-010's hint when Xcode could not sign a debug build for an iPhone. That
716
+ * build signs with what config/config.json sets, nothing else: the team, and
717
+ * an installed development profile when ios.provisioningProfile names one.
718
+ */
719
+ const DEVICE_TEAM_HINT = exports.DEVICE_TEAM_HINT = "Set WEBVIEW_CONFIG.ios.developmentTeam in config/config.json to your 10-character team ID. With Xcode signed in to that team, Xcode signs automatically. Or also set ios.provisioningProfile to the name of an installed development profile that includes this iPhone.";
720
+
721
+ /**
722
+ * DEVICE_TEAM_HINT, or for a plan that signs with ios.provisioningProfile
723
+ * what to check in that profile.
724
+ *
725
+ * @param {ReturnType<typeof planIosSigning>} [plan]
726
+ * @returns {string}
727
+ */
728
+ function deviceSigningHint(plan) {
729
+ if (!plan?.provisioningProfile) return DEVICE_TEAM_HINT;
730
+ return `Check that the profile "${plan.provisioningProfile}" set in WEBVIEW_CONFIG.ios.provisioningProfile includes this iPhone, matches the bundle id, and that its certificate is in your keychain.`;
731
+ }
732
+
733
+ /**
734
+ * IOS-010's detail and hint for an App Store release that found no installed
735
+ * App Store profile for its bundle id (plan.missingProfileFor) and has no
736
+ * other signing source: the missing profile first, then every way to sign,
737
+ * the Xcode account last. Null for any other plan.
738
+ *
739
+ * @param {string | undefined} bundleId - plan.missingProfileFor
740
+ * @param {string} [detail] - what failed after that, e.g. the Xcode account
741
+ * @returns {{ detail: string, hint: string } | null}
742
+ */
743
+ function missingProfileError(bundleId, detail) {
744
+ if (!bundleId) return null;
745
+ const missing = `no App Store provisioning profile for ${bundleId} is installed on this Mac`;
746
+ return {
747
+ detail: detail ? `${missing}, and ${detail}` : missing,
748
+ 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.`
749
+ };
750
+ }
751
+
688
752
  /**
689
753
  * The teams in IDEProvisioningTeamByIdentifier (account id -> teams), one
690
754
  * per team id. `free` is isFreeProvisioningTeam, undefined when Xcode did
@@ -753,31 +817,35 @@ function needsXcodeTeam(plan, {
753
817
  * Store, release-testing or enterprise export, so it is left out unless the
754
818
  * export is for debugging; a team Xcode did not mark either way is kept.
755
819
  * One team is used; several are a question, NATIVE-005 without a terminal;
756
- * none is IOS-010.
820
+ * none is IOS-010, which leads with `missingProfileFor` when that is set.
757
821
  *
758
822
  * @param {object} options
759
823
  * @param {ReturnType<typeof parseXcodeTeams>} options.teams - from readXcodeTeams
760
824
  * @param {string} [options.exportMethod]
761
825
  * @param {boolean} [options.isTTY]
762
826
  * @param {typeof select} [options.choose]
827
+ * @param {string} [options.missingProfileFor] - plan.missingProfileFor
763
828
  * @returns {Promise<{ teamId: string, teamName: string, free: boolean | undefined }>}
764
829
  */
765
830
  async function chooseXcodeTeam({
766
831
  teams,
767
832
  exportMethod = "app-store-connect",
768
833
  isTTY,
769
- choose = _utils.select
834
+ choose = _utils.select,
835
+ missingProfileFor
770
836
  }) {
771
- if (!teams) {
772
- throw (0, _utils.nativeError)("IOS-010", "could not read which teams are signed in to Xcode", {
837
+ const noTeam = detail => {
838
+ const missing = missingProfileError(missingProfileFor, detail);
839
+ return missing ? (0, _utils.nativeError)("IOS-010", missing.detail, {
840
+ hint: missing.hint
841
+ }) : (0, _utils.nativeError)("IOS-010", detail, {
773
842
  hint: XCODE_TEAM_HINT
774
843
  });
775
- }
844
+ };
845
+ if (!teams) throw noTeam("could not read which teams are signed in to Xcode");
776
846
  const usable = exportMethod === "debugging" ? teams : teams.filter(team => team.free !== true);
777
847
  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
- });
848
+ 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
849
  }
782
850
  if (usable.length === 1) return usable[0];
783
851
  const label = team => `${team.teamName || "Unnamed team"} (${team.teamId})`;
@@ -831,7 +899,8 @@ async function settleXcodeTeam({
831
899
  teams: readTeams(),
832
900
  exportMethod,
833
901
  isTTY: interactive,
834
- choose
902
+ choose,
903
+ missingProfileFor: plan.missingProfileFor
835
904
  });
836
905
  const label = `${team.teamName || "Unnamed team"} (${team.teamId})`;
837
906
  if (!interactive) {
@@ -849,6 +918,44 @@ async function settleXcodeTeam({
849
918
  };
850
919
  }
851
920
 
921
+ /**
922
+ * The profile step for a release that auto-picked an installed App Store
923
+ * profile (planIosSigning's "installed profile"): in a terminal save it as
924
+ * ios.provisioningProfile with `save`, so later builds sign with the same
925
+ * one; without a terminal (CI, agents) only use it for this build, since
926
+ * config.json is never written there. Null for any other plan.
927
+ *
928
+ * @param {object} options
929
+ * @param {ReturnType<typeof planIosSigning>} options.plan - from planSigningForConfig
930
+ * @param {boolean} [options.isTTY] - defaults to isInteractive()
931
+ * @param {(profile: string) => void} options.save - writes WEBVIEW_CONFIG.ios.provisioningProfile
932
+ * @param {(message: string, type?: string) => void} [options.log]
933
+ * @returns {{ provisioningProfile: string, saved: boolean } | null}
934
+ */
935
+ function settleInstalledProfile({
936
+ plan,
937
+ isTTY,
938
+ save,
939
+ log = () => {}
940
+ }) {
941
+ if (plan?.source !== "installed profile") return null;
942
+ // The name, or the UUID when another installed profile shares the name.
943
+ const profile = plan.provisioningProfile;
944
+ if (!(isTTY ?? (0, _utils.isInteractive)())) {
945
+ log(`Using the installed profile "${plan.profileName}". Set WEBVIEW_CONFIG.ios.provisioningProfile to keep it.`, "note");
946
+ return {
947
+ provisioningProfile: profile,
948
+ saved: false
949
+ };
950
+ }
951
+ log(`Signing with the installed profile "${plan.profileName}" (team ${plan.teamId})`, "info");
952
+ save(profile);
953
+ return {
954
+ provisioningProfile: profile,
955
+ saved: true
956
+ };
957
+ }
958
+
852
959
  /**
853
960
  * Local signing with credentials/ios/: import the .p12 into a temporary
854
961
  * keychain, put that keychain on the user search list, and install the
@@ -1103,15 +1210,28 @@ function signingFailureLine(output) {
1103
1210
 
1104
1211
  /**
1105
1212
  * 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.
1213
+ * the diagnostic. The detail ends with the line that says what went wrong,
1214
+ * and starts with the missing App Store profile when `missingProfileFor` is set.
1107
1215
  *
1108
1216
  * @param {string} detail
1109
1217
  * @param {any} [cause]
1218
+ * @param {object} [options]
1219
+ * @param {string} [options.missingProfileFor] - plan.missingProfileFor
1220
+ * @param {string} [options.hint] - over the registry's hint, e.g. deviceSigningHint(plan)
1110
1221
  * @returns {any}
1111
1222
  */
1112
- function signingSetupError(detail, cause) {
1223
+ function signingSetupError(detail, cause, {
1224
+ missingProfileFor,
1225
+ hint
1226
+ } = {}) {
1113
1227
  const said = signingFailureLine(cause?.output);
1114
- const error = (0, _utils.nativeError)("IOS-010", said ? `${detail}: ${said}` : detail);
1228
+ const failed = said ? `${detail}: ${said}` : detail;
1229
+ const missing = missingProfileError(missingProfileFor, failed);
1230
+ const error = missing ? (0, _utils.nativeError)("IOS-010", missing.detail, {
1231
+ hint: missing.hint
1232
+ }) : (0, _utils.nativeError)("IOS-010", failed, {
1233
+ hint
1234
+ });
1115
1235
  if (cause) {
1116
1236
  error.cause = cause;
1117
1237
  if (cause.output) error.output = cause.output;
@@ -489,7 +489,8 @@ async function chooseIosTarget({
489
489
  /**
490
490
  * The host a debug build reaches the dev server on. The simulator shares the
491
491
  * Mac's network, so localhost. An iPhone uses the Mac's Bonjour name
492
- * (`<LocalHostName>.local`), else its LAN IP; IOS-015 when there is neither.
492
+ * (`<localhostname>.local`, lowercased), else its LAN IP; IOS-015 when there
493
+ * is neither.
493
494
  * It goes into the build only, never into config.json.
494
495
  *
495
496
  * @param {{ target: string, name?: string } | null} target
@@ -72,8 +72,8 @@ function getLocalIPAddress(interfaces = _os.default.networkInterfaces()) {
72
72
 
73
73
  /**
74
74
  * The host a physical device on the same network reaches this Mac on:
75
- * `<LocalHostName>.local` (Bonjour, survives DHCP changes), else the LAN IP.
76
- * Null when neither is available.
75
+ * `<localhostname>.local` (Bonjour, survives DHCP changes, lowercased), else
76
+ * the LAN IP. Null when neither is available.
77
77
  *
78
78
  * @param {object} [options]
79
79
  * @param {() => string} [options.localHostName] - returns `scutil --get LocalHostName`
@@ -90,8 +90,10 @@ function getDeviceReachableHost({
90
90
  } catch (error) {
91
91
  // Not macOS, or scutil failed: fall back to the LAN IP.
92
92
  }
93
+ // Lowercase: WebKit sends the Host header lowercased, and Vite's
94
+ // server.allowedHosts compares it exactly.
93
95
  if (/^[A-Za-z0-9-]+$/.test(name)) return {
94
- host: `${name}.local`,
96
+ host: `${name.toLowerCase()}.local`,
95
97
  source: "bonjour"
96
98
  };
97
99
  const ip = getLocalIPAddress(interfaces);
@@ -415,12 +417,14 @@ async function ensureDevServer({
415
417
  // One-time migration: older setups wrote this machine's LAN address into
416
418
  // config.json (every "localhost", and any http://<ip>), which makes Express
417
419
  // 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.
420
+ // for this machine's own addresses (or the one it had on another network):
421
+ // a container IP, 0.0.0.0 or a loopback address is the app's own setting.
420
422
  // ---------------------------------------------------------------------------
421
423
 
422
424
  const MIGRATION_KEY = "lanAddresses";
423
425
  const IPV4 = /^\d{1,3}(?:\.\d{1,3}){3}$/;
426
+ // 10/8, 172.16/12, 192.168/16 and link-local 169.254/16.
427
+ const PRIVATE_LAN_IPV4 = /^(?:10|172\.(?:1[6-9]|2\d|3[01])|192\.168|169\.254)\./;
424
428
  const isCI = env => {
425
429
  const value = env.CI;
426
430
  return value !== undefined && value !== "" && value !== "0" && String(value).toLowerCase() !== "false";
@@ -429,10 +433,13 @@ const isCI = env => {
429
433
  /**
430
434
  * The changes the migration would make, without writing anything. Only a
431
435
  * 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.
436
+ * http:// URL on one of them, goes back to localhost. So does a private
437
+ * LOCAL_IP that NODE_SERVER_HOSTNAME also holds: an earlier setup wrote this
438
+ * machine's address on another network into both, and the dev server can't
439
+ * bind it here. Any other address, private ones included (a LOCAL_IP
440
+ * pointing at a VPN or LAN staging server), is the app's own setting.
441
+ * WEBVIEW_CONFIG.url is never touched, and nothing is when
442
+ * WEBVIEW_CONFIG.allowPrivateHost is true.
436
443
  *
437
444
  * @param {object} config - the parsed config.json (not modified)
438
445
  * @param {object} [options]
@@ -451,6 +458,11 @@ function planLanAddressMigration(config, {
451
458
  };
452
459
  // This machine's IPv4 addresses, without loopback and 0.0.0.0/8.
453
460
  const saved = new Set([...localHosts].filter(host => IPV4.test(host) && !/^(?:127|0)\./.test(host)));
461
+ // The LAN address this machine had when LOCAL_IP was written.
462
+ const leftover = typeof wv?.LOCAL_IP === "string" ? wv.LOCAL_IP.trim() : "";
463
+ if (IPV4.test(leftover) && PRIVATE_LAN_IPV4.test(leftover) && String(config.NODE_SERVER_HOSTNAME ?? "").trim() === leftover) {
464
+ saved.add(leftover);
465
+ }
454
466
  if (!saved.size) return {
455
467
  config,
456
468
  changes
@@ -532,8 +544,8 @@ function migrateLanAddresses({
532
544
  } : undefined).changes;
533
545
  const changes = planned.length ? writeConfigKeys(file, Object.fromEntries(planned.map(change => [change.path, change.to]))) : [];
534
546
  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");
547
+ log(`Put localhost back in config/config.json where an earlier setup saved this machine's LAN address:`, "note");
548
+ for (const change of changes) log(` ${change.path}: ${change.from} -> ${change.to}`, "note");
537
549
  }
538
550
  const state = (0, _localState.readLocalState)({
539
551
  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, sign in to Xcode (Xcode > Settings > Accounts) with the Apple account that has your company's team. For a debug build on an iPhone, set WEBVIEW_CONFIG.ios.developmentTeam in config/config.json to your 10-character team ID (with Xcode signed in to that team, Xcode signs automatically), or also set ios.provisioningProfile to the name of an installed development profile that includes this iPhone. When ios.provisioningProfile is set, check that the profile includes this iPhone, matches the bundle id, and that its certificate is in your keychain. 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",