Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions lib/services/ios-project-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1336,6 +1336,30 @@ export class IOSProjectService
[],
);
if (packages.length) {
for (const pkg of packages) {
// a plugin's local package path is naturally authored relative
// to the plugin itself, but the SPM service resolves relative
// paths against the app project dir. When the app-relative
// path doesn't exist (e.g. non-hoisted node_modules layouts),
// fall back to resolving against the plugin's own directory.
if (
"path" in pkg &&
pkg.path &&
!path.isAbsolute(pkg.path) &&
!this.$fs.exists(path.resolve(projectData.projectDir, pkg.path))
) {
const pluginRelativePath = path.resolve(
plugin.fullPath,
pkg.path,
);
if (this.$fs.exists(pluginRelativePath)) {
this.$logger.trace(
`SPM: resolved plugin-relative package path for ${pkg.name}: ${pluginRelativePath}`,
);
pkg.path = pluginRelativePath;
}
}
}
pluginSpmPackages.push(...packages);
}
}
Expand Down Expand Up @@ -1505,9 +1529,15 @@ export class IOSProjectService
): Promise<void> {
const project = this.createPbxProj(projectData);
const group = await this.getRootGroup(groupName, sourceFolderPath);
// pin the sources to the main app target: without an explicit target the
// underlying xcode lib picks whichever "Sources" build phase it finds
// first, which can be an extension target (e.g. a widget) once one
// exists — compiling plugin native code into extensions breaks their
// builds (and bloats them) since they lack the app's search paths.
project.addPbxGroup(group.files, group.name, group.path, null, {
isMain: true,
filesRelativeToProject: true,
target: project.getFirstTarget()?.uuid,
Comment on lines +1532 to +1540

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

🏁 Script executed:

#!/bin/bash
rg -n 'getFirstTarget' -A 10 -g '*.js' -g '*.ts' node_modules/@nativescript/nativescript-dev-xcode 2>/dev/null
rg -n 'getFirstTarget' lib/definitions/nativescript-dev-xcode.d.ts

Repository: NativeScript/nativescript-cli

Length of output: 208


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate and inspect the implementation and relevant usage context without running repository code.
echo "== find package files =="
git ls-files | rg '(^|/)node_modules/@nativescript/nativescript-dev-xcode/|^packages/.*/lib/services/ios-project-service\.ts$|lib/services/ios-project-service\.ts$' || true

echo
echo "== getFirstTarget references =="
rg -n 'getFirstTarget|addExtensions|prepareProject|addPbxGroup' -S \
  -g '*.js' -g '*.ts' \
  . \
  | head -n 200

echo
echo "== package declaration files mentioning nativescript-dev-xcode =="
for f in package.json pnpm-lock.yaml yarn.lock package-lock.json; do
  if [ -f "$f" ]; then
    echo "--- $f ---"
    rg -n '`@nativescript/nativescript-dev-xcode`|nativescript-dev-xcode' "$f" | head -n 50 || true
  fi
done

echo
echo "== current working tree status =="
git status --short | head -n 50 || true

Repository: NativeScript/nativescript-cli

Length of output: 2972


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the relevant implementation if the package is present in the checkout.
if [ -d node_modules/@nativescript/nativescript-dev-xcode ]; then
  echo "== nativescript-dev-xcode package contents =="
  git ls-files node_modules/@nativescript/nativescript-dev-xcode | head -n 200 || true
  echo
  echo "== getFirstTarget implementation candidates =="
  rg -n 'getFirstTarget|getTargets|targets\s*=|targetNames|firstTarget' node_modules/@nativescript/nativescript-dev-xcode -S -A 12 -B 8
else
  echo "node_modules/@nativescript/nativescript-dev-xcode not present"
fi

Repository: NativeScript/nativescript-cli

Length of output: 229


🌐 Web query:

@nativescript/nativescript-dev-xcode 0.8.2 getFirstTarget source

💡 Result:

The package @nativescript/nativescript-dev-xcode is a fork of apache/cordova-node-xcode [1][2]. Searches performed across the repository and related documentation did not locate a function named getFirstTarget within the @nativescript/nativescript-dev-xcode codebase [1][3][4]. While the term getFirstTarget appears in other unrelated projects (such as Java-based implementations or other libraries) [5][6][7], it is not a documented or standard part of the @nativescript/nativescript-dev-xcode API [2][3]. The primary API for this library involves methods like addHeaderFile, addSourceFile, and addFramework, which are used after parsing a project file [2][3].

Citations:


🌐 Web query:

site:nativescript.org or site:github.com/NativeScript/nativescript dev-xcode getFirstTarget

💡 Result:

The term dev-xcode refers to nativescript-dev-xcode, which is a Node.js library used to parse and modify Xcode project (.xcodeproj) files [1]. It was forked from the cordova-node-xcode project [1]. There is no standard, widely documented function named getFirstTarget within the public API of the nativescript-dev-xcode library [1]. While the library allows for the manipulation of Xcode project files, interaction with targets typically involves accessing the project's internal data structures, such as the pbxproj object, which represents the project's configuration [1]. If you are looking to interact with targets in an Xcode project using this library, you would typically parse the project file first and then access the targets array within the project object [1]: const xcode = require("xcode"); const projectPath = "myproject.xcodeproj/project.pbxproj"; const myProj = xcode.project(projectPath); myProj.parse(function (err) { // Targets are usually stored in myProj.hash.project.objects.PBXNativeTarget const targets = myProj.pbxNativeTargets; const firstTarget = targets; console.log(firstTarget); }); Because nativescript-dev-xcode is a wrapper around cordova-node-xcode, its API methods are consistent with the original library, which primarily exposes methods like addHeaderFile, addSourceFile, and addFramework [1]. You may need to inspect the specific version of the library you are using to confirm if any custom helper methods exist, or access the targets directly via the parsed object [1].

Citations:


🌐 Web query:

npm package nativescript-dev-xcode 0.8.2 getFirstTarget

💡 Result:

In the nativescript-dev-xcode package (which is a fork of cordova-node-xcode), the getFirstTarget method is a utility function used to retrieve the first target defined within an Xcode project (.pbxproj) file [1][2][3]. Based on its implementation and standard usage in projects derived from this parser, getFirstTarget returns an object containing the UUID and the target details [3][4][5]. Typical usage often looks like this: const target = xcode.getFirstTarget; // target.uuid provides the UUID of the first target // target.firstTarget provides the object representation of the target The method is frequently used in build scripts and plugin logic (such as Cordova or NativeScript plugins) to automatically identify the main project target when performing operations like adding build phases, modifying build settings, or embedding frameworks [6]. While version 0.8.2 is the latest release (published June 10, 2026) [7][8], the getFirstTarget method is a long-standing feature in this codebase, inherited from the original cordova-node-xcode parser [1].

Citations:


🌐 Web query:

github.com NativeScript nativescript-dev-xcode getFirstTarget implementation

💡 Result:

In the nativescript-dev-xcode library, which is a fork of cordova-node-xcode [1][2], the getFirstTarget method is intended to retrieve the first build target defined within the Xcode project file [3][4][5]. Based on the library's implementation history and shared ancestry with node-xcode, the method generally accesses the project's native targets section and returns the first entry found [3][4]. It typically returns an object containing the UUID of the target and the target definition itself (often labeled as pbxNativeTarget) [4][5]. Usage example: const target = xcode.getFirstTarget; [3] Technical details: 1. Origin: The library is a parser utility for.xcodeproj/project.pbxproj files [1][2]. 2. Implementation Context: The method is used in automated build processes to identify the primary application or library target, though it can sometimes lead to issues if multiple targets (such as iOS and tvOS) are present, as it indiscriminately selects the first one [6]. 3. Signatures: You can find type definitions for this method in various related projects that consume the same node-xcode-style API, where it is defined as returning an object containing the UUID and the target details [7][4][5]. For specific implementation code, you should examine the lib/pbxProject.js file within the NativeScript/nativescript-dev-xcode repository on GitHub, as this file contains the core project parsing logic and method definitions [1][8][9].

Citations:


Use the main-target UUID explicitly before pinning Sources.

getFirstTarget() only exposes a uuid shape here; its implementation is not available locally to prove it excludes extension/watch targets. If an extension/watch target is pinned instead of the main app target, the Sources phase still resolves to the wrong target.

🤖 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-project-service.ts` around lines 1532 - 1540, Update the
target selection in the project.addPbxGroup call to resolve and use the main
application target’s UUID explicitly, rather than relying on
project.getFirstTarget().uuid. Ensure the pinned Sources group always targets
the app target and never an extension or watch target.

});
project.addToHeaderSearchPaths(group.path);
const headerFiles = this.$fs.exists(sourceFolderPath)
Expand Down
217 changes: 212 additions & 5 deletions lib/services/ios/spm-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,29 @@ import { IFileSystem } from "../../common/declarations";
import { ITerminalSpinnerService } from "../../definitions/terminal-spinner-service";
import { color } from "../../color";
import path = require("path");
import os = require("os");
import fs = require("fs");

export class SPMService implements ISPMService {
// SwiftPM keeps one bare clone per source package here (shared by Xcode and
// xcodebuild). Watching a package's clone grow is the only visibility into
// an otherwise silent long fetch — SwiftPM clones a repository's ENTIRE git
// history to read Package.swift, so a package hosted in a multi-GB repo can
// legitimately "fetch" for tens of minutes with no output.
private static readonly SWIFTPM_REPO_CACHE = path.join(
os.homedir(),
"Library",
"Caches",
"org.swift.swiftpm",
"repositories",
);
// Once a single package's clone passes this size, surface a one-time note
// explaining why the fetch is slow (250 MB is already far beyond any
// reasonably-hosted Swift package).
private static readonly LARGE_CLONE_NOTE_BYTES = 250 * 1024 * 1024;
// Lines of raw xcodebuild output kept for the failure report.
private static readonly OUTPUT_TAIL_LINES = 25;

constructor(
private $logger: ILogger,
private $fs: IFileSystem,
Expand Down Expand Up @@ -42,13 +63,22 @@ export class SPMService implements ISPMService {
// include swift packages from plugin configs
// but allow app packages to override plugin packages with the same name
const appPackageNames = new Set(appPackages.map((pkg) => pkg.name));
// multiple plugins may declare the same package (e.g. a shared shim) —
// only the first declaration is added; a second same-name package would
// produce duplicate (and possibly conflicting) references in the pbxproj.
const addedPluginPackageNames = new Set<string>();

for (const pluginPkg of pluginPackages) {
if (appPackageNames.has(pluginPkg.name)) {
this.$logger.trace(
`SPM: app package overrides plugin package: ${pluginPkg.name}`,
);
} else if (addedPluginPackageNames.has(pluginPkg.name)) {
this.$logger.trace(
`SPM: skipping duplicate plugin package: ${pluginPkg.name}`,
);
} else {
addedPluginPackageNames.add(pluginPkg.name);
appPackages.push(pluginPkg);
}
}
Expand Down Expand Up @@ -79,6 +109,11 @@ export class SPMService implements ISPMService {
return;
}

// name every package and where it comes from — when resolution is
// slow or fails, this is the first thing needed to tell WHICH
// dependency is responsible.
this.$logger.info(this.formatPackageListing(spmPackages));

const project = new MobileProject(platformData.projectRoot, {
ios: {
path: ".",
Expand All @@ -99,6 +134,13 @@ export class SPMService implements ISPMService {
// resolve the path relative to the project root
this.$logger.trace("SPM: resolving path for package: ", pkg.path);
pkg.path = path.resolve(projectData.projectDir, pkg.path);
if (!this.$fs.exists(pkg.path)) {
// surface this now — otherwise the only symptom is a cryptic
// xcodebuild resolution failure much later.
this.$logger.warn(
`SPM: local package path for "${pkg.name}" does not exist: ${pkg.path} — Xcode will fail to resolve it.`,
);
}
}
this.$logger.trace(`SPM: adding package ${pkg.name} to project.`, pkg);
await project.ios.addSPMPackage(projectData.projectName, pkg);
Expand Down Expand Up @@ -165,24 +207,80 @@ export class SPMService implements ISPMService {
const startedAt = Date.now();
let activity = "Resolving Swift Package dependencies";
let lineBuffer = "";
// package currently being git-fetched (short name), if any — used to
// measure its growing clone in the SwiftPM cache so a long silent fetch
// shows visible progress ("2.31 GB of git history fetched") instead of
// looking hung.
let fetchingPackageRef: string = null;
let fetchedBytes = 0;
let largeCloneNoted = false;
// rolling tail of raw xcodebuild output for the failure report.
const outputTail: string[] = [];

const render = () => {
const elapsed = Math.round((Date.now() - startedAt) / 1000);
spinner.text = `${activity}… ${color.dim(`(${this.formatElapsed(elapsed)})`)}`;
const fetched =
fetchedBytes > 0
? ` — ${this.formatBytes(fetchedBytes)} of git history fetched`
: "";
spinner.text = `${activity}…${fetched} ${color.dim(`(${this.formatElapsed(elapsed)})`)}`;
};
// keep the elapsed timer ticking even when xcodebuild is silent (e.g.
// while a binary artifact downloads) so the user can see it's alive.
const ticker = setInterval(render, 1000);
// while a repository clones or a binary artifact downloads) so the user
// can see it's alive. Every 5th tick, measure the in-progress clone.
let tickCount = 0;
const ticker = setInterval(() => {
tickCount++;
if (fetchingPackageRef && tickCount % 5 === 0) {
fetchedBytes = this.getPackageCloneSizeBytes(fetchingPackageRef);
if (
!largeCloneNoted &&
fetchedBytes >= SPMService.LARGE_CLONE_NOTE_BYTES
) {
largeCloneNoted = true;
// persist a one-time explanation above the spinner: this is the
// point where users otherwise assume the CLI is stuck.
spinner.stopAndPersist({
symbol: color.yellow("ℹ"),
text: color.yellow(
`the "${fetchingPackageRef}" package is hosted in a repository with a large git history — ` +
`SwiftPM clones the entire repository on first fetch, which can take a long time.${os.EOL}` +
` cache: ${SPMService.SWIFTPM_REPO_CACHE}`,
),
});
spinner.start();
}
}
render();
}, 1000);

const onProgress = (chunk: { data: string; pipe: string }) => {
lineBuffer += chunk.data;
const lines = lineBuffer.split("\n");
// tolerate CRLF as well as LF so parsed lines never carry a stray \r
const lines = lineBuffer.split(/\r?\n/);
// keep the last (possibly partial) line in the buffer
lineBuffer = lines.pop();
for (const line of lines) {
const trimmed = line.trim();
if (trimmed) {
this.$logger.trace(`SPM: ${trimmed}`);
outputTail.push(trimmed);
if (outputTail.length > SPMService.OUTPUT_TAIL_LINES) {
outputTail.shift();
}
}
const described = this.describeSPMActivity(line);
if (described) {
activity = described;
// track which package a "Fetching <url>" line refers to so the
// ticker can measure its clone; any other activity means the
// fetch finished.
if (/^Fetching\b/i.test(trimmed)) {
fetchingPackageRef = this.shortenPackageRef(trimmed);
} else {
fetchingPackageRef = null;
fetchedBytes = 0;
}
render();
}
}
Expand All @@ -195,9 +293,21 @@ export class SPMService implements ISPMService {
cwd: projectData.projectDir,
onProgress,
});
spinner.succeed(color.green("Swift Package dependencies resolved"));
const elapsed = Math.round((Date.now() - startedAt) / 1000);
spinner.succeed(
color.green("Swift Package dependencies resolved") +
color.dim(` (${this.formatElapsed(elapsed)})`),
);
} catch (err) {
spinner.fail(color.red("Failed to resolve Swift Package dependencies"));
// the spinner swallowed the raw log — replay the tail so the actual
// xcodebuild error is visible without rerunning in verbose mode.
if (outputTail.length) {
this.$logger.info(color.dim("xcodebuild output (last lines):"));
for (const line of outputTail) {
this.$logger.info(color.dim(` ${line}`));
}
}
throw err;
} finally {
clearInterval(ticker);
Expand Down Expand Up @@ -306,6 +416,103 @@ export class SPMService implements ISPMService {
return `${minutes}m ${seconds}s`;
}

/**
* Multi-line listing of every package and its source — one package per
* line, separated by the platform EOL so entries never clump together in
* terminal output on macOS, Windows, or Linux.
*/
private formatPackageListing(spmPackages: IosSPMPackage[]): string {
return [
"Swift Packages:",
...spmPackages.map((pkg) => ` ${this.describePackageSource(pkg)}`),
].join(os.EOL);
}

/**
* One-line description of a package and where it resolves from, e.g.
* "FontManager (1.0.12 · https://github.com/NativeScript/font-manager.git)"
* or "CanvasNative (local: node_modules/@nativescript/canvas/platforms/ios/NativeScriptV8)".
*/
private describePackageSource(pkg: IosSPMPackage): string {
if ("path" in pkg) {
return `${pkg.name} (local: ${pkg.path})`;
}
return `${pkg.name} (${pkg.version} · ${pkg.repositoryURL})`;
}

/**
* Size on disk of the SwiftPM cache clone(s) for a package ref (the short
* name produced by shortenPackageRef). Cache entries are named
* "<repo-name>-<hash>". Returns 0 when nothing is there (yet).
*/
private getPackageCloneSizeBytes(packageRef: string): number {
let entries: fs.Dirent[];
try {
entries = fs.readdirSync(SPMService.SWIFTPM_REPO_CACHE, {
withFileTypes: true,
});
} catch (err) {
return 0;
}
let total = 0;
for (const entry of entries) {
if (
entry.isDirectory() &&
(entry.name === packageRef || entry.name.startsWith(`${packageRef}-`))
) {
total += this.getDirectorySizeBytes(
path.join(SPMService.SWIFTPM_REPO_CACHE, entry.name),
);
}
}
return total;
}

/**
* Recursive directory size via the raw fs API. Tolerates files vanishing
* mid-walk (git renames its temp packfiles while cloning) and does not
* follow symlinks.
*/
private getDirectorySizeBytes(dirPath: string): number {
let total = 0;
const pending = [dirPath];
while (pending.length) {
const current = pending.pop();
let entries: fs.Dirent[];
try {
entries = fs.readdirSync(current, { withFileTypes: true });
} catch (err) {
continue;
}
for (const entry of entries) {
const fullPath = path.join(current, entry.name);
try {
if (entry.isDirectory()) {
pending.push(fullPath);
} else if (entry.isFile()) {
total += fs.statSync(fullPath).size;
}
} catch (err) {
// entry disappeared between readdir and stat — ignore
}
}
}
return total;
}

/** Formats a byte count as a short human-readable size ("2.31 GB"). */
private formatBytes(bytes: number): string {
const GB = 1024 ** 3;
const MB = 1024 ** 2;
if (bytes >= GB) {
return `${(bytes / GB).toFixed(2)} GB`;
}
if (bytes >= MB) {
return `${Math.round(bytes / MB)} MB`;
}
return `${Math.round(bytes / 1024)} KB`;
}

/** True when the Xcode project references any Swift packages. */
private hasSPMReferences(
platformData: IPlatformData,
Expand Down
21 changes: 21 additions & 0 deletions test/spm-service.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { assert } from "chai";
import { EOL } from "os";
import { SPMService } from "../lib/services/ios/spm-service";

/**
Expand Down Expand Up @@ -307,4 +308,24 @@ describe("SPM Service - resolution log parsing", () => {
assert.equal(service.formatElapsed(3725), "62m 5s");
});
});

describe("formatPackageListing", () => {
it("lists one package per line using the platform EOL", () => {
assert.equal(
service.formatPackageListing([
{
name: "FontManager",
version: "1.0.12",
repositoryURL: "https://github.com/NativeScript/font-manager.git",
},
{ name: "CanvasNative", path: "node_modules/canvas/ios" },
]),
"Swift Packages:" +
EOL +
" FontManager (1.0.12 · https://github.com/NativeScript/font-manager.git)" +
EOL +
" CanvasNative (local: node_modules/canvas/ios)",
);
});
});
});