Skip to content
Draft
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
10 changes: 7 additions & 3 deletions lib/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ export const TNS_CORE_THEME_NAME = "nativescript-theme-core";
export const SCOPED_TNS_CORE_THEME_NAME = "@nativescript/theme";
export const WEBPACK_PLUGIN_NAME = "@nativescript/webpack";
export const RSPACK_PLUGIN_NAME = "@nativescript/rspack";
// Project-relative directory the Vite bundler writes its build output to
// before the CLI copies it into the platforms app folder. Mirrors the
// default value computed in `@nativescript/vite`'s base configuration
// (`process.env.NS_VITE_DIST_DIR || '.ns-vite-build'`).
export const VITE_DIST_FOLDER_NAME = ".ns-vite-build";
export const TNS_CORE_MODULES_WIDGETS_NAME = "tns-core-modules-widgets";
export const UI_MOBILE_BASE_NAME = "@nativescript/ui-mobile-base";
export const TNS_ANDROID_RUNTIME_NAME = "tns-android";
Expand Down Expand Up @@ -172,9 +177,7 @@ export class ITMSConstants {
static altoolExecutableName = "altool";
}

class ItunesConnectApplicationTypesClass
implements IiTunesConnectApplicationType
{
class ItunesConnectApplicationTypesClass implements IiTunesConnectApplicationType {
public iOS = "iOS App";
public Mac = "Mac OS X App";
}
Expand Down Expand Up @@ -409,6 +412,7 @@ export enum IOSNativeTargetTypes {
watchApp = "watch_app",
watchExtension = "watch_extension",
appExtension = "app_extension",
application = "application",
}

const pathToLoggerAppendersDir = join(
Expand Down
184 changes: 184 additions & 0 deletions lib/controllers/run-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ export class RunController extends EventEmitter implements IRunController {
private $prepareNativePlatformService: IPrepareNativePlatformService,
private $projectChangesService: IProjectChangesService,
protected $projectDataService: IProjectDataService,
private $staticConfig: Config.IStaticConfig,
) {
super();
}
Expand Down Expand Up @@ -498,6 +499,20 @@ export class RunController extends EventEmitter implements IRunController {
},
);

// For Android + Vite HMR, own the `adb reverse` ourselves —
// with our SDK-resolved adb, scoped to this exact serial, and
// only after the device is up — then hand the bundler the
// result via env vars. This MUST run before `prepare` (which
// spawns the Vite bundler that inherits `process.env`) so the
// bundler trusts the tunnel instead of racing us to spawn its
// own adb during config-load. See packages/vite hardening.
await this.setupAndroidViteHmrReverse(
device,
projectData,
liveSyncInfo,
"pre-build",
);

const prepareResultData =
await this.$prepareController.prepare(prepareData);

Expand Down Expand Up @@ -572,6 +587,17 @@ export class RunController extends EventEmitter implements IRunController {
liveSyncDeviceData: deviceDescriptor,
});

// Re-establish the adb reverse on the CURRENT transport right
// before launch — the transport can change during build/install
// and drop the mapping set in `pre-build`, which would leave the
// app unable to reach the Vite dev server at 127.0.0.1.
await this.setupAndroidViteHmrReverse(
device,
projectData,
liveSyncInfo,
"pre-launch",
);

await this.refreshApplication(
projectData,
liveSyncResultInfo,
Expand Down Expand Up @@ -626,6 +652,164 @@ export class RunController extends EventEmitter implements IRunController {
);
}

/**
* Set up `adb reverse tcp:<port> tcp:<port>` for an Android device
* when the project bundles with Vite in HMR/watch mode, then export
* the result to the bundler subprocess via environment variables.
*
* The Vite dev-host helper prefers an ADB tunnel (device-side
* `127.0.0.1:<port>` → host) over the emulator's flaky slirp NAT
* (`10.0.2.2`). Historically the bundler tried to wire that tunnel
* itself at config-load time, racing this CLI's device discovery
* over the single global adb daemon and intermittently freezing the
* run at "Searching for devices…". The CLI is the right owner: it
* knows the exact target serial and when the device is ready, and it
* already drives a single, version-matched adb. We do the reverse
* here and signal the bundler with `NS_ADB_REVERSE_READY=1` so it
* never spawns adb on its own.
*
* Best-effort: any failure is logged at trace level and swallowed.
* The bundler then falls back to its own (now hardened) adb path, or
* ultimately to `10.0.2.2`, so a reverse hiccup never fails the run.
*/
private async setupAndroidViteHmrReverse(
device: Mobile.IDevice,
projectData: IProjectData,
liveSyncInfo: ILiveSyncInfo,
phase: "pre-build" | "pre-launch",
): Promise<void> {
try {
if (!this.$mobileHelper.isAndroidPlatform(device.deviceInfo.platform)) {
return;
}
if (projectData.bundler !== "vite") {
return;
}
// HMR over the tunnel only matters for a live watch session.
if (liveSyncInfo.skipWatcher || !liveSyncInfo.useHotModuleReload) {
return;
}
// Respect the user's explicit opt-out — they want the
// `10.0.2.2` / LAN path, so don't create a tunnel or claim one
// exists.
if (this.isTruthyEnvFlag(process.env.NS_HMR_NO_ADB_REVERSE)) {
return;
}
// `NS_HMR_PREFER_LAN_HOST` means the dev wants LAN routing
// (physical device over Wi-Fi); the dev-host resolver suppresses
// the adb-reverse path for it, so don't bother wiring one.
if (this.isTruthyEnvFlag(process.env.NS_HMR_PREFER_LAN_HOST)) {
return;
}

const serial = device.deviceInfo.identifier;
const port = this.getViteHmrPort();

if (phase === "pre-build") {
// Decide the origin baked into bundle.mjs. Hand the bundler our
// exact adb (so any self-managed fallback can't version-mismatch
// the daemon) and, if the tunnel comes up, tell it to emit
// `127.0.0.1` and skip adb entirely.
process.env.NS_ADB_PATH = await this.$staticConfig.getAdbFilePath();
process.env.NS_DEVICE_SERIAL = serial;

const ok = await this.ensureAndroidReverse(device, serial, port);
if (ok) {
process.env.NS_ADB_REVERSE_READY = "1";
this.$logger.info(
`Set up adb reverse tcp:${port} tcp:${port} for ${serial} (Vite HMR routes device-side 127.0.0.1:${port} through ADB).`,
);
} else {
this.$logger.warn(
`Could not confirm 'adb reverse tcp:${port}' on ${serial} (device adbd slow/unresponsive). Vite HMR will fall back to 10.0.2.2. If this persists, cold-boot/wipe the emulator, or set NS_HMR_NO_ADB_REVERSE=1.`,
);
}
return;
}

// phase === "pre-launch": re-establish the mapping right before the
// app boots. `adb reverse` mappings are bound to the device's adb
// transport, and that transport can change during the (long) build
// + install (fresh emulators reconnect as they settle), silently
// dropping the early mapping. We only bother when we actually told
// the bundle to use `127.0.0.1` (READY set during pre-build).
if (!this.isTruthyEnvFlag(process.env.NS_ADB_REVERSE_READY)) {
return;
}
const ok = await this.ensureAndroidReverse(device, serial, port);
if (!ok) {
this.$logger.warn(
`adb reverse tcp:${port} was not active before launch on ${serial}; the app may fail to reach the Vite dev server at 127.0.0.1:${port}.`,
);
}
} catch (err) {
this.$logger.trace(
`Setting up adb reverse for Vite HMR (${phase}) failed; leaving it to the bundler fallback. Error: ${err}`,
);
}
}

/**
* Apply `adb reverse tcp:<port> tcp:<port>` to the device and confirm
* via `adb reverse --list` that it actually landed, retrying a few
* times. Every device-side call is bounded with a Node `spawn` timeout
* + `SIGKILL` so a wedged/slow adbd (observed blocking 90s+ on some
* fresh-boot / API-36 arm64 emulators) can never hang the run — the
* hung adb child is reaped, not orphaned. Returns whether the mapping
* is confirmed present.
*/
private async ensureAndroidReverse(
device: Mobile.IDevice,
serial: string,
port: number,
): Promise<boolean> {
const adb = (device as Mobile.IAndroidDevice).adb;
const ADB_WAIT_MS = 15000;
const ADB_REVERSE_MS = 20000;
const bounded = (timeout: number) => ({
deviceIdentifier: serial,
treatErrorsAsWarnings: true,
childProcessOptions: { timeout, killSignal: "SIGKILL" },
});

// `wait-for-device` only blocks until the transport is up; bounded so a
// never-ready device can't stall us.
await adb.executeCommand(["wait-for-device"], bounded(ADB_WAIT_MS));

for (let attempt = 1; attempt <= 3; attempt++) {
await adb.executeCommand(
["reverse", `tcp:${port}`, `tcp:${port}`],
bounded(ADB_REVERSE_MS),
);
// Verify it landed (a SIGKILL'd-on-timeout reverse resolves rather
// than throws, so success of the call isn't proof).
const list =
(
await adb.executeCommand(["reverse", "--list"], bounded(ADB_WAIT_MS))
)?.toString?.() ?? "";
if (list.includes(`tcp:${port}`)) {
return true;
}
}
return false;
}

private getViteHmrPort(): number {
// The Vite dev server defaults to 5173; the bundler reads the same
// default. If a project runs Vite on a different port, the dev sets
// `NS_HMR_PORT` so the CLI reverses the matching port.
const fromEnv = Number(process.env.NS_HMR_PORT);
return Number.isFinite(fromEnv) && fromEnv > 0 ? fromEnv : 5173;
}

private isTruthyEnvFlag(value: string | undefined): boolean {
if (typeof value !== "string") {
return false;
}
const v = value.trim().toLowerCase();
return !!v && v !== "0" && v !== "false" && v !== "off" && v !== "no";
}

private async syncChangedDataOnDevices(
data: IFilesChangeEventData,
projectData: IProjectData,
Expand Down
26 changes: 25 additions & 1 deletion lib/definitions/nativescript-dev-xcode.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,27 @@ declare module "nativescript-dev-xcode" {
}

class project {
hash: any;
filepath: string;
constructor(filename: string);

parse(callback: () => void): void;
parseSync(): void;

generateUuid(): string;

writeSync(options: any): string;

addFramework(filepath: string, options?: Options): void;
removeFramework(filePath: string, options?: Options): void;


getProductFile(watchApptarget: target): any;
addToPbxFrameworksBuildPhase(file);
addToPbxCopyfilesBuildPhase(file, comment: string, targetid: string);
pbxFrameworksBuildPhaseObj(targetid: string): any;
pbxBuildFileSection(): {[k: string] : any};

addPbxGroup(
filePathsArray: any[],
name: string,
Expand All @@ -27,17 +38,30 @@ declare module "nativescript-dev-xcode" {

removePbxGroup(groupName: string, path: string): void;

addTargetDependency(target: string, dependencyTargets: string[]);

findTargetKey(name: string);
pbxTargetByName(name: string): target;
pbxNativeTargetSection(): {[key: string]: any};

addToHeaderSearchPaths(options?: Options): void;
removeFromHeaderSearchPaths(options?: Options): void;
updateBuildProperty(key: string, value: any): void;

pbxXCBuildConfigurationSection(): any;

buildPhaseObject(
buildPhaseType: string,
comment: string,
target: tstring
)

addTarget(
targetName: string,
targetType: string,
targetPath?: string,
parentTarget?: string
parentTarget?: string,
productTargetType?: string
): target;
addBuildPhase(
filePathsArray: string[],
Expand Down
39 changes: 35 additions & 4 deletions lib/definitions/project.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -601,9 +601,7 @@ interface INativePrepare {
}

interface IBuildConfig
extends IAndroidBuildOptionsSettings,
IiOSBuildConfig,
IProjectDir {
extends IAndroidBuildOptionsSettings, IiOSBuildConfig, IProjectDir {
clean?: boolean;
architectures?: string[];
buildOutputStdio?: string;
Expand All @@ -615,7 +613,8 @@ interface IBuildConfig
* Describes iOS-specific build configuration properties
*/
interface IiOSBuildConfig
extends IBuildForDevice,
extends
IBuildForDevice,
IiCloudContainerEnvironment,
IDeviceIdentifier,
IProvision,
Expand Down Expand Up @@ -865,6 +864,7 @@ interface IAddExtensionsFromPathOptions extends IAddTargetFromPathOptions {

interface IAddWatchAppFromPathOptions extends IAddTargetFromPathOptions {
watchAppFolderPath: string;
disableStubBinary?: boolean;
}

interface IRemoveExtensionsOptions {
Expand All @@ -873,6 +873,37 @@ interface IRemoveExtensionsOptions {

interface IRemoveWatchAppOptions extends IRemoveExtensionsOptions {}

interface IWatchAppJSONConfigModule {
name?: string;
path: string;
targetType?: string;
embed?: boolean;
frameworks?: Array<string | Record<string, string>>;
dependencies?: string[];
headerSearchPaths?: string[];
resources?: string[];
src?: string[];
linkerFlags?: string[];
buildConfigurationProperties?: Record<string, string>;
SPMPackages?: Array<IOSSPMPackage | string>;
}
interface IWatchAppJSONConfig {
targetType?: string;
forceAddEmbedWatchContent?: boolean;
sharedModulesBuildConfigurationProperties?: Record<string, string>;
basedir?: string;
infoPlistPath?: string;
xcprivacyPath?: string;
importSourcesFromMainFolder?: boolean;
importResourcesFromMainFolder?: boolean;
resources?: string[];
src?: string[];
resourcesExclude?: string[];
srcExclude?: string[];
modules: IWatchAppConfigModule[];
SPMPackages?: Array<IOSSPMPackage>;
}

interface IRubyFunction {
functionName: string;
functionParameters?: string;
Expand Down
Loading
Loading