Skip to content

feat(catalyst): run and build Mac Catalyst apps with ns run catalyst - #6125

Open
farfromrefug wants to merge 2 commits into
NativeScript:mainfrom
Akylas:feat/macos-catalyst
Open

feat(catalyst): run and build Mac Catalyst apps with ns run catalyst#6125
farfromrefug wants to merge 2 commits into
NativeScript:mainfrom
Akylas:feat/macos-catalyst

Conversation

@farfromrefug

@farfromrefug farfromrefug commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

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?

catalyst becomes a supported platform: ns build catalyst and ns run catalyst, the latter with full LiveSync.

On the name. This deliberately does not claim macos. A separate effort adds ns run macos for a native macOS app built against a macOS runtime — a different product. Mac Catalyst is the iOS app rebuilt against the macOS SDK, so it takes the name that says what it is. The two can land independently.

A Catalyst app 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/catalyst) and the SDK it builds against.

  • Platform resolution. iOSProjectService reports iOS as the platform name so every iOS convention falls out for free, and projectRoot becomes the single place Catalyst differs. Two call sites rebuilt the platform directory from the platform name instead of reading projectRoot; both now use projectRoot, which already resolves hostProjectPath identically. Left as-is, a Catalyst build would have probed and deleted platforms/ios.
  • Catalyst build. xcodebuild gets -destination 'platform=macOS,variant=Mac Catalyst' and SUPPORTS_MACCATALYST=YES, with the deployment target clamped to the 13.1 minimum. Products land in <Configuration>-maccatalyst.
  • The Mac as a device. Build, deploy and LiveSync drive it through the existing pipeline. Everything 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 platform matching. Prepare events are now 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, 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, since stopWatchers and stopBundlerCompiler are called with it. Nothing changes for ios/android/visionos, where both strings are identical.

Testing

tsc and npm run build are clean, and the new files pass prettier --check.

End-to-end verification used a real app (OSS Weather) on Apple Silicon: ns build catalyst runs pod install, generates metadata for arm64-apple-ios<version>-macabi and links and signs the .app; ns run catalyst launches it, streams its logs, and syncs a file change into the running app — an added console.log came 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 catalyst gets as far as adding the platform (Platform catalyst successfully added, platforms/catalyst created), 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, pkill and log, none of which the existing suite has a harness for.

For reference, vitest run reports 189 failures out of 1648 here — the suite is already red on main with 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/ios are needed before a clean checkout can build a working Catalyst app:

  • the metadata generator needs the iOSSupport framework search paths, or the app launches into ReferenceError: UIDevice is not defined (opened as fix(metadata-generator): emit UIKit metadata for Mac Catalyst ios#433);
  • the packaged xcframeworks must keep their versioned-bundle symlinks, or codesign rejects the app with "code object is not signed at all".

iOS never hits either, because it uses shallow framework bundles and its own SDK paths.

Summary by CodeRabbit

  • New Features
    • Added Mac Catalyst as a supported platform.
    • Added run catalyst and build catalyst commands for building and launching Catalyst apps on macOS.
    • Added Catalyst device discovery, application management, file transfers, logs, and debugging support.
    • Added Catalyst project configuration and automatic app bundle handling.
  • Bug Fixes
    • Improved platform path resolution and build output handling for Catalyst projects.
    • Added deployment-target validation, defaulting unsupported targets to iOS 13.1.

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>
@coderabbitai

coderabbitai Bot commented Aug 11, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The 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 run|catalyst and build|catalyst commands.

Changes

Mac Catalyst support

Layer / File(s) Summary
Platform contracts and normalization
lib/constants.ts, lib/common/definitions/mobile.d.ts, lib/common/mobile/device-platforms-constants.ts, lib/common/mobile/mobile-helper.ts, lib/definitions/project.d.ts, lib/project-data.ts, lib/services/platforms-data-service.ts, lib/services/project-data-service.ts
Platform types, configuration, identifiers, mappings, and normalization now recognize Catalyst.
Local Catalyst device integration
lib/common/bootstrap.ts, lib/common/mobile/mobile-core/*, lib/common/mobile/mac/*, lib/device-path-provider.ts
The CLI discovers one local Catalyst device on Darwin and provides application, filesystem, bundle-path, and device-event integration.
Catalyst build pipeline
lib/definitions/ios.d.ts, lib/services/ios-project-service.ts, lib/services/ios/xcodebuild-args-service.ts, lib/services/ios/xcodebuild-service.ts, lib/services/bundler/bundler-compiler-service.ts, lib/services/platform/*, lib/controllers/platform-controller.ts
Catalyst builds use macOS Catalyst Xcode arguments, a minimum deployment target of 13.1, .app output, Catalyst project roots, and normalized bundler platform keys.
CLI commands and preparation state
lib/bootstrap.ts, lib/commands/build.ts, lib/commands/run.ts, lib/controllers/prepare-controller.ts
The CLI registers Catalyst build and run commands. Preparation watchers, compiler state, events, and results use the normalized requested platform.

Estimated code review effort: 4 (Complex) | ~60 minutes

Possibly related PRs

Suggested reviewers: nathanwalker

Poem

A rabbit packs an .app with care,
Through Catalyst paths and macOS air.
Build and run now hop in line,
Xcode commands execute fine.
Local devices join the show—
Wiggle ears, and off we go!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies the main change: adding Mac Catalyst support for running and building apps through the CLI.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

`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>
@farfromrefug farfromrefug changed the title feat(macos): run and build Mac Catalyst apps with ns run macos feat(catalyst): run and build Mac Catalyst apps with ns run catalyst Aug 11, 2026
@farfromrefug
farfromrefug marked this pull request as ready for review August 11, 2026 15:52

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

📥 Commits

Reviewing files that changed from the base of the PR and between 9322fdc and 26860f8.

📒 Files selected for processing (27)
  • lib/bootstrap.ts
  • lib/commands/build.ts
  • lib/commands/run.ts
  • lib/common/bootstrap.ts
  • lib/common/definitions/mobile.d.ts
  • lib/common/mobile/device-platforms-constants.ts
  • lib/common/mobile/mac/mac-catalyst-application-manager.ts
  • lib/common/mobile/mac/mac-catalyst-device.ts
  • lib/common/mobile/mac/mac-catalyst-file-system.ts
  • lib/common/mobile/mobile-core/devices-service.ts
  • lib/common/mobile/mobile-core/ios-device-discovery.ts
  • lib/common/mobile/mobile-core/mac-catalyst-discovery.ts
  • lib/common/mobile/mobile-helper.ts
  • lib/constants.ts
  • lib/controllers/platform-controller.ts
  • lib/controllers/prepare-controller.ts
  • lib/definitions/ios.d.ts
  • lib/definitions/project.d.ts
  • lib/device-path-provider.ts
  • lib/project-data.ts
  • lib/services/bundler/bundler-compiler-service.ts
  • lib/services/ios-project-service.ts
  • lib/services/ios/xcodebuild-args-service.ts
  • lib/services/ios/xcodebuild-service.ts
  • lib/services/platform/add-platform-service.ts
  • lib/services/platforms-data-service.ts
  • lib/services/project-data-service.ts

Comment on lines +65 to +80
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}`,
);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 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}));
}
JS

Repository: 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 || true

Repository: 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:


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.

Comment on lines 60 to +65
public isApplePlatform(platform: string): boolean {
return this.isiOSPlatform(platform) || this.isvisionOSPlatform(platform);
return (
this.isiOSPlatform(platform) ||
this.isvisionOSPlatform(platform) ||
this.isCatalystPlatform(platform)
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 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*\(' lib

Repository: 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' lib

Repository: 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.ts

Repository: 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.

Comment on lines +20 to +33
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;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 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 ts

Repository: 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 ts

Repository: 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 ts

Repository: 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)
PY

Repository: 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.

Comment thread lib/project-data.ts
Comment on lines +350 to +355
if (config.ios && config.ios.id) {
identifier.catalyst = config.ios.id;
}
if (config.catalyst && config.catalyst.id) {
identifier.catalyst = config.catalyst.id;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ 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()]) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 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.

Comment on lines +290 to +326
/**
* 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}`,
];
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 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:


🏁 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.ts

Repository: 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 }));
}
JS

Repository: 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.

Comment on lines +187 to +188
// projectRoot already accounts for hostProjectPath and platforms/catalyst.
this.$fs.deleteDirectory(platformData.projectRoot);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ 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.ts

Repository: 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)))
PY

Repository: 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,
    )),
)
PY

Repository: 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant