feat(catalyst): run and build Mac Catalyst apps with ns run catalyst - #6125
feat(catalyst): run and build Mac Catalyst apps with ns run catalyst#6125farfromrefug wants to merge 2 commits into
ns run catalyst#6125Conversation
Adds macOS as a supported platform. A Mac Catalyst app is the iOS app rebuilt against the macOS SDK, so it keeps every iOS convention -- App_Resources/iOS, the iOS runtime package, each plugin's platforms/ios folder, the iOS Podfile, the iOS bundle -- and diverges only in the directory it prepares into, platforms/macos, and the SDK it builds against. That is the whole design: iOSProjectService reports iOS as the platform name so every iOS convention falls out for free, and projectRoot is the single place macOS differs. Two call sites rebuilt the platform directory from the platform name rather than reading projectRoot, which would have made a macOS build probe and delete platforms/ios; both now use projectRoot, which already resolves hostProjectPath the same way. The Mac is modelled as a device so build, deploy and LiveSync drive it through the existing pipeline. Everything it does is local: the .app is a directory on this machine, so the file system is a plain copy, install only records the built bundle, launch is `open -n`, and the log stream is `log stream` narrowed to the app and the runtime. Watch-mode prepare events are stamped with the platform the caller asked for rather than the platform data's name. run-controller pairs an event with a device by comparing the two, so for Catalyst -- iOS platform data, macOS device -- a file change recompiled but reached no device. The non-watch path already reported the requested platform; watchers and bundler processes are now keyed by it too, since stopWatchers and stopBundlerCompiler are called with it. Nothing changes for ios/android/visionos, where both strings are identical. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
📝 WalkthroughWalkthroughThe CLI adds Mac Catalyst as a supported platform. It introduces local device discovery, application and filesystem management, Catalyst-specific Xcodebuild execution, platform configuration, and registered ChangesMac Catalyst support
Estimated code review effort: 4 (Complex) | ~60 minutes Possibly related PRs
Suggested reviewers: Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
`ns run macos` is being taken by a separate effort that builds a native macOS app against a macOS runtime. That is a different product from a Mac Catalyst build -- the iOS app rebuilt against the macOS SDK -- so this one takes the name that says what it actually is: `ns build catalyst`, `ns run catalyst`, preparing into platforms/catalyst. Only the platform identifier changes; the build, device and LiveSync behaviour is untouched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ns run macosns run catalyst
There was a problem hiding this comment.
Actionable comments posted: 7
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@lib/common/mobile/mac/mac-catalyst-application-manager.ts`:
- Around line 65-80: Update stopApplication to escape all ERE metacharacters in
the getExecutablePath() result before constructing the anchored pkill -f
pattern, while preserving literal path matching and the existing no-process
handling.
In `@lib/common/mobile/mobile-helper.ts`:
- Around line 60-65: Update getDeviceSyncZipPath to return undefined when the
platform is Catalyst, while preserving the existing sync-zip behavior for iOS
and visionOS. Use isCatalystPlatform to detect Catalyst so LiveSync falls back
to local file copying.
In `@lib/device-path-provider.ts`:
- Around line 20-33: Update the Catalyst handling across the device sync flow,
including IOSLiveSyncService.fullSync and MacCatalystFileSystem, so Catalyst
devices use direct local copying rather than the iOS archive sync destination.
Do not rely solely on returning undefined from getDeviceSyncZipPath; ensure
fullSync detects or delegates Catalyst devices before sending sync.zip to the
iOS archive path, while preserving existing non-Catalyst behavior.
In `@lib/project-data.ts`:
- Around line 350-355: Update IOSProjectService.setProductBundleIdentifier to
select projectIdentifiers.catalyst when the requested platform is Catalyst,
while retaining projectIdentifiers.ios for iOS builds, so config.catalyst.id is
applied to the Xcode target’s PRODUCT_BUNDLE_IDENTIFIER.
In `@lib/services/bundler/bundler-compiler-service.ts`:
- Line 112: Update startViteDevServer to store the Vite process under the
requested platform key passed by the caller, such as catalyst, rather than
platformData.platformNameLowerCase. Ensure the corresponding stopBundlerCompiler
lookup uses the same key so the server is removed and stopped correctly.
In `@lib/services/ios/xcodebuild-args-service.ts`:
- Around line 290-326: The semver comparison in getCatalystDeploymentTargetArgs
must handle deployment targets containing unresolved Xcode variables without
throwing. Store the coerced project deployment target, only call semver.lt when
that value is non-null, and continue comparing against semver.coerce(minimum)
while treating an uncoercible project value as requiring the minimum target.
In `@lib/services/platform/add-platform-service.ts`:
- Around line 187-188: Guard the cleanup deletion in
add-platform-service.ts:187-188 with the same CLI-owned-root check used at
add-platform-service.ts:73-76, so platformData.projectRoot is deleted only when
it is not $options.hostProjectPath. Update the native-platform cleanup around
the visible deleteDirectory call; the sibling failure-cleanup site at
add-platform-service.ts:73-76 requires no direct change and serves as the
ownership-check reference.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro Plus
Run ID: 250040a1-b1b3-471c-9086-5baf8ab8df4b
📒 Files selected for processing (27)
lib/bootstrap.tslib/commands/build.tslib/commands/run.tslib/common/bootstrap.tslib/common/definitions/mobile.d.tslib/common/mobile/device-platforms-constants.tslib/common/mobile/mac/mac-catalyst-application-manager.tslib/common/mobile/mac/mac-catalyst-device.tslib/common/mobile/mac/mac-catalyst-file-system.tslib/common/mobile/mobile-core/devices-service.tslib/common/mobile/mobile-core/ios-device-discovery.tslib/common/mobile/mobile-core/mac-catalyst-discovery.tslib/common/mobile/mobile-helper.tslib/constants.tslib/controllers/platform-controller.tslib/controllers/prepare-controller.tslib/definitions/ios.d.tslib/definitions/project.d.tslib/device-path-provider.tslib/project-data.tslib/services/bundler/bundler-compiler-service.tslib/services/ios-project-service.tslib/services/ios/xcodebuild-args-service.tslib/services/ios/xcodebuild-service.tslib/services/platform/add-platform-service.tslib/services/platforms-data-service.tslib/services/project-data-service.ts
| public async stopApplication( | ||
| appData: Mobile.IApplicationData, | ||
| ): Promise<void> { | ||
| try { | ||
| // Anchored so it never matches our own log stream process. | ||
| await this.$childProcess.spawnFromEvent( | ||
| "pkill", | ||
| ["-f", `^${this.getExecutablePath()}$`], | ||
| "close", | ||
| ); | ||
| } catch (err) { | ||
| // pkill exits non-zero when no process matched. | ||
| this.$logger.trace( | ||
| `Nothing to stop for ${appData.appId}. More info: ${err.message}`, | ||
| ); | ||
| } |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -eu
file=$(git ls-files | grep -F 'lib/common/mobile/mac/mac-catalyst-application-manager.ts' | head -n1)
printf '%s\n' "$file"
cat -n "$file" | sed -n '1,130p'
printf '\n-- path construction and related process calls --\n'
rg -n -C 3 'getExecutablePath|applicationBundlePath|spawnFromEvent|pkill' lib/common/mobile/mac "$file"Repository: NativeScript/nativescript-cli
Length of output: 16260
🏁 Script executed:
#!/bin/bash
set -eu
file=$(git ls-files | grep -F 'lib/common/mobile/mac/mac-catalyst-application-manager.ts' | head -n1)
cat -n "$file" | sed -n '1,130p'
rg -n -C 3 'getExecutablePath|applicationBundlePath|spawnFromEvent|pkill' lib/common/mobile/mac "$file"Repository: NativeScript/nativescript-cli
Length of output: 16151
🏁 Script executed:
#!/bin/bash
set -eu
printf '%s\n' '-- application bundle path sources --'
rg -n -C 5 'getBuiltApplicationBundlePath|packageFilePath|applicationBundlePath\s*=' lib | head -n 240
printf '%s\n' '-- pkill documentation available in the environment --'
if command -v man >/dev/null 2>&1; then
man pkill 2>/dev/null | col -b 2>/dev/null | grep -n -A8 -B4 -E 'extended|full process|command line|regular expression' | head -n 120 || true
fi
printf '%s\n' '-- behavioral probe for the proposed escaping --'
node - <<'JS'
const escapeEre = value => value.replace(/[\\^$.*+?()[\]{}|]/g, "\\$&");
const paths = [
"/Users/me/[work]/Demo.app/Contents/MacOS/Demo",
"/Users/me/foo+bar/Demo.app/Contents/MacOS/Demo",
"/Users/me/foo.bar/Demo.app/Contents/MacOS/Demo",
"/Users/me/foo(bar)/Demo.app/Contents/MacOS/Demo",
"/Users/me/foo\\bar/Demo.app/Contents/MacOS/Demo",
];
for (const path of paths) {
const raw = `^${path}$`;
const escaped = `^${escapeEre(path)}$`;
let rawValid = true;
try { new RegExp(raw); } catch { rawValid = false; }
let escapedValid = true;
try { new RegExp(escaped); } catch { escapedValid = false; }
console.log(JSON.stringify({path, raw, rawValid, escaped, escapedValid}));
}
JSRepository: NativeScript/nativescript-cli
Length of output: 19125
🏁 Script executed:
#!/bin/bash
set -eu
printf '%s\n' '-- Mac Catalyst device path generation --'
file=$(git ls-files | grep -F 'lib/common/mobile/mac/mac-catalyst-device.ts' | head -n1)
cat -n "$file" | sed -n '1,115p'
printf '%s\n' '-- inherited path-generation definitions --'
rg -n -C 5 'BuiltApplicationBundlePath|built application bundle|application bundle path|applicationBundlePath' lib/common lib/platforms | head -n 240
printf '%s\n' '-- process and Mac Catalyst tests --'
rg -n -C 4 'MacCatalystApplicationManager|mac-catalyst-application-manager|pkill|stopApplication' lib/common/test test 2>/dev/null | head -n 260 || trueRepository: NativeScript/nativescript-cli
Length of output: 20209
🌐 Web query:
macOS pkill man page -f pattern extended regular expression
💡 Result:
On macOS, the pkill utility accepts extended regular expressions for its pattern argument [1]. When you provide a pattern to pkill, it treats the expression as an extended regular expression to match against the process name or the full argument list (if the -f flag is used) [1]. Important details regarding the use of regular expressions with pkill: 1. Pattern Matching: The pattern is matched against the executable's name by default [1]. Using the -f option causes pkill to match the pattern against the full command line argument string instead [1]. 2. Shell Interference: Because regular expressions often contain characters (like ,?, or []) that are also interpreted as shell meta-characters (wildcards), it is strongly recommended to enclose your pattern in quotes (e.g., pkill -f '^python.') to prevent the shell from expanding them before pkill receives them [2][3]. 3. Exact Matches: If you want the pattern to match the entire string rather than just a substring, you should use the -x flag [1]. 4. Regex Syntax: pkill uses extended regular expression (ERE) syntax [1]. This is different from shell globbing; for example, the.* construct is used to match any character sequence, whereas the shell uses * [4]. If you are unsure whether your regex will match the intended processes, you can use pgrep with the same pattern and flags first to see which process IDs would be affected without actually sending a signal [1][3].
Citations:
- 1: https://www.unix.com/man_page/osx/1/pkill/
- 2: https://osxdaily.com/2012/10/18/kill-process-wildcards-pkill-mac-os-x/
- 3: https://stackoverflow.com/questions/3510673/find-and-kill-a-process-in-one-line-using-bash-and-regular-expressions
- 4: https://unix.stackexchange.com/questions/234483/pkill-with-regex
Escape getExecutablePath() before passing it to pkill -f.
pkill -f interprets the pattern as an extended regular expression. The project-derived path can contain regex metacharacters. For example, [work] is parsed as a character class, so the literal executable path does not match. Escape all ERE metacharacters before adding the anchors.
🧰 Tools
🪛 ast-grep (0.45.1)
[warning] Importing child_process exposes a command-execution surface; ensure any command/argument built from input is validated, and prefer execFile/spawn with an argument array over exec.
Context: import { ChildProcess } from "child_process";
Note: [CWE-78] Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection').
(detect-child-process-typescript)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@lib/common/mobile/mac/mac-catalyst-application-manager.ts` around lines 65 -
80, Update stopApplication to escape all ERE metacharacters in the
getExecutablePath() result before constructing the anchored pkill -f pattern,
while preserving literal path matching and the existing no-process handling.
| public isApplePlatform(platform: string): boolean { | ||
| return this.isiOSPlatform(platform) || this.isvisionOSPlatform(platform); | ||
| return ( | ||
| this.isiOSPlatform(platform) || | ||
| this.isvisionOSPlatform(platform) || | ||
| this.isCatalystPlatform(platform) | ||
| ); |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
ast-grep outline lib/device-path-provider.ts --items all
fd -i 'mac-catalyst-(device|file-system)\.ts$' lib --exec sed -n '1,240p' {}
rg -n -C 6 '\bgetDeviceSyncZipPath\s*\(' libRepository: NativeScript/nativescript-cli
Length of output: 9165
🏁 Script executed:
#!/bin/bash
set -euo pipefail
rg -n -C 12 'deviceSyncZipPath|IOS_DEVICE_SYNC_ZIP_PATH|transferDirectory|transferFiles' lib/services lib/common lib | head -n 500
printf '\n--- Catalyst registrations and LiveSync services ---\n'
rg -n -C 8 'MacCatalyst(FileSystem|Device)|Catalyst.*LiveSync|liveSync.*Catalyst|platform.*Catalyst' libRepository: NativeScript/nativescript-cli
Length of output: 50385
🏁 Script executed:
#!/bin/bash
set -euo pipefail
printf '%s\n' '--- LiveSync service selection ---'
rg -n -C 15 'getDeviceLiveSyncService|IOSDeviceLiveSyncService|IosDeviceLiveSyncService|iOSLiveSync|isApplePlatform|isCatalystPlatform' lib/services lib/common
printf '%s\n' '--- iOS transfer entry points ---'
sed -n '1,180p' lib/services/livesync/ios-livesync-service.ts
printf '%s\n' '--- Catalyst filesystem and device path provider ---'
sed -n '1,130p' lib/device-path-provider.ts
sed -n '1,150p' lib/common/mobile/mac/mac-catalyst-file-system.tsRepository: NativeScript/nativescript-cli
Length of output: 50385
🏁 Script executed:
#!/bin/bash
set -euo pipefail
printf '%s\n' '--- Platform LiveSync service resolution ---'
rg -n -C 12 'iOSLiveSyncService|androidLiveSyncService|getPlatformLiveSyncService|LiveSyncService' lib/services lib/commands lib/common --glob '*.ts' | grep -E 'iOSLiveSyncService|androidLiveSyncService|getPlatformLiveSyncService|resolve|platform|register' | head -n 240
printf '%s\n' '--- iOS full-sync transfer implementation ---'
sed -n '1,145p' lib/services/livesync/ios-livesync-service.ts
printf '%s\n' '--- Catalyst-specific LiveSync references ---'
rg -n -C 10 'Catalyst' lib/services/livesync lib/common/mobile/mac lib/device-path-provider.ts --glob '*.ts'Repository: NativeScript/nativescript-cli
Length of output: 41709
🏁 Script executed:
#!/bin/bash
set -euo pipefail
printf '%s\n' '--- LiveSync command and service injection ---'
fd -i '.*livesync.*\.ts$' lib --exec sh -c 'echo "--- $1"; rg -n -C 8 "iOSLiveSyncService|androidLiveSyncService|IPlatformLiveSyncService|platformLiveSyncService|resolve.*LiveSync|LiveSyncService" "$1"' sh {}
printf '%s\n' '--- All iOS LiveSync service references ---'
rg -n -C 8 'iOSLiveSyncService|IOSLiveSyncService' lib --glob '*.ts'Repository: NativeScript/nativescript-cli
Length of output: 25176
Exclude Catalyst from getDeviceSyncZipPath. LiveSyncServiceResolver maps Catalyst to IOSLiveSyncService, whose non-emulator full sync uploads a zip to deviceAppData.deviceSyncZipPath. MacCatalystFileSystem does not prevent this path. Return undefined for Catalyst so LiveSync uses local file copying.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@lib/common/mobile/mobile-helper.ts` around lines 60 - 65, Update
getDeviceSyncZipPath to return undefined when the platform is Catalyst, while
preserving the existing sync-zip behavior for iOS and visionOS. Use
isCatalystPlatform to detect Catalyst so LiveSync falls back to local file
copying.
| if (this.$mobileHelper.isCatalystPlatform(device.deviceInfo.platform)) { | ||
| projectRoot = (<Mobile.IMacCatalystDevice>device).applicationBundlePath; | ||
| if (!projectRoot) { | ||
| this.$errors.fail("Unable to get application path on device."); | ||
| } | ||
|
|
||
| // Catalyst keeps its payload under Contents/Resources, not the bundle root. | ||
| projectRoot = path.join(projectRoot, "Contents", "Resources"); | ||
| if (!options.getDirname) { | ||
| projectRoot = path.join(projectRoot, APP_FOLDER_NAME); | ||
| } | ||
|
|
||
| return projectRoot; | ||
| } |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Map the provider before tracing typed call sites.
ast-grep outline lib/device-path-provider.ts --items all
# Inspect consumers and their platform guards.
rg -n -C 6 '\.getDeviceSyncZipPath\s*\(' lib --type tsRepository: NativeScript/nativescript-cli
Length of output: 1839
🏁 Script executed:
#!/bin/bash
set -euo pipefail
printf '%s\n' '--- device path provider ---'
sed -n '1,180p' lib/device-path-provider.ts
printf '%s\n' '--- livesync context and consumers ---'
sed -n '260,360p' lib/services/livesync/platform-livesync-service-base.ts
rg -n -C 8 'deviceSyncZipPath|IOS_DEVICE_SYNC_ZIP_PATH|syncZipPath|zipPath' lib/services lib/common --type ts
printf '%s\n' '--- Catalyst file-system implementation ---'
sed -n '1,140p' lib/common/mobile/mac/mac-catalyst-file-system.ts
printf '%s\n' '--- platform and emulator predicates ---'
rg -n -C 8 'isApplePlatform|isCatalystPlatform|isEmulator|deviceSyncZipPath' lib --type tsRepository: NativeScript/nativescript-cli
Length of output: 50386
🏁 Script executed:
#!/bin/bash
set -euo pipefail
printf '%s\n' '--- iOS LiveSync service ---'
sed -n '1,220p' lib/services/livesync/ios-livesync-service.ts
printf '%s\n' '--- service resolver ---'
sed -n '1,80p' lib/resolvers/livesync-service-resolver.ts
printf '%s\n' '--- service selection and transfer entry points ---'
rg -n -C 12 'getDeviceLiveSyncService|resolveLiveSyncService|transferSyncZip|deviceSyncZipPath' \
lib/services/livesync lib/resolvers --type tsRepository: NativeScript/nativescript-cli
Length of output: 43106
🏁 Script executed:
#!/bin/bash
set -euo pipefail
printf '%s\n' '--- Catalyst integration ---'
rg -n -C 10 'MacCatalystFileSystem|isCatalystPlatform|IMacCatalystDevice|applicationBundlePath' \
lib test tests --type ts --type d.ts 2>/dev/null || true
printf '%s\n' '--- LiveSync tests ---'
rg -n -C 8 'getDeviceSyncZipPath|IOSLiveSyncService|MacCatalyst|Catalyst|sync.zip' \
test tests lib --glob '*test*' --glob '*spec*' --type ts 2>/dev/null || true
printf '%s\n' '--- read-only call-graph verifier ---'
python3 - <<'PY'
from pathlib import Path
provider = Path("lib/device-path-provider.ts").read_text()
resolver = Path("lib/resolvers/livesync-service-resolver.ts").read_text()
ios = Path("lib/services/livesync/ios-livesync-service.ts").read_text()
catalyst_fs = Path("lib/common/mobile/mac/mac-catalyst-file-system.ts").read_text()
checks = {
"Catalyst project-root branch exists": 'isCatalystPlatform(device.deviceInfo.platform)' in provider,
"sync-zip predicate lacks Catalyst exclusion": (
'isApplePlatform(device.deviceInfo.platform)' in provider
and '!device.isEmulator' in provider
and 'isCatalystPlatform(device.deviceInfo.platform)' not in
provider[provider.index('public getDeviceSyncZipPath'):provider.index('public getDeviceSyncZipPath') + 500]
),
"Apple platforms resolve to iOS LiveSync": (
'isApplePlatform(platform)' in resolver and '"iOSLiveSyncService"' in resolver
),
"iOS fullSync uses the non-emulator archive branch": (
'if (device.isEmulator)' in ios and
'device.fileSystem.transferFiles(deviceAppData' in ios and
'getDevicePath: () => deviceAppData.deviceSyncZipPath' in ios
),
"Catalyst transferFiles performs local copy": (
'public async transferFiles(' in catalyst_fs and
'this.transferFile(' in catalyst_fs
),
}
for name, result in checks.items():
print(f"{'PASS' if result else 'FAIL'}: {name}")
if not all(checks.values()):
raise SystemExit(1)
PYRepository: NativeScript/nativescript-cli
Length of output: 500
Keep Catalyst out of the iOS archive sync path.
IOSLiveSyncService.fullSync sends Catalyst sync.zip to the iOS archive path. MacCatalystFileSystem performs local copies, so this destination is incorrect. Route Catalyst through direct local copying; returning undefined from getDeviceSyncZipPath alone is insufficient.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@lib/device-path-provider.ts` around lines 20 - 33, Update the Catalyst
handling across the device sync flow, including IOSLiveSyncService.fullSync and
MacCatalystFileSystem, so Catalyst devices use direct local copying rather than
the iOS archive sync destination. Do not rely solely on returning undefined from
getDeviceSyncZipPath; ensure fullSync detects or delegates Catalyst devices
before sending sync.zip to the iOS archive path, while preserving existing
non-Catalyst behavior.
| if (config.ios && config.ios.id) { | ||
| identifier.catalyst = config.ios.id; | ||
| } | ||
| if (config.catalyst && config.catalyst.id) { | ||
| identifier.catalyst = config.catalyst.id; | ||
| } |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win
Apply catalyst.id to the Xcode target.
config.catalyst.id is stored here. IOSProjectService.setProductBundleIdentifier still writes projectIdentifiers.ios to PRODUCT_BUNDLE_IDENTIFIER. A Catalyst build therefore ignores the explicit Catalyst identifier.
Select projectIdentifiers.catalyst when the requested platform is Catalyst.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@lib/project-data.ts` around lines 350 - 355, Update
IOSProjectService.setProductBundleIdentifier to select
projectIdentifiers.catalyst when the requested platform is Catalyst, while
retaining projectIdentifiers.ios for iOS builds, so config.catalyst.id is
applied to the Xcode target’s PRODUCT_BUNDLE_IDENTIFIER.
| ): Promise<any> { | ||
| return new Promise(async (resolve, reject) => { | ||
| if (this.bundlerProcesses[platformData.platformNameLowerCase]) { | ||
| if (this.bundlerProcesses[prepareData.platform.toLowerCase()]) { |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
Use the requested platform key for the Vite server.
These lines store the bundler process under catalyst. startViteDevServer still stores its process under platformData.platformNameLowerCase, which is ios for Catalyst. stopBundlerCompiler("catalyst") then leaves the Vite server running. A later watch session can reuse the stale server or collide with it.
Proposed fix
- const key = platformData.platformNameLowerCase;
+ const key = prepareData.platform.toLowerCase();Also applies to: 599-599
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@lib/services/bundler/bundler-compiler-service.ts` at line 112, Update
startViteDevServer to store the Vite process under the requested platform key
passed by the caller, such as catalyst, rather than
platformData.platformNameLowerCase. Ensure the corresponding stopBundlerCompiler
lookup uses the same key so the server is removed and stopped correctly.
| /** | ||
| * Mac Catalyst starts at iOS 13.1, so a project that still targets an older iOS | ||
| * cannot be built as-is. Raise the deployment target for the Catalyst build only | ||
| * rather than failing — the iOS build keeps whatever the app has chosen. | ||
| * `MACCATALYST_DEPLOYMENT_TARGET` is passed alongside because the runtime's | ||
| * metadata generator reads it and older runtimes crash when it is unset. | ||
| */ | ||
| private getCatalystDeploymentTargetArgs(projectData: IProjectData): string[] { | ||
| const buildSettingsFilePath = path.join( | ||
| projectData.appResourcesDirectoryPath, | ||
| this.$devicePlatformsConstants.iOS, | ||
| constants.BUILD_XCCONFIG_FILE_NAME, | ||
| ); | ||
| const projectDeploymentTarget = this.$xcconfigService.readPropertyValue( | ||
| buildSettingsFilePath, | ||
| "IPHONEOS_DEPLOYMENT_TARGET", | ||
| ); | ||
| const minimum = XcodebuildArgsService.MIN_CATALYST_DEPLOYMENT_TARGET; | ||
| let deploymentTarget = projectDeploymentTarget; | ||
|
|
||
| if ( | ||
| !deploymentTarget || | ||
| semver.lt(semver.coerce(deploymentTarget), semver.coerce(minimum)) | ||
| ) { | ||
| if (deploymentTarget) { | ||
| this.$logger.warn( | ||
| `Mac Catalyst requires iOS ${minimum} or higher. Building the Mac Catalyst app with IPHONEOS_DEPLOYMENT_TARGET=${minimum} instead of the project's ${deploymentTarget}.`, | ||
| ); | ||
| } | ||
| deploymentTarget = minimum; | ||
| } | ||
|
|
||
| return [ | ||
| `IPHONEOS_DEPLOYMENT_TARGET=${deploymentTarget}`, | ||
| `MACCATALYST_DEPLOYMENT_TARGET=${deploymentTarget}`, | ||
| ]; | ||
| } |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🌐 Web query:
For semver 7.7.3, does semver.lt(semver.coerce("$(IPHONEOS_DEPLOYMENT_TARGET)"), "13.1")throw whensemver.coercereturnsnull?
💡 Result:
Yes, in semver 7.7.3 (as well as other versions), the semver.lt function will throw an error if passed null as an argument [1][2][3]. Because semver.coerce returns null when it cannot successfully parse a valid semantic version from the provided input string [4][5][6], passing that null result directly into semver.lt will trigger a TypeError (e.g., "Invalid Version: null" or "Invalid Version: undefined" depending on the internal implementation handling) [1][2]. To avoid this, you should verify the output of semver.coerce before passing it to comparison functions: javascript const coerced = semver.coerce("$(IPHONEOS_DEPLOYMENT_TARGET)"); if (coerced) { semver.lt(coerced, "13.1"); } else { // Handle the case where the version could not be coerced }
Citations:
- 1: yup npm package throws "Invalid Version: undefined" error flow-typed/flow-typed#3834
- 2: TypeError: Invalid Version: null amilajack/eslint-plugin-compat#284
- 3: https://docs.seedcli.dev/modules/semver
- 4: https://docs.npmjs.com/cli/v6/using-npm/semver/
- 5: https://manpages.ubuntu.com/manpages/jammy/man1/semver.1.html
- 6: https://www.npmjs.com/package/semver
🏁 Script executed:
#!/bin/bash
set -eu
printf '%s\n' '--- semver dependency declarations ---'
rg -n '"semver"|"`@types/semver`"' package.json package-lock.json yarn.lock pnpm-lock.yaml 2>/dev/null || true
printf '%s\n' '--- relevant source symbols and usages ---'
rg -n -C 6 'getCatalystDeploymentTargetArgs|IPHONEOS_DEPLOYMENT_TARGET|readPropertyValue|MIN_CATALYST_DEPLOYMENT_TARGET' lib test tests 2>/dev/null || true
printf '%s\n' '--- xcconfig service definitions ---'
fd -i '.*xcconfig.*' .Repository: NativeScript/nativescript-cli
Length of output: 25998
🏁 Script executed:
#!/bin/bash
set -eu
printf '%s\n' '--- xcconfig parser implementation ---'
sed -n '1,180p' lib/services/xcconfig-service.ts
printf '%s\n' '--- xcconfig parser tests ---'
sed -n '1,180p' test/xcconfig-service.ts
printf '%s\n' '--- deployment-target tests ---'
rg -n -C 12 'Catalyst|catalyst|deployment target|deploymentTarget|MACCATALYST_DEPLOYMENT_TARGET' test lib/services/ios/xcodebuild-args-service.tsRepository: NativeScript/nativescript-cli
Length of output: 19603
🏁 Script executed:
#!/bin/bash
set -eu
node - <<'JS'
const semver = require("semver");
for (const value of ["$(IPHONEOS_DEPLOYMENT_TARGET)", "", "13.0", "13.1"]) {
const coerced = semver.coerce(value);
let comparison;
let error;
try {
comparison = semver.lt(coerced, "13.1");
} catch (err) {
error = String(err);
}
console.log(JSON.stringify({ value, coerced: coerced && coerced.version, comparison, error }));
}
JSRepository: NativeScript/nativescript-cli
Length of output: 564
Guard the coerced deployment target before comparing.
If IPHONEOS_DEPLOYMENT_TARGET contains an Xcode variable, semver.coerce() returns null, and semver.lt() throws. Keep semver.coerce(minimum) in the comparison because the raw "13.1" value is not a valid strict semver value.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@lib/services/ios/xcodebuild-args-service.ts` around lines 290 - 326, The
semver comparison in getCatalystDeploymentTargetArgs must handle deployment
targets containing unresolved Xcode variables without throwing. Store the
coerced project deployment target, only call semver.lt when that value is
non-null, and continue comparing against semver.coerce(minimum) while treating
an uncoercible project value as requiring the minimum target.
| // projectRoot already accounts for hostProjectPath and platforms/catalyst. | ||
| this.$fs.deleteDirectory(platformData.projectRoot); |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Map the platform-data sources before inspecting project-root resolution.
ast-grep outline lib/services/platforms-data-service.ts --items all
ast-grep outline lib/project-data.ts --items all
# Inspect how hostProjectPath and projectRoot relate across add-platform flows.
rg -n -C 8 '\b(hostProjectPath|projectRoot)\b' \
lib/services/platforms-data-service.ts \
lib/project-data.ts \
lib/services/platform/add-platform-service.tsRepository: NativeScript/nativescript-cli
Length of output: 5755
🏁 Script executed:
#!/bin/bash
set -euo pipefail
printf '%s\n' '--- platforms data service ---'
cat -n lib/services/platforms-data-service.ts | sed -n '1,180p'
printf '%s\n' '--- add platform service ---'
cat -n lib/services/platform/add-platform-service.ts | sed -n '1,220p'
printf '%s\n' '--- platform data definitions and hostProjectPath references ---'
rg -n -C 6 'interface IPlatformData|hostProjectPath|platforms/catalyst|platformData\.projectRoot|getPlatformData' \
lib/definitions lib lib/services --glob '*.ts' | sed -n '1,260p'Repository: NativeScript/nativescript-cli
Length of output: 24946
🏁 Script executed:
#!/bin/bash
set -euo pipefail
printf '%s\n' '--- platform getPlatformData implementations ---'
rg -n -l 'getPlatformData\s*\(' lib --glob '*.ts' --glob '*.js' | sort
printf '%s\n' '--- platform root construction ---'
rg -n -C 12 'getPlatformData\s*\(|projectRoot\s*[:=]|hostProjectPath|platformsDir' \
lib/platforms lib/services lib --glob '*.ts' --glob '*.js' \
| rg -v 'node_modules' | sed -n '1,360p'
printf '%s\n' '--- add-platform call graph ---'
rg -n -C 10 'addPlatformSafe|addPlatform\(|hostProjectPath' \
lib/services lib/helpers lib/commands lib/controllers --glob '*.ts' | sed -n '1,300p'Repository: NativeScript/nativescript-cli
Length of output: 26517
🏁 Script executed:
#!/bin/bash
set -euo pipefail
printf '%s\n' '--- Android project data ---'
rg -n -C 20 'getPlatformData|projectRoot|hostProjectPath|platformsDir' \
lib/services/android-project-service.ts | sed -n '1,240p'
printf '%s\n' '--- iOS project data ---'
rg -n -C 24 'getPlatformData|projectRoot|hostProjectPath|platformsDir' \
lib/services/ios-project-service.ts | sed -n '1,280p'
printf '%s\n' '--- Catalyst and host-path helpers ---'
rg -n -C 16 'isCatalystPlatform|hostProjectPath|USER_PROJECT_PLATFORMS_IOS|USER_PROJECT_PLATFORMS_ANDROID' \
lib --glob '*.ts' | sed -n '1,300p'
printf '%s\n' '--- add-platform service tests ---'
rg -n -l 'AddPlatformService|addPlatformSafe|deleteDirectory' test tests spec lib --glob '*.{ts,js}' 2>/dev/null | sort | sed -n '1,160p'Repository: NativeScript/nativescript-cli
Length of output: 36973
🏁 Script executed:
#!/bin/bash
set -euo pipefail
printf '%s\n' '--- platform controller add flow ---'
cat -n lib/controllers/platform-controller.ts | sed -n '40,155p'
printf '%s\n' '--- hostProjectPath assignments and add-platform invocations ---'
rg -n -C 12 'hostProjectPath\s*=|hostProjectPath:|addPlatformSafe|addPlatformService' \
lib test --glob '*.{ts,js}' | sed -n '1,360p'
printf '%s\n' '--- add-platform service tests ---'
cat -n test/services/platform/add-platform-service.ts | sed -n '1,320p'
printf '%s\n' '--- controller tests around host project handling ---'
rg -n -C 16 'hostProjectPath|addPlatformIfNeeded|addPlatform' \
test/controllers test --glob '*.{ts,js}' | sed -n '1,320p'Repository: NativeScript/nativescript-cli
Length of output: 50385
🏁 Script executed:
#!/bin/bash
set -euo pipefail
printf '%s\n' '--- embedding command execution ---'
cat -n lib/commands/embedding/embed.ts | sed -n '40,190p'
printf '%s\n' '--- all PlatformController.addPlatform callers ---'
rg -n -C 14 '\$platformController\.addPlatform|platformController\.addPlatform|addPlatformIfNeeded' \
lib --glob '*.ts' | sed -n '1,300p'
printf '%s\n' '--- host option propagation ---'
rg -n -C 12 'hostProjectPath' \
lib/commands lib/controllers lib/services lib/options.ts lib/declarations.d.ts \
--glob '*.ts' --glob '*.d.ts' | sed -n '1,360p'Repository: NativeScript/nativescript-cli
Length of output: 38474
🏁 Script executed:
#!/bin/bash
set -euo pipefail
python3 - <<'PY'
from pathlib import Path
import posixpath
import re
android = Path("lib/services/android-project-service.ts").read_text()
ios = Path("lib/services/ios-project-service.ts").read_text()
add = Path("lib/services/platform/add-platform-service.ts").read_text()
controller = Path("lib/controllers/platform-controller.ts").read_text()
assert "const projectRoot = this.$options.hostProjectPath" in android
assert "const projectRoot = this.$options.hostProjectPath" in ios
assert re.search(r"this\.\$fs\.deleteDirectory\(platformData\.projectRoot\);", add)
assert re.search(
r"if\s*\(!this\.\$options\.hostProjectPath\)\s*\{\s*"
r"this\.\$fs\.deleteDirectory\(platformData\.projectRoot\);",
add,
re.S,
)
def root(host, platforms, platform):
return host if host else posixpath.join(platforms, platform)
cases = [
(None, "/project/platforms", "android"),
(None, "/project/platforms", "catalyst"),
("/user/native-host", "/project/platforms", "android"),
("/user/native-host", "/project/platforms", "catalyst"),
]
for host, platforms, platform in cases:
print({
"hostProjectPath": host,
"platform": platform,
"projectRoot": root(host, platforms, platform),
"failure_cleanup_deletes": not bool(host),
"native_cleanup_deletes": True,
})
print("root mapping: hostProjectPath overrides platforms/<platform>")
print("failure cleanup: guarded by !hostProjectPath")
print("native cleanup: unconditional")
print("addPlatformIfNeeded: returns before addPlatform when addPlatformData.hostProjectPath is set:",
bool(re.search(r"if\s*\(addPlatformData\.hostProjectPath\).*?return;", controller, re.S)))
PYRepository: NativeScript/nativescript-cli
Length of output: 1054
🏁 Script executed:
#!/bin/bash
set -euo pipefail
python3 - <<'PY'
from pathlib import Path
import posixpath
import re
android = Path("lib/services/android-project-service.ts").read_text()
ios = Path("lib/services/ios-project-service.ts").read_text()
add = Path("lib/services/platform/add-platform-service.ts").read_text()
controller = Path("lib/controllers/platform-controller.ts").read_text()
assert "const projectRoot = this.$options.hostProjectPath" in android
assert "const projectRoot = this.$options.hostProjectPath" in ios
assert re.search(r"this\.\$fs\.deleteDirectory\(platformData\.projectRoot\);", add)
assert re.search(
r"if\s*\(!this\.\$options\.hostProjectPath\)\s*\{\s*"
r"this\.\$fs\.deleteDirectory\(platformData\.projectRoot\);",
add,
re.S,
)
def root(host, platforms, platform):
return host if host else posixpath.join(platforms, platform)
for host, platforms, platform in [
(None, "/project/platforms", "android"),
(None, "/project/platforms", "catalyst"),
("/user/native-host", "/project/platforms", "android"),
("/user/native-host", "/project/platforms", "catalyst"),
]:
print({
"hostProjectPath": host,
"platform": platform,
"projectRoot": root(host, platforms, platform),
"failure_cleanup_deletes": not bool(host),
"native_cleanup_deletes": True,
})
print("root mapping: hostProjectPath overrides platforms/<platform>")
print("failure cleanup: guarded by !hostProjectPath")
print("native cleanup: unconditional")
print(
"addPlatformIfNeeded: returns before addPlatform when addPlatformData.hostProjectPath is set:",
bool(re.search(
r"if\s*\(addPlatformData\.hostProjectPath\).*?return;",
controller,
re.S,
)),
)
PYRepository: NativeScript/nativescript-cli
Length of output: 1054
Protect hostProjectPath during native-platform cleanup
projectRoot resolves to $options.hostProjectPath for host projects and to platforms/<platform> otherwise. The failure cleanup at lib/services/platform/add-platform-service.ts#L73-L76 already deletes only CLI-owned roots. Guard lib/services/platform/add-platform-service.ts#L187-L188 with the same ownership check before deleting platformData.projectRoot; otherwise native cleanup can delete the host project.
📍 Affects 1 file
lib/services/platform/add-platform-service.ts#L187-L188(this comment)lib/services/platform/add-platform-service.ts#L73-L76
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@lib/services/platform/add-platform-service.ts` around lines 187 - 188, Guard
the cleanup deletion in add-platform-service.ts:187-188 with the same
CLI-owned-root check used at add-platform-service.ts:73-76, so
platformData.projectRoot is deleted only when it is not
$options.hostProjectPath. Update the native-platform cleanup around the visible
deleteDirectory call; the sibling failure-cleanup site at
add-platform-service.ts:73-76 requires no direct change and serves as the
ownership-check reference.
PR Checklist
What is the current behavior?
There is no way to build or run a NativeScript app as a Mac Catalyst app.
What is the new behavior?
catalystbecomes a supported platform:ns build catalystandns run catalyst, the latter with full LiveSync.A Catalyst app keeps every iOS convention —
App_Resources/iOS, the iOS runtime package, each plugin'splatforms/iosfolder, the iOS Podfile, the iOS bundle — and diverges only in the directory it prepares into (platforms/catalyst) and the SDK it builds against.iOSProjectServicereports iOS as the platform name so every iOS convention falls out for free, andprojectRootbecomes the single place Catalyst differs. Two call sites rebuilt the platform directory from the platform name instead of readingprojectRoot; both now useprojectRoot, which already resolveshostProjectPathidentically. Left as-is, a Catalyst build would have probed and deletedplatforms/ios.xcodebuildgets-destination 'platform=macOS,variant=Mac Catalyst'andSUPPORTS_MACCATALYST=YES, with the deployment target clamped to the 13.1 minimum. Products land in<Configuration>-maccatalyst..appis a directory on this machine, so the file system is a plain copy, install only records the built bundle, launch isopen -n, and the log stream islog streamnarrowed to the app and the runtime.run-controllerpairs an event with a device by comparing the two, so for Catalyst — iOS platform data, Catalyst device — a file change recompiled but reached no device. The non-watch path already reported the requested platform; watchers and bundler processes are keyed by it too, sincestopWatchersandstopBundlerCompilerare called with it. Nothing changes for ios/android/visionos, where both strings are identical.Testing
tscandnpm run buildare clean, and the new files passprettier --check.End-to-end verification used a real app (OSS Weather) on Apple Silicon:
ns build catalystruns pod install, generates metadata forarm64-apple-ios<version>-macabiand links and signs the.app;ns run catalystlaunches it, streams its logs, and syncs a file change into the running app — an addedconsole.logcame back through the CLI after the automatic restart.A caveat on where that ran. That app depends on a fork-only bundler feature (a configurable webpack package name), so on this branch alone
ns run catalystgets as far as adding the platform (Platform catalyst successfully added,platforms/catalystcreated), discovering the Mac device and starting prepare, then fails inside webpack. Command registration, platform resolution and device discovery are therefore exercised on this branch; the build/launch/LiveSync steps were verified by running this exact code — including the rename — on a branch where the app's bundler resolves. Happy to redo the whole run against a vanilla template, though the runtime caveat below has to land first.No unit tests are included: the change is platform plumbing plus a device implementation that shells out to
open,pkillandlog, none of which the existing suite has a harness for.For reference,
vitest runreports 189 failures out of 1648 here — the suite is already red onmainwith what appears to be the same set, so I have left that checklist box unchecked rather than claim otherwise.Note for reviewers
Two runtime-side fixes in
NativeScript/iosare needed before a clean checkout can build a working Catalyst app:iOSSupportframework search paths, or the app launches intoReferenceError: UIDevice is not defined(opened as fix(metadata-generator): emit UIKit metadata for Mac Catalyst ios#433);iOS never hits either, because it uses shallow framework bundles and its own SDK paths.
Summary by CodeRabbit
run catalystandbuild catalystcommands for building and launching Catalyst apps on macOS.