From cc1cdd7c9304e5abdacda6f514556e7a0da4d198 Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Sun, 4 Oct 2026 00:15:28 -0700 Subject: [PATCH 1/8] feat: native release builds with --native and nativeRelease A release build can compile the app to Swift or Kotlin with @nativescript/native-release, a devDependency resolved from the project, instead of bundling it for the JavaScript runtime. - --native (implies --release) or nativeRelease in nativescript.config, per platform under ios/android; --no-native opts out for one build. - Prepare runs the project's compiler into platforms/native/, skipping webpack and the runtime's platform project. - Build runs xcodegen and xcodebuild (simulator .app, or a device archive exported with --team-id/--provision, unsigned otherwise) or Gradle with the --key-store-* options as AGP injected signing, --aab included. - The built package goes to the existing install and launch path, so ns run --release and ns deploy work unchanged. --- .../project/testing/build-android.md | 3 +- docs/man_pages/project/testing/build-ios.md | 3 +- docs/man_pages/project/testing/run-android.md | 3 +- docs/man_pages/project/testing/run-ios.md | 3 +- lib/bootstrap.ts | 1 + lib/constants.ts | 1 + lib/controllers/build-controller.ts | 41 +- lib/controllers/prepare-controller.ts | 21 + lib/data/prepare-data.ts | 2 + lib/declarations.d.ts | 1 + lib/definitions/prepare.d.ts | 5 + lib/definitions/project.d.ts | 10 + lib/options.ts | 6 + lib/services/native-release-service.ts | 424 ++++++++++++++++++ test/controllers/prepare-controller.ts | 35 ++ test/options.ts | 47 ++ test/services/native-release-service.ts | 384 ++++++++++++++++ test/stubs.ts | 3 + 18 files changed, 983 insertions(+), 10 deletions(-) create mode 100644 lib/services/native-release-service.ts create mode 100644 test/services/native-release-service.ts diff --git a/docs/man_pages/project/testing/build-android.md b/docs/man_pages/project/testing/build-android.md index d537fa081c..5e5db141f0 100644 --- a/docs/man_pages/project/testing/build-android.md +++ b/docs/man_pages/project/testing/build-android.md @@ -13,13 +13,14 @@ Builds the project for Android and produces an APK that you can manually deploy Usage | Synopsis ---|--- -General | `$ ns build android [--compileSdk ] [--key-store-path --key-store-password --key-store-alias --key-store-alias-password ] [--release] [--copy-to ] [--env.*]] [--aab]` +General | `$ ns build android [--compileSdk ] [--key-store-path --key-store-password --key-store-alias --key-store-alias-password ] [--release [--native]] [--copy-to ] [--env.*]] [--aab]` ### Options * `--compileSdk` - Sets the Android SDK that will be used to build the project. `` is a valid Android API level. For example: 28, 29. The minimum supported SDK is 28. <% if(isHtml) { %> For a complete list of the Android API levels and their corresponding Android versions, click [here](http://developer.android.com/guide/topics/manifest/uses-sdk-element.html#platform).<% } %> * `--clean` - If set, forces the complete rebuild of the native application. * `--release` - If set, produces a release build. Otherwise, produces a debug build. When set, you must also specify the `--key-store-*` options. +* `--native` - Compiles the release build to native code with `@nativescript/native-release` (a devDependency of the project): no JavaScript runtime in the app. Implies `--release`. Set `nativeRelease: true` in `nativescript.config.ts` to make it the default for release builds, and `--no-native` to opt out for one build. * `--key-store-path` - Specifies the file path to the keystore file (P12) which you want to use to code sign your APK. You can use the `--key-store-*` options along with `--release` to produce a signed release build. You need to specify all `--key-store-*` options. * `--key-store-password` - Provides the password for the keystore file specified with `--key-store-path`. You can use the `--key-store-*` options along with `--release` to produce a signed release build. You need to specify all `--key-store-*` options. * `--key-store-alias` - Provides the alias for the keystore file specified with `--key-store-path`. You can use the `--key-store-*` options along with `--release` to produce a signed release build. You need to specify all `--key-store-*` options. diff --git a/docs/man_pages/project/testing/build-ios.md b/docs/man_pages/project/testing/build-ios.md index 41331624bf..565573dbe5 100644 --- a/docs/man_pages/project/testing/build-ios.md +++ b/docs/man_pages/project/testing/build-ios.md @@ -17,11 +17,12 @@ Builds the project for iOS and produces an `APP` or `IPA` that you can manually Usage | Synopsis ---|--- -General | `$ ns build ios [--for-device] [--release] [--copy-to ] [--provision []] [--env.*]]` +General | `$ ns build ios [--for-device] [--release [--native]] [--copy-to ] [--provision []] [--env.*]]` ### Options * `--release` - If set, produces a release build. Otherwise, produces a debug build. +* `--native` - Compiles the release build to native code with `@nativescript/native-release` (a devDependency of the project): no JavaScript runtime in the app. Implies `--release`. Set `nativeRelease: true` in `nativescript.config.ts` to make it the default for release builds, and `--no-native` to opt out for one build. * `--for-device` - If set, produces an application package that you can deploy on device. Otherwise, produces a build that you can run only in the native iOS Simulator. * `--i-cloud-container-environment` - If set, adds the passed `iCloudContainerEnvironment` when exporting an application package with the `--for-device` option. * `--copy-to` - Specifies the file path where the built `.ipa` will be copied. If it points to a non-existent directory path, it will be created. If the specified value is existing directory, the original file name will be used. diff --git a/docs/man_pages/project/testing/run-android.md b/docs/man_pages/project/testing/run-android.md index ab651eff42..94d8e2cd01 100644 --- a/docs/man_pages/project/testing/run-android.md +++ b/docs/man_pages/project/testing/run-android.md @@ -17,7 +17,7 @@ When running this command without passing `--release` flag, the HMR (Hot Module Usage | Synopsis ---|--- -Run on all connected devices and running emulators | `$ ns run android [--key-store-path --key-store-password --key-store-alias --key-store-alias-password ] [--release] [--justlaunch] [--env.*]] [--aab]` +Run on all connected devices and running emulators | `$ ns run android [--key-store-path --key-store-password --key-store-alias --key-store-alias-password ] [--release [--native]] [--justlaunch] [--env.*]] [--aab]` Run on a selected connected device or running emulator. Will start emulator with specified `Device Identifier`, if not already running. | `$ ns run android --device [--key-store-path --key-store-password --key-store-alias --key-store-alias-password ] [--release] [--justlaunch] [--env.*]] [--aab]` Start a default emulator if none are running, or run application on all connected emulators. | `$ ns run android --emulator [--key-store-path --key-store-password --key-store-alias --key-store-alias-password ] [--release] [--justlaunch] [--env.*]] [--aab]` @@ -29,6 +29,7 @@ Start a default emulator if none are running, or run application on all connecte * `--clean` - If set, forces the complete rebuild of the native application. * `--no-watch` - If set, changes in your code will not be reflected during the execution of this command. * `--release` - If set, produces a release build by running webpack in production mode and native build in release mode. Otherwise, produces a debug build. When set, you must also specify the --key-store-* options. +* `--native` - Compiles the release build to native code with `@nativescript/native-release` (a devDependency of the project): no JavaScript runtime in the app. Implies `--release`. Set `nativeRelease: true` in `nativescript.config.ts` to make it the default for release builds, and `--no-native` to opt out for one build. * `--key-store-path` - Specifies the file path to the keystore file (P12) which you want to use to code sign your APK. You can use the `--key-store-*` options along with `--release` to produce a signed release build. You need to specify all `--key-store-*` options. * `--key-store-password` - Provides the password for the keystore file specified with `--key-store-path`. You can use the `--key-store-*` options along with `--release` to produce a signed release build. You need to specify all `--key-store-*` options. * `--key-store-alias` - Provides the alias for the keystore file specified with `--key-store-path`. You can use the `--key-store-*` options along with `--release` to produce a signed release build. You need to specify all `--key-store-*` options. diff --git a/docs/man_pages/project/testing/run-ios.md b/docs/man_pages/project/testing/run-ios.md index 3e8d23de6f..49bd2d78c0 100644 --- a/docs/man_pages/project/testing/run-ios.md +++ b/docs/man_pages/project/testing/run-ios.md @@ -21,7 +21,7 @@ When running this command without passing `--release` flag, the HMR (Hot Module Usage | Synopsis ---|--- -Run on all connected devices | `$ ns run ios [--release] [--justlaunch] [--env.*]]` +Run on all connected devices | `$ ns run ios [--release [--native]] [--justlaunch] [--env.*]]` Run on a selected connected device. Will start simulator with specified `Device Identifier`, if not already running. | `$ ns run ios [--device ] [--release] [--justlaunch] [--env.*]]` Start an emulator and run the app inside it | `$ ns run ios --emulator [--release] [--env.*]]` Start an emulator with specified device name and sdk | `$ ns run ios [--device ] [--sdk ]` @@ -36,6 +36,7 @@ Start an emulator with specified device identifier and sdk | `$ ns run ios [--de * `--clean` - If set, forces the complete rebuild of the native application. * `--no-watch` - If set, changes in your code will not be reflected during the execution of this command. * `--release` - If set, produces a release build by running webpack in production mode and native build in release mode. Otherwise, produces a debug build. +* `--native` - Compiles the release build to native code with `@nativescript/native-release` (a devDependency of the project): no JavaScript runtime in the app. Implies `--release`. Set `nativeRelease: true` in `nativescript.config.ts` to make it the default for release builds, and `--no-native` to opt out for one build. * `--no-hmr` - Disables Hot Module Replacement (HMR). In this case, when a change in the code is applied, CLI will transfer the modified files and restart the application. * `--env.*` - Specifies additional flags that the bundler may process. Can be passed multiple times. * `--env.aot` - creates Ahead-Of-Time build (Angular only). diff --git a/lib/bootstrap.ts b/lib/bootstrap.ts index 634c916e47..87100c64ec 100644 --- a/lib/bootstrap.ts +++ b/lib/bootstrap.ts @@ -423,6 +423,7 @@ injector.require( "viteHmrPortService", "./services/bundler/vite-hmr-port-service", ); +injector.require("nativeReleaseService", "./services/native-release-service"); injector.require( "applePortalSessionService", diff --git a/lib/constants.ts b/lib/constants.ts index 7420893fab..383fc3e623 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -16,6 +16,7 @@ export const SCOPED_TNS_CORE_MODULES = "@nativescript/core"; 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 NATIVE_RELEASE_PACKAGE_NAME = "@nativescript/native-release"; export const RSPACK_PLUGIN_NAME = "@nativescript/rspack"; // Root of the project-relative directory the Vite bundler writes its build // output to before the CLI copies it into the platforms app folder. The CLI diff --git a/lib/controllers/build-controller.ts b/lib/controllers/build-controller.ts index f5ebb1666c..a7df443f7e 100644 --- a/lib/controllers/build-controller.ts +++ b/lib/controllers/build-controller.ts @@ -14,6 +14,8 @@ import { IAnalyticsService, IFileSystem } from "../common/declarations"; import { IInjector } from "../common/definitions/yok"; import { injector } from "../common/yok"; import { performance } from "perf_hooks"; +import * as path from "path"; +import { NativeReleaseService } from "../services/native-release-service"; export class BuildController extends EventEmitter implements IBuildController { constructor( @@ -26,7 +28,8 @@ export class BuildController extends EventEmitter implements IBuildController { private $mobileHelper: Mobile.IMobileHelper, private $projectDataService: IProjectDataService, private $projectChangesService: IProjectChangesService, - private $prepareController: IPrepareController + private $prepareController: IPrepareController, + private $nativeReleaseService: NativeReleaseService ) { super(); } @@ -74,6 +77,24 @@ export class BuildController extends EventEmitter implements IBuildController { }`, }); + if (this.$nativeReleaseService.isNativeRelease(buildData)) { + const packageFile = await this.$nativeReleaseService.build( + platformData, + projectData, + buildData + ); + this.logBuildTime(startTime); + if (buildData.copyTo) { + const target = path.resolve(buildData.copyTo); + this.$fs.copyFile(packageFile, target); + this.$logger.info(`Copied file '${packageFile}' to '${target}'.`); + } else { + this.$logger.info(`The build result is located at: ${packageFile}`); + } + + return packageFile; + } + if (buildData.clean) { await platformData.platformProjectService.cleanProject( platformData.projectRoot @@ -104,11 +125,7 @@ export class BuildController extends EventEmitter implements IBuildController { buildInfoFileDir ); - const endTime = performance.now(); - const buildTime = (endTime - startTime) / 1000; - - this.$logger.info("Project successfully built."); - this.$logger.info(`Build time: ${buildTime.toFixed(3)} s.`); + this.logBuildTime(startTime); const result = await this.$buildArtifactsService.getLatestAppPackagePath( platformData, @@ -128,6 +145,13 @@ export class BuildController extends EventEmitter implements IBuildController { return result; } + private logBuildTime(startTime: number): void { + const buildTime = (performance.now() - startTime) / 1000; + + this.$logger.info("Project successfully built."); + this.$logger.info(`Build time: ${buildTime.toFixed(3)} s.`); + } + public async buildIfNeeded(buildData: IBuildData): Promise { let result = null; @@ -140,6 +164,11 @@ export class BuildController extends EventEmitter implements IBuildController { } public async shouldBuild(buildData: IBuildData): Promise { + // xcodebuild and Gradle decide what is stale in the native project. + if (this.$nativeReleaseService.isNativeRelease(buildData)) { + return true; + } + const projectData = this.$projectDataService.getProjectData( buildData.projectDir ); diff --git a/lib/controllers/prepare-controller.ts b/lib/controllers/prepare-controller.ts index 26e480db97..4d8e03b9f6 100644 --- a/lib/controllers/prepare-controller.ts +++ b/lib/controllers/prepare-controller.ts @@ -41,6 +41,7 @@ import { IProjectService, } from "../definitions/project"; import { resolvePackageJSONPath } from "@rigor789/resolve-package-path"; +import { NativeReleaseService } from "../services/native-release-service"; interface IPlatformWatcherData { hasWebpackCompilerProcess: boolean; @@ -81,6 +82,7 @@ export class PrepareController private $markingModeService: IMarkingModeService, private $projectConfigService: IProjectConfigService, private $projectService: IProjectService, + private $nativeReleaseService: NativeReleaseService, ) { super(); } @@ -143,6 +145,25 @@ export class PrepareController projectData: IProjectData, ): Promise { await this.$projectService.ensureAppResourcesExist(projectData.projectDir); + + // The native build needs neither the bundle nor the runtime's platform project. + if (this.$nativeReleaseService.isNativeRelease(prepareData)) { + await this.$nativeReleaseService.prepare( + this.$platformsDataService.getPlatformData( + prepareData.platform, + projectData, + ), + projectData, + ); + this.$logger.info( + `Project successfully prepared (${prepareData.platform.toLowerCase()}, native)`, + ); + return { + hasNativeChanges: true, + platform: prepareData.platform.toLowerCase(), + }; + } + await this.$platformController.addPlatformIfNeeded( prepareData, projectData, diff --git a/lib/data/prepare-data.ts b/lib/data/prepare-data.ts index 0df388906f..5e185ef5e9 100644 --- a/lib/data/prepare-data.ts +++ b/lib/data/prepare-data.ts @@ -4,6 +4,7 @@ import * as _ from "lodash"; export class PrepareData extends ControllerDataBase { public release: boolean; + public native?: boolean; public hmr: boolean; public env: any; public watch?: boolean; @@ -34,6 +35,7 @@ export class PrepareData extends ControllerDataBase { } this.release = data.release; + this.native = data.native; this.hmr = data.hmr || data.useHotModuleReload; this.env = { ...env, diff --git a/lib/declarations.d.ts b/lib/declarations.d.ts index 79f4559d76..b0b6e030fb 100644 --- a/lib/declarations.d.ts +++ b/lib/declarations.d.ts @@ -693,6 +693,7 @@ interface IOptions inspector: boolean; // the counterpart to --chrome background: string; hmr: boolean; + native: boolean; link: boolean; performance: Object; cleanupLogFile: string; diff --git a/lib/definitions/prepare.d.ts b/lib/definitions/prepare.d.ts index 0755bc52bb..e15f0111bd 100644 --- a/lib/definitions/prepare.d.ts +++ b/lib/definitions/prepare.d.ts @@ -6,6 +6,11 @@ import { IProjectData } from "./project"; declare global { interface IPrepareData extends IControllerDataBase { release: boolean; + /** + * Compile a release build to native code with no JavaScript runtime. + * Unset defers to `nativeRelease` in nativescript.config. + */ + native?: boolean; hmr: boolean; env: any; watch?: boolean; diff --git a/lib/definitions/project.d.ts b/lib/definitions/project.d.ts index cd2a931617..10fee1ed42 100644 --- a/lib/definitions/project.d.ts +++ b/lib/definitions/project.d.ts @@ -101,6 +101,10 @@ interface IProjectService { interface INsConfigPlaform { id?: string; + /** + * Overrides the top-level `nativeRelease` for this platform. + */ + nativeRelease?: boolean; } interface IOSSPMPackageBase { @@ -201,6 +205,12 @@ interface INsConfig { webpackConfigPath?: string; bundlerConfigPath?: string; bundler?: BundlerType; + /** + * Release builds compile the app to native code with + * `@nativescript/native-release` instead of bundling it for the + * JavaScript runtime. `--native` / `--no-native` override it per command. + */ + nativeRelease?: boolean; ios?: INsConfigIOS; android?: INsConfigAndroid; visionos?: INSConfigVisionOS; diff --git a/lib/options.ts b/lib/options.ts index df6ce62f4d..b4f59ea54d 100644 --- a/lib/options.ts +++ b/lib/options.ts @@ -202,6 +202,7 @@ export class Options { alias: "r", hasSensitiveValue: false, }, + native: { type: OptionType.Boolean, hasSensitiveValue: false }, markingMode: { type: OptionType.Boolean, hasSensitiveValue: false }, var: { type: OptionType.Object, hasSensitiveValue: true }, default: { type: OptionType.Boolean, hasSensitiveValue: false }, @@ -517,6 +518,11 @@ export class Options { this.argv.emulator = this.argv.simulator; } + // a native build has no debug variant: it is the release build + if (this.argv.native) { + this.argv.release = true; + } + this.argv.bundle = "webpack"; this.adjustDashedOptions(); diff --git a/lib/services/native-release-service.ts b/lib/services/native-release-service.ts new file mode 100644 index 0000000000..2761fe3cac --- /dev/null +++ b/lib/services/native-release-service.ts @@ -0,0 +1,424 @@ +import * as path from "path"; +import { resolvePackagePath } from "@rigor789/resolve-package-path"; +import { NATIVE_RELEASE_PACKAGE_NAME } from "../constants"; +import { IChildProcess, IErrors, IFileSystem } from "../common/declarations"; +import { IPlatformData } from "../definitions/platform"; +import { IProjectData, IProjectDataService } from "../definitions/project"; +import { + IAndroidBuildData, + IBuildData, + IiOSBuildData, +} from "../definitions/build"; +import { IGradleCommandService } from "../definitions/gradle"; +import { IOSProvisionService } from "./ios-provision-service"; +import { injector } from "../common/yok"; + +export interface INativeReleaseOptions { + projectDir: string; + platform: string; + release?: boolean; + native?: boolean; +} + +/** + * A release build compiled by `@nativescript/native-release` into Swift or + * Kotlin: no JavaScript runtime, no webpack bundle and no `platforms/` + * runtime project. Prepare writes the native project to + * `platforms/native/`; build compiles it there and returns the + * .app, .ipa, .apk or .aab for the deploy services to install. + */ +export class NativeReleaseService { + constructor( + private $childProcess: IChildProcess, + private $errors: IErrors, + private $exportOptionsPlistService: IExportOptionsPlistService, + private $fs: IFileSystem, + private $gradleCommandService: IGradleCommandService, + private $iOSProvisionService: IOSProvisionService, + private $logger: ILogger, + private $mobileHelper: Mobile.IMobileHelper, + private $projectDataService: IProjectDataService, + private $xcodebuildCommandService: IXcodebuildCommandService, + ) {} + + /** + * `--native` (or `--no-native`) wins; otherwise `nativeRelease` in + * nativescript.config, where `ios.nativeRelease` and `android.nativeRelease` + * override the top-level value. Debug builds always run on the JavaScript + * runtime. + */ + public isNativeRelease(options: INativeReleaseOptions): boolean { + if (!options.release) { + return false; + } + + if (typeof options.native === "boolean") { + return options.native; + } + + const nsConfig = this.$projectDataService.getProjectData( + options.projectDir, + ).nsConfig; + const platformConfig: { nativeRelease?: boolean } = + nsConfig?.[options.platform.toLowerCase() as "ios" | "android"]; + + return !!(platformConfig?.nativeRelease ?? nsConfig?.nativeRelease); + } + + private getProjectRoot(projectData: IProjectData, platform: string): string { + return path.join( + projectData.platformsDir, + "native", + platform.toLowerCase(), + ); + } + + public async prepare( + platformData: IPlatformData, + projectData: IProjectData, + ): Promise { + const platform = platformData.platformNameLowerCase; + if ( + !this.$mobileHelper.isiOSPlatform(platform) && + !this.$mobileHelper.isAndroidPlatform(platform) + ) { + this.$errors.fail( + `A native release build is available for iOS and Android, not ${platformData.normalizedPlatformName}.`, + ); + } + + const args = [ + this.getCompilerPath(projectData), + projectData.projectDir, + "--platform", + platform, + "--out", + this.getProjectRoot(projectData, platform), + "--name", + projectData.projectName, + "--bundle", + projectData.projectIdentifiers[platform], + ]; + + this.$logger.info(`Compiling the app to native code (${platform})...`); + try { + await this.$childProcess.spawnFromEvent(process.execPath, args, "close", { + cwd: projectData.projectDir, + stdio: "inherit", + }); + } catch (err) { + this.$errors.fail(`The native release compile failed. ${err.message}`); + } + } + + public async build( + platformData: IPlatformData, + projectData: IProjectData, + buildData: IBuildData, + ): Promise { + if (buildData.clean) { + const projectRoot = this.getProjectRoot( + projectData, + platformData.platformNameLowerCase, + ); + this.$fs.deleteDirectory(path.join(projectRoot, "DerivedData")); + this.$fs.deleteDirectory(path.join(projectRoot, "build")); + } + + const packageFile = this.$mobileHelper.isAndroidPlatform( + platformData.platformNameLowerCase, + ) + ? await this.buildAndroid(projectData, buildData) + : await this.buildIOS(projectData, buildData); + if (!this.$fs.exists(packageFile)) { + this.$errors.fail( + `The native release build finished without producing ${packageFile}.`, + ); + } + + return packageFile; + } + + private getCompilerPath(projectData: IProjectData): string { + const packageDir = this.getPackageDir(projectData); + const { bin } = this.$fs.readJson(path.join(packageDir, "package.json")); + + return path.join(packageDir, bin["ns-native"]); + } + + private getPackageDir(projectData: IProjectData): string { + const packageDir = resolvePackagePath(NATIVE_RELEASE_PACKAGE_NAME, { + paths: [projectData.projectDir], + }); + if (!packageDir) { + this.$errors.fail( + `A native release build needs the ${NATIVE_RELEASE_PACKAGE_NAME} compiler in the project. ` + + `Install it with 'npm install --save-dev ${NATIVE_RELEASE_PACKAGE_NAME}', ` + + `or build on the JavaScript runtime with --no-native.`, + ); + } + + return packageDir; + } + + private async buildIOS( + projectData: IProjectData, + buildData: IiOSBuildData, + ): Promise { + const projectRoot = this.getProjectRoot(projectData, "ios"); + const name = projectData.projectName; + + try { + await this.$childProcess.spawnFromEvent( + "xcodegen", + ["generate", "--quiet"], + "close", + { cwd: projectRoot, stdio: "inherit" }, + ); + } catch (err) { + this.$errors.fail( + err.code === "ENOENT" + ? "The native iOS build generates its Xcode project with XcodeGen. Install it with 'brew install xcodegen'." + : err.message, + ); + } + + const projectArgs = [ + "-project", + `${name}.xcodeproj`, + "-scheme", + name, + "-configuration", + "Release", + "-derivedDataPath", + "DerivedData", + "-quiet", + ]; + + if (!buildData.buildForDevice && !buildData.buildForAppStore) { + await this.$xcodebuildCommandService.executeCommand( + [ + ...projectArgs, + "-destination", + "generic/platform=iOS Simulator", + "build", + ], + { cwd: projectRoot, message: "Xcode build (native release)..." }, + ); + + return path.join( + projectRoot, + "DerivedData", + "Build", + "Products", + "Release-iphonesimulator", + `${name}.app`, + ); + } + + const outputDir = path.join(projectRoot, "build"); + const archivePath = path.join(outputDir, `${name}.xcarchive`); + // Signing settings given to xcodebuild reach every target, and the kit's + // Swift package rejects a provisioning profile, so with --provision the + // archive stays unsigned and the export signs the app with the profile. + const automaticSigning = + buildData.teamId && !buildData.provision + ? [ + "CODE_SIGNING_ALLOWED=YES", + "CODE_SIGN_STYLE=Automatic", + `DEVELOPMENT_TEAM=${buildData.teamId}`, + "-allowProvisioningUpdates", + ] + : []; + this.$fs.deleteDirectory(archivePath); + await this.$xcodebuildCommandService.executeCommand( + [ + ...projectArgs, + "-destination", + "generic/platform=iOS", + "-archivePath", + archivePath, + "archive", + ...automaticSigning, + ], + { cwd: projectRoot, message: "Xcode archive (native release)..." }, + ); + + if (!buildData.teamId && !buildData.provision) { + this.$logger.warn( + `The archive is unsigned: pass --team-id or --provision to sign it and export an .ipa that installs on a device. ` + + `The unsigned .ipa is for re-signing elsewhere.`, + ); + return this.packageUnsignedIpa(archivePath, outputDir, name); + } + + const exportOptions = buildData.provision + ? await this.createProvisionExportOptions( + projectData, + buildData.provision, + outputDir, + ) + : buildData.buildForAppStore + ? await this.$exportOptionsPlistService.createDistributionExportOptionsPlist( + archivePath, + projectData, + buildData, + ) + : await this.$exportOptionsPlistService.createDevelopmentExportOptionsPlist( + archivePath, + projectData, + buildData, + ); + await this.$xcodebuildCommandService.executeCommand( + [ + "-exportArchive", + "-archivePath", + archivePath, + "-exportPath", + exportOptions.exportFileDir, + "-exportOptionsPlist", + exportOptions.exportOptionsPlistFilePath, + ...(buildData.provision ? [] : ["-allowProvisioningUpdates"]), + ], + { cwd: projectRoot, message: "Exporting the .ipa..." }, + ); + + return exportOptions.exportFilePath; + } + + private async createProvisionExportOptions( + projectData: IProjectData, + provision: string, + outputDir: string, + ): Promise { + const bundleId = projectData.projectIdentifiers.ios; + const profile = await this.$iOSProvisionService.pick(provision, bundleId); + if (!profile) { + this.$errors.fail( + `Failed to find mobile provision with UUID or Name: ${provision}`, + ); + } + + const method = { + Development: "development", + AdHoc: "ad-hoc", + Distribution: "app-store", + Enterprise: "enterprise", + }[profile.Type]; + const exportOptionsPlistFilePath = path.join( + outputDir, + "ExportOptions.plist", + ); + this.$fs.writeFile( + exportOptionsPlistFilePath, + ` + + + + method + ${method} + signingStyle + manual + teamID + ${profile.TeamIdentifier[0]} + provisioningProfiles + + ${bundleId} + ${profile.UUID} + + + +`, + ); + + return { + exportFileDir: outputDir, + exportFilePath: path.join(outputDir, `${projectData.projectName}.ipa`), + exportOptionsPlistFilePath, + }; + } + + private async packageUnsignedIpa( + archivePath: string, + outputDir: string, + name: string, + ): Promise { + const payloadDir = path.join(outputDir, "Payload"); + const ipaPath = path.join(outputDir, `${name}.ipa`); + this.$fs.deleteDirectory(payloadDir); + this.$fs.deleteFile(ipaPath); + this.$fs.ensureDirectoryExists(payloadDir); + await this.$childProcess.spawnFromEvent( + "cp", + [ + "-R", + path.join(archivePath, "Products", "Applications", `${name}.app`), + payloadDir, + ], + "close", + ); + await this.$childProcess.spawnFromEvent( + "zip", + ["-qr", "-y", ipaPath, "Payload"], + "close", + { cwd: outputDir }, + ); + this.$fs.deleteDirectory(payloadDir); + + return ipaPath; + } + + private async buildAndroid( + projectData: IProjectData, + buildData: IAndroidBuildData, + ): Promise { + const projectRoot = this.getProjectRoot(projectData, "android"); + const args = [ + "-p", + projectRoot, + buildData.androidBundle ? ":bundleRelease" : ":assembleRelease", + "--quiet", + ]; + + // AGP's injected signing replaces the generated project's debug signing + // for this build only, as Android Studio's "Generate Signed Bundle" does. + if (buildData.keyStorePath) { + args.push( + `-Pandroid.injected.signing.store.file=${path.resolve(buildData.keyStorePath)}`, + `-Pandroid.injected.signing.store.password=${buildData.keyStorePassword}`, + `-Pandroid.injected.signing.key.alias=${buildData.keyStoreAlias}`, + `-Pandroid.injected.signing.key.password=${buildData.keyStoreAliasPassword}`, + ); + } + + await this.$gradleCommandService.executeCommand(args, { + cwd: projectRoot, + message: "Gradle build (native release)...", + gradlePath: + buildData.gradlePath ?? + path.join(this.getPackageDir(projectData), "kit-android", "gradlew"), + }); + + // The generated project is the app module itself, so AGP names its + // outputs after the root project, which the compiler names --name. + const outputName = projectData.projectName; + return buildData.androidBundle + ? path.join( + projectRoot, + "build", + "outputs", + "bundle", + "release", + `${outputName}-release.aab`, + ) + : path.join( + projectRoot, + "build", + "outputs", + "apk", + "release", + `${outputName}-release.apk`, + ); + } +} +injector.register("nativeReleaseService", NativeReleaseService); diff --git a/test/controllers/prepare-controller.ts b/test/controllers/prepare-controller.ts index e3982de1e4..1095654947 100644 --- a/test/controllers/prepare-controller.ts +++ b/test/controllers/prepare-controller.ts @@ -151,6 +151,41 @@ describe("prepareController", () => { }); }); + describe("preparePlatform for a native release", () => { + _.each(["ios", "android"], (platform) => { + it(`compiles to native code instead of bundling or adding the ${platform} runtime`, async () => { + const injector = createTestInjector({ hasNativeChanges: false }); + let isAddPlatformCalled = false; + injector.resolve("platformController").addPlatformIfNeeded = () => { + isAddPlatformCalled = true; + }; + const nativePrepareCalls: any[] = []; + Object.assign(injector.resolve("nativeReleaseService"), { + isNativeRelease: (data: IPrepareData) => data.release, + prepare: async (platformData: any, projectData: any) => { + nativePrepareCalls.push({ platformData, projectData }); + }, + }); + + const prepareController: PrepareController = + injector.resolve("prepareController"); + const result = await prepareController.prepare({ + ...prepareData, + release: true, + watch: false, + platform, + }); + + assert.lengthOf(nativePrepareCalls, 1); + assert.isFalse(isCompileWithoutWatchCalled); + assert.isFalse(isNativePrepareCalled); + assert.isFalse(isAddPlatformCalled); + assert.isTrue(isEnsuringAppResourcesExist); + assert.deepStrictEqual(result, { hasNativeChanges: true, platform }); + }); + }); + }); + describe("preparePlatform without watch", () => { _.each(["ios", "android"], (platform) => { it("shouldn't start the watcher when watch is false", async () => { diff --git a/test/options.ts b/test/options.ts index e6e2557fd1..4843f08b02 100644 --- a/test/options.ts +++ b/test/options.ts @@ -486,6 +486,11 @@ describe("options", () => { args: ["--debugBrk"], expectedHmrValue: false, }, + { + name: "should set hmr to false by default when --native option is provided", + args: ["--native"], + expectedHmrValue: false, + }, ]; _.each(testCases, (testCase) => { @@ -502,6 +507,48 @@ describe("options", () => { }); }); }); + + describe("native option", () => { + const testCases = [ + { + name: "is a release build", + args: ["--native"], + expected: { native: true, release: true }, + }, + { + name: "stays a release build with --release", + args: ["--release", "--native"], + expected: { native: true, release: true }, + }, + { + name: "is left unset without the flag, so the config decides", + args: ["--release"], + expected: { native: undefined, release: true }, + }, + { + name: "can be turned off with --no-native", + args: ["--release", "--no-native"], + expected: { native: false, release: true }, + }, + ]; + + _.each(testCases, (testCase) => { + it(testCase.name, () => { + testCase.args.forEach((arg) => process.argv.push(arg)); + + const options: any = createOptions(testInjector); + options.setupOptions(null); + + testCase.args.forEach(() => process.argv.pop()); + + assert.deepStrictEqual( + { native: options.native, release: options.release }, + testCase.expected, + ); + assert.isFalse(isExecutionStopped); + }); + }); + }); }); }); diff --git a/test/services/native-release-service.ts b/test/services/native-release-service.ts new file mode 100644 index 0000000000..d83ef7835b --- /dev/null +++ b/test/services/native-release-service.ts @@ -0,0 +1,384 @@ +import { assert } from "chai"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { Yok } from "../../lib/common/yok"; +import { IInjector } from "../../lib/common/definitions/yok"; +import { NativeReleaseService } from "../../lib/services/native-release-service"; +import { IGradleCommandOptions } from "../../lib/definitions/gradle"; + +interface ISpawnCall { + command: string; + args: string[]; + options: any; +} + +let projectDir: string; +let spawnCalls: ISpawnCall[]; +let gradleCalls: { args: string[]; options: IGradleCommandOptions }[]; +let xcodebuildCalls: string[][]; +let writtenFiles: { [path: string]: string }; +let warnings: string[]; + +function createProjectData(nsConfig: any = {}): any { + return { + projectDir, + projectName: "demo", + platformsDir: path.join(projectDir, "platforms"), + projectIdentifiers: { + ios: "org.example.demo", + android: "org.example.demo", + }, + nsConfig, + }; +} + +function createService(nsConfig: any = {}): NativeReleaseService { + const injector: IInjector = new Yok(); + injector.register("childProcess", { + spawnFromEvent: async ( + command: string, + args: string[], + _: string, + options: any, + ) => { + spawnCalls.push({ command, args, options }); + return { stdout: "", stderr: "", exitCode: 0 }; + }, + }); + injector.register("errors", { + fail: (message: string) => { + throw new Error(message); + }, + }); + injector.register("exportOptionsPlistService", {}); + injector.register("fs", { + exists: () => true, + readJson: (file: string) => JSON.parse(fs.readFileSync(file, "utf8")), + deleteDirectory: (): void => undefined, + deleteFile: (): void => undefined, + ensureDirectoryExists: (): void => undefined, + writeFile: (file: string, content: string) => { + writtenFiles[file] = content; + }, + }); + injector.register("gradleCommandService", { + executeCommand: async (args: string[], options: IGradleCommandOptions) => { + gradleCalls.push({ args, options }); + }, + }); + injector.register("iOSProvisionService", { + pick: async (uuidOrName: string) => + uuidOrName === "Demo Profile" + ? { UUID: "1234-ABCD", Type: "AdHoc", TeamIdentifier: ["TEAM123"] } + : undefined, + }); + injector.register("logger", { + info: (): void => undefined, + warn: (message: string) => warnings.push(message), + }); + injector.register("mobileHelper", { + isAndroidPlatform: (platform: string) => + platform.toLowerCase() === "android", + isiOSPlatform: (platform: string) => platform.toLowerCase() === "ios", + }); + injector.register("projectDataService", { + getProjectData: () => createProjectData(nsConfig), + }); + injector.register("xcodebuildCommandService", { + executeCommand: async (args: string[]) => { + xcodebuildCalls.push(args); + }, + }); + injector.register("nativeReleaseService", NativeReleaseService); + + return injector.resolve("nativeReleaseService"); +} + +function installCompilerPackage(): string { + const packageDir = path.join( + projectDir, + "node_modules", + "@nativescript", + "native-release", + ); + fs.mkdirSync(packageDir, { recursive: true }); + fs.writeFileSync( + path.join(packageDir, "package.json"), + JSON.stringify({ + name: "@nativescript/native-release", + bin: { "ns-native": "bin/ns-native.js" }, + }), + ); + return fs.realpathSync(packageDir); +} + +const platformData = (platform: string): any => ({ + platformNameLowerCase: platform.toLowerCase(), + normalizedPlatformName: platform, +}); + +describe("nativeReleaseService", () => { + beforeEach(() => { + projectDir = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), "native-release-")), + ); + spawnCalls = []; + gradleCalls = []; + xcodebuildCalls = []; + writtenFiles = {}; + warnings = []; + }); + + afterEach(() => { + fs.rmSync(projectDir, { recursive: true, force: true }); + }); + + describe("isNativeRelease", () => { + const testCases = [ + { + name: "a debug build, even with --native", + options: { release: false, native: true }, + nsConfig: { nativeRelease: true }, + expected: false, + }, + { + name: "a release build without flag or config", + options: { release: true }, + nsConfig: {}, + expected: false, + }, + { + name: "a release build with --native", + options: { release: true, native: true }, + nsConfig: {}, + expected: true, + }, + { + name: "a release build with nativeRelease in the config", + options: { release: true }, + nsConfig: { nativeRelease: true }, + expected: true, + }, + { + name: "a release build with --no-native over the config", + options: { release: true, native: false }, + nsConfig: { nativeRelease: true }, + expected: false, + }, + { + name: "a release build whose platform opts out in the config", + options: { release: true }, + nsConfig: { nativeRelease: true, android: { nativeRelease: false } }, + expected: false, + }, + { + name: "a release build whose platform alone opts in", + options: { release: true }, + nsConfig: { android: { nativeRelease: true } }, + expected: true, + }, + ]; + + testCases.forEach((testCase) => { + it(`is ${testCase.expected} for ${testCase.name}`, () => { + const service = createService(testCase.nsConfig); + assert.equal( + service.isNativeRelease({ + projectDir, + platform: "Android", + ...testCase.options, + }), + testCase.expected, + ); + }); + }); + }); + + describe("prepare", () => { + it("runs the project's compiler with the CLI's node, writing to platforms/native/", async () => { + const packageDir = installCompilerPackage(); + const service = createService(); + + await service.prepare(platformData("iOS"), createProjectData()); + + assert.lengthOf(spawnCalls, 1); + assert.equal(spawnCalls[0].command, process.execPath); + assert.deepStrictEqual(spawnCalls[0].args, [ + path.join(packageDir, "bin", "ns-native.js"), + projectDir, + "--platform", + "ios", + "--out", + path.join(projectDir, "platforms", "native", "ios"), + "--name", + "demo", + "--bundle", + "org.example.demo", + ]); + }); + + it("explains how to install the compiler when the project lacks it", async () => { + const service = createService(); + + let error: Error; + try { + await service.prepare(platformData("iOS"), createProjectData()); + } catch (err) { + error = err; + } + + assert.include( + error.message, + "npm install --save-dev @nativescript/native-release", + ); + assert.lengthOf(spawnCalls, 0); + }); + + it("refuses platforms the compiler does not target", async () => { + installCompilerPackage(); + const service = createService(); + + let error: Error; + try { + await service.prepare(platformData("visionOS"), createProjectData()); + } catch (err) { + error = err; + } + + assert.include(error.message, "not visionOS"); + }); + }); + + describe("build", () => { + const keyStore = { + keyStorePath: "keys/release.keystore", + keyStorePassword: "store-pass", + keyStoreAlias: "upload", + keyStoreAliasPassword: "key-pass", + }; + + it("signs the Android build with the --key-store-* options", async () => { + const packageDir = installCompilerPackage(); + const service = createService(); + + const result = await service.build( + platformData("Android"), + createProjectData(), + { release: true, ...keyStore }, + ); + + const projectRoot = path.join( + projectDir, + "platforms", + "native", + "android", + ); + assert.lengthOf(gradleCalls, 1); + assert.deepStrictEqual(gradleCalls[0].args, [ + "-p", + projectRoot, + ":assembleRelease", + "--quiet", + `-Pandroid.injected.signing.store.file=${path.resolve("keys/release.keystore")}`, + "-Pandroid.injected.signing.store.password=store-pass", + "-Pandroid.injected.signing.key.alias=upload", + "-Pandroid.injected.signing.key.password=key-pass", + ]); + assert.equal( + gradleCalls[0].options.gradlePath, + path.join(packageDir, "kit-android", "gradlew"), + ); + assert.equal( + result, + path.join( + projectRoot, + "build", + "outputs", + "apk", + "release", + "demo-release.apk", + ), + ); + }); + + it("builds an App Bundle for --aab", async () => { + installCompilerPackage(); + const service = createService(); + + const result = await service.build( + platformData("Android"), + createProjectData(), + { release: true, androidBundle: true, ...keyStore }, + ); + + assert.equal(gradleCalls[0].args[2], ":bundleRelease"); + assert.match(result, /bundle\/release\/demo-release\.aab$/); + }); + + it("builds the iOS simulator app", async () => { + const service = createService(); + + const result = await service.build( + platformData("iOS"), + createProjectData(), + { release: true, buildForDevice: false }, + ); + + assert.deepStrictEqual(spawnCalls[0].args, ["generate", "--quiet"]); + assert.lengthOf(xcodebuildCalls, 1); + assert.include(xcodebuildCalls[0], "generic/platform=iOS Simulator"); + assert.match( + result, + /DerivedData\/Build\/Products\/Release-iphonesimulator\/demo\.app$/, + ); + }); + + it("archives an unsigned device build without --team-id or --provision", async () => { + const service = createService(); + + const result = await service.build( + platformData("iOS"), + createProjectData(), + { release: true, buildForDevice: true }, + ); + + assert.lengthOf(xcodebuildCalls, 1); + assert.include(xcodebuildCalls[0], "archive"); + assert.notInclude(xcodebuildCalls[0], "CODE_SIGNING_ALLOWED=YES"); + assert.lengthOf(warnings, 1); + assert.match(result, /build\/demo\.ipa$/); + }); + + it("signs with --provision at export, leaving the kit's package target unsigned", async () => { + const service = createService(); + + const result = await service.build( + platformData("iOS"), + createProjectData(), + { release: true, buildForDevice: true, provision: "Demo Profile" }, + ); + + assert.lengthOf(xcodebuildCalls, 2); + assert.isFalse( + xcodebuildCalls[0].some((arg) => + arg.startsWith("PROVISIONING_PROFILE"), + ), + ); + assert.include(xcodebuildCalls[1], "-exportArchive"); + const exportOptions = + writtenFiles[ + xcodebuildCalls[1][ + xcodebuildCalls[1].indexOf("-exportOptionsPlist") + 1 + ] + ]; + assert.include(exportOptions, "ad-hoc"); + assert.include(exportOptions, "TEAM123"); + assert.include( + exportOptions, + "org.example.demo\n\t\t1234-ABCD", + ); + assert.match(result, /build\/demo\.ipa$/); + }); + }); +}); diff --git a/test/stubs.ts b/test/stubs.ts index 7be77bc26a..d1ab2a4d5c 100644 --- a/test/stubs.ts +++ b/test/stubs.ts @@ -1522,6 +1522,9 @@ export class InjectorStub extends Yok implements IInjector { getDeviceByIdentifier: (): Mobile.IDevice => undefined, }); this.register("terminalSpinnerService", TerminalSpinnerServiceStub); + this.register("nativeReleaseService", { + isNativeRelease: () => false, + }); } } From 8a84527aa358a500be502823c2da95bd294b3769 Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Sun, 4 Oct 2026 16:07:52 -0700 Subject: [PATCH 2/8] native release: build the workspace the compiler integrated when the project has pods or Swift packages --- lib/services/native-release-service.ts | 39 ++++++++++++++++---------- 1 file changed, 24 insertions(+), 15 deletions(-) diff --git a/lib/services/native-release-service.ts b/lib/services/native-release-service.ts index 2761fe3cac..c5f68d8e5a 100644 --- a/lib/services/native-release-service.ts +++ b/lib/services/native-release-service.ts @@ -168,24 +168,33 @@ export class NativeReleaseService { const projectRoot = this.getProjectRoot(projectData, "ios"); const name = projectData.projectName; - try { - await this.$childProcess.spawnFromEvent( - "xcodegen", - ["generate", "--quiet"], - "close", - { cwd: projectRoot, stdio: "inherit" }, - ); - } catch (err) { - this.$errors.fail( - err.code === "ENOENT" - ? "The native iOS build generates its Xcode project with XcodeGen. Install it with 'brew install xcodegen'." - : err.message, - ); + // A project with CocoaPods or Swift packages is generated and integrated by + // the compiler, which names the workspace to build. + const integrated = path.join(projectRoot, "ns-native-project.json"); + const workspace = this.$fs.exists(integrated) + ? this.$fs.readJson(integrated).workspace + : null; + if (!workspace) { + try { + await this.$childProcess.spawnFromEvent( + "xcodegen", + ["generate", "--quiet"], + "close", + { cwd: projectRoot, stdio: "inherit" }, + ); + } catch (err) { + this.$errors.fail( + err.code === "ENOENT" + ? "The native iOS build generates its Xcode project with XcodeGen. Install it with 'brew install xcodegen'." + : err.message, + ); + } } const projectArgs = [ - "-project", - `${name}.xcodeproj`, + ...(workspace + ? ["-workspace", workspace] + : ["-project", `${name}.xcodeproj`]), "-scheme", name, "-configuration", From cccf2ec7e207f08c8564534cf9564324baa5da1b Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Sun, 4 Oct 2026 19:28:49 -0700 Subject: [PATCH 3/8] Rename the native release flag to --compiled and the compiler package to @nativescript/compiler The flag is --compiled (--no-compiled to opt out), the CLI resolves @nativescript/compiler, and builds go to platforms/compiled/. NativeReleaseService becomes CompiledReleaseService. The nativescript.config key nativeRelease is unchanged. --- .../project/testing/build-android.md | 4 +- docs/man_pages/project/testing/build-ios.md | 4 +- docs/man_pages/project/testing/run-android.md | 4 +- docs/man_pages/project/testing/run-ios.md | 4 +- lib/bootstrap.ts | 2 +- lib/constants.ts | 2 +- lib/controllers/build-controller.ts | 10 ++--- lib/controllers/prepare-controller.ts | 12 ++--- lib/data/prepare-data.ts | 4 +- lib/declarations.d.ts | 2 +- lib/definitions/prepare.d.ts | 2 +- lib/definitions/project.d.ts | 4 +- lib/options.ts | 6 +-- ...service.ts => compiled-release-service.ts} | 44 +++++++++--------- test/controllers/prepare-controller.ts | 6 +-- test/options.ts | 24 +++++----- ...service.ts => compiled-release-service.ts} | 45 ++++++++++--------- test/stubs.ts | 4 +- 18 files changed, 93 insertions(+), 90 deletions(-) rename lib/services/{native-release-service.ts => compiled-release-service.ts} (89%) rename test/services/{native-release-service.ts => compiled-release-service.ts} (89%) diff --git a/docs/man_pages/project/testing/build-android.md b/docs/man_pages/project/testing/build-android.md index 5e5db141f0..17ae0f214a 100644 --- a/docs/man_pages/project/testing/build-android.md +++ b/docs/man_pages/project/testing/build-android.md @@ -13,14 +13,14 @@ Builds the project for Android and produces an APK that you can manually deploy Usage | Synopsis ---|--- -General | `$ ns build android [--compileSdk ] [--key-store-path --key-store-password --key-store-alias --key-store-alias-password ] [--release [--native]] [--copy-to ] [--env.*]] [--aab]` +General | `$ ns build android [--compileSdk ] [--key-store-path --key-store-password --key-store-alias --key-store-alias-password ] [--release [--compiled]] [--copy-to ] [--env.*]] [--aab]` ### Options * `--compileSdk` - Sets the Android SDK that will be used to build the project. `` is a valid Android API level. For example: 28, 29. The minimum supported SDK is 28. <% if(isHtml) { %> For a complete list of the Android API levels and their corresponding Android versions, click [here](http://developer.android.com/guide/topics/manifest/uses-sdk-element.html#platform).<% } %> * `--clean` - If set, forces the complete rebuild of the native application. * `--release` - If set, produces a release build. Otherwise, produces a debug build. When set, you must also specify the `--key-store-*` options. -* `--native` - Compiles the release build to native code with `@nativescript/native-release` (a devDependency of the project): no JavaScript runtime in the app. Implies `--release`. Set `nativeRelease: true` in `nativescript.config.ts` to make it the default for release builds, and `--no-native` to opt out for one build. +* `--compiled` - Compiles the release build to native code with `@nativescript/compiler` (a devDependency of the project): no JavaScript runtime in the app. Implies `--release`. Set `nativeRelease: true` in `nativescript.config.ts` to make it the default for release builds, and `--no-compiled` to opt out for one build. * `--key-store-path` - Specifies the file path to the keystore file (P12) which you want to use to code sign your APK. You can use the `--key-store-*` options along with `--release` to produce a signed release build. You need to specify all `--key-store-*` options. * `--key-store-password` - Provides the password for the keystore file specified with `--key-store-path`. You can use the `--key-store-*` options along with `--release` to produce a signed release build. You need to specify all `--key-store-*` options. * `--key-store-alias` - Provides the alias for the keystore file specified with `--key-store-path`. You can use the `--key-store-*` options along with `--release` to produce a signed release build. You need to specify all `--key-store-*` options. diff --git a/docs/man_pages/project/testing/build-ios.md b/docs/man_pages/project/testing/build-ios.md index 565573dbe5..27422034b2 100644 --- a/docs/man_pages/project/testing/build-ios.md +++ b/docs/man_pages/project/testing/build-ios.md @@ -17,12 +17,12 @@ Builds the project for iOS and produces an `APP` or `IPA` that you can manually Usage | Synopsis ---|--- -General | `$ ns build ios [--for-device] [--release [--native]] [--copy-to ] [--provision []] [--env.*]]` +General | `$ ns build ios [--for-device] [--release [--compiled]] [--copy-to ] [--provision []] [--env.*]]` ### Options * `--release` - If set, produces a release build. Otherwise, produces a debug build. -* `--native` - Compiles the release build to native code with `@nativescript/native-release` (a devDependency of the project): no JavaScript runtime in the app. Implies `--release`. Set `nativeRelease: true` in `nativescript.config.ts` to make it the default for release builds, and `--no-native` to opt out for one build. +* `--compiled` - Compiles the release build to native code with `@nativescript/compiler` (a devDependency of the project): no JavaScript runtime in the app. Implies `--release`. Set `nativeRelease: true` in `nativescript.config.ts` to make it the default for release builds, and `--no-compiled` to opt out for one build. * `--for-device` - If set, produces an application package that you can deploy on device. Otherwise, produces a build that you can run only in the native iOS Simulator. * `--i-cloud-container-environment` - If set, adds the passed `iCloudContainerEnvironment` when exporting an application package with the `--for-device` option. * `--copy-to` - Specifies the file path where the built `.ipa` will be copied. If it points to a non-existent directory path, it will be created. If the specified value is existing directory, the original file name will be used. diff --git a/docs/man_pages/project/testing/run-android.md b/docs/man_pages/project/testing/run-android.md index 94d8e2cd01..09754a9fe3 100644 --- a/docs/man_pages/project/testing/run-android.md +++ b/docs/man_pages/project/testing/run-android.md @@ -17,7 +17,7 @@ When running this command without passing `--release` flag, the HMR (Hot Module Usage | Synopsis ---|--- -Run on all connected devices and running emulators | `$ ns run android [--key-store-path --key-store-password --key-store-alias --key-store-alias-password ] [--release [--native]] [--justlaunch] [--env.*]] [--aab]` +Run on all connected devices and running emulators | `$ ns run android [--key-store-path --key-store-password --key-store-alias --key-store-alias-password ] [--release [--compiled]] [--justlaunch] [--env.*]] [--aab]` Run on a selected connected device or running emulator. Will start emulator with specified `Device Identifier`, if not already running. | `$ ns run android --device [--key-store-path --key-store-password --key-store-alias --key-store-alias-password ] [--release] [--justlaunch] [--env.*]] [--aab]` Start a default emulator if none are running, or run application on all connected emulators. | `$ ns run android --emulator [--key-store-path --key-store-password --key-store-alias --key-store-alias-password ] [--release] [--justlaunch] [--env.*]] [--aab]` @@ -29,7 +29,7 @@ Start a default emulator if none are running, or run application on all connecte * `--clean` - If set, forces the complete rebuild of the native application. * `--no-watch` - If set, changes in your code will not be reflected during the execution of this command. * `--release` - If set, produces a release build by running webpack in production mode and native build in release mode. Otherwise, produces a debug build. When set, you must also specify the --key-store-* options. -* `--native` - Compiles the release build to native code with `@nativescript/native-release` (a devDependency of the project): no JavaScript runtime in the app. Implies `--release`. Set `nativeRelease: true` in `nativescript.config.ts` to make it the default for release builds, and `--no-native` to opt out for one build. +* `--compiled` - Compiles the release build to native code with `@nativescript/compiler` (a devDependency of the project): no JavaScript runtime in the app. Implies `--release`. Set `nativeRelease: true` in `nativescript.config.ts` to make it the default for release builds, and `--no-compiled` to opt out for one build. * `--key-store-path` - Specifies the file path to the keystore file (P12) which you want to use to code sign your APK. You can use the `--key-store-*` options along with `--release` to produce a signed release build. You need to specify all `--key-store-*` options. * `--key-store-password` - Provides the password for the keystore file specified with `--key-store-path`. You can use the `--key-store-*` options along with `--release` to produce a signed release build. You need to specify all `--key-store-*` options. * `--key-store-alias` - Provides the alias for the keystore file specified with `--key-store-path`. You can use the `--key-store-*` options along with `--release` to produce a signed release build. You need to specify all `--key-store-*` options. diff --git a/docs/man_pages/project/testing/run-ios.md b/docs/man_pages/project/testing/run-ios.md index 49bd2d78c0..de56338cdd 100644 --- a/docs/man_pages/project/testing/run-ios.md +++ b/docs/man_pages/project/testing/run-ios.md @@ -21,7 +21,7 @@ When running this command without passing `--release` flag, the HMR (Hot Module Usage | Synopsis ---|--- -Run on all connected devices | `$ ns run ios [--release [--native]] [--justlaunch] [--env.*]]` +Run on all connected devices | `$ ns run ios [--release [--compiled]] [--justlaunch] [--env.*]]` Run on a selected connected device. Will start simulator with specified `Device Identifier`, if not already running. | `$ ns run ios [--device ] [--release] [--justlaunch] [--env.*]]` Start an emulator and run the app inside it | `$ ns run ios --emulator [--release] [--env.*]]` Start an emulator with specified device name and sdk | `$ ns run ios [--device ] [--sdk ]` @@ -36,7 +36,7 @@ Start an emulator with specified device identifier and sdk | `$ ns run ios [--de * `--clean` - If set, forces the complete rebuild of the native application. * `--no-watch` - If set, changes in your code will not be reflected during the execution of this command. * `--release` - If set, produces a release build by running webpack in production mode and native build in release mode. Otherwise, produces a debug build. -* `--native` - Compiles the release build to native code with `@nativescript/native-release` (a devDependency of the project): no JavaScript runtime in the app. Implies `--release`. Set `nativeRelease: true` in `nativescript.config.ts` to make it the default for release builds, and `--no-native` to opt out for one build. +* `--compiled` - Compiles the release build to native code with `@nativescript/compiler` (a devDependency of the project): no JavaScript runtime in the app. Implies `--release`. Set `nativeRelease: true` in `nativescript.config.ts` to make it the default for release builds, and `--no-compiled` to opt out for one build. * `--no-hmr` - Disables Hot Module Replacement (HMR). In this case, when a change in the code is applied, CLI will transfer the modified files and restart the application. * `--env.*` - Specifies additional flags that the bundler may process. Can be passed multiple times. * `--env.aot` - creates Ahead-Of-Time build (Angular only). diff --git a/lib/bootstrap.ts b/lib/bootstrap.ts index 87100c64ec..b606e0b8a9 100644 --- a/lib/bootstrap.ts +++ b/lib/bootstrap.ts @@ -423,7 +423,7 @@ injector.require( "viteHmrPortService", "./services/bundler/vite-hmr-port-service", ); -injector.require("nativeReleaseService", "./services/native-release-service"); +injector.require("compiledReleaseService", "./services/compiled-release-service"); injector.require( "applePortalSessionService", diff --git a/lib/constants.ts b/lib/constants.ts index 383fc3e623..b3373f248b 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -16,7 +16,7 @@ export const SCOPED_TNS_CORE_MODULES = "@nativescript/core"; 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 NATIVE_RELEASE_PACKAGE_NAME = "@nativescript/native-release"; +export const COMPILER_PACKAGE_NAME = "@nativescript/compiler"; export const RSPACK_PLUGIN_NAME = "@nativescript/rspack"; // Root of the project-relative directory the Vite bundler writes its build // output to before the CLI copies it into the platforms app folder. The CLI diff --git a/lib/controllers/build-controller.ts b/lib/controllers/build-controller.ts index a7df443f7e..a4d257eb04 100644 --- a/lib/controllers/build-controller.ts +++ b/lib/controllers/build-controller.ts @@ -15,7 +15,7 @@ import { IInjector } from "../common/definitions/yok"; import { injector } from "../common/yok"; import { performance } from "perf_hooks"; import * as path from "path"; -import { NativeReleaseService } from "../services/native-release-service"; +import { CompiledReleaseService } from "../services/compiled-release-service"; export class BuildController extends EventEmitter implements IBuildController { constructor( @@ -29,7 +29,7 @@ export class BuildController extends EventEmitter implements IBuildController { private $projectDataService: IProjectDataService, private $projectChangesService: IProjectChangesService, private $prepareController: IPrepareController, - private $nativeReleaseService: NativeReleaseService + private $compiledReleaseService: CompiledReleaseService ) { super(); } @@ -77,8 +77,8 @@ export class BuildController extends EventEmitter implements IBuildController { }`, }); - if (this.$nativeReleaseService.isNativeRelease(buildData)) { - const packageFile = await this.$nativeReleaseService.build( + if (this.$compiledReleaseService.isCompiledRelease(buildData)) { + const packageFile = await this.$compiledReleaseService.build( platformData, projectData, buildData @@ -165,7 +165,7 @@ export class BuildController extends EventEmitter implements IBuildController { public async shouldBuild(buildData: IBuildData): Promise { // xcodebuild and Gradle decide what is stale in the native project. - if (this.$nativeReleaseService.isNativeRelease(buildData)) { + if (this.$compiledReleaseService.isCompiledRelease(buildData)) { return true; } diff --git a/lib/controllers/prepare-controller.ts b/lib/controllers/prepare-controller.ts index 4d8e03b9f6..f18de97b53 100644 --- a/lib/controllers/prepare-controller.ts +++ b/lib/controllers/prepare-controller.ts @@ -41,7 +41,7 @@ import { IProjectService, } from "../definitions/project"; import { resolvePackageJSONPath } from "@rigor789/resolve-package-path"; -import { NativeReleaseService } from "../services/native-release-service"; +import { CompiledReleaseService } from "../services/compiled-release-service"; interface IPlatformWatcherData { hasWebpackCompilerProcess: boolean; @@ -82,7 +82,7 @@ export class PrepareController private $markingModeService: IMarkingModeService, private $projectConfigService: IProjectConfigService, private $projectService: IProjectService, - private $nativeReleaseService: NativeReleaseService, + private $compiledReleaseService: CompiledReleaseService, ) { super(); } @@ -146,9 +146,9 @@ export class PrepareController ): Promise { await this.$projectService.ensureAppResourcesExist(projectData.projectDir); - // The native build needs neither the bundle nor the runtime's platform project. - if (this.$nativeReleaseService.isNativeRelease(prepareData)) { - await this.$nativeReleaseService.prepare( + // The compiled build needs neither the bundle nor the runtime's platform project. + if (this.$compiledReleaseService.isCompiledRelease(prepareData)) { + await this.$compiledReleaseService.prepare( this.$platformsDataService.getPlatformData( prepareData.platform, projectData, @@ -156,7 +156,7 @@ export class PrepareController projectData, ); this.$logger.info( - `Project successfully prepared (${prepareData.platform.toLowerCase()}, native)`, + `Project successfully prepared (${prepareData.platform.toLowerCase()}, compiled)`, ); return { hasNativeChanges: true, diff --git a/lib/data/prepare-data.ts b/lib/data/prepare-data.ts index 5e185ef5e9..9d2a7c0145 100644 --- a/lib/data/prepare-data.ts +++ b/lib/data/prepare-data.ts @@ -4,7 +4,7 @@ import * as _ from "lodash"; export class PrepareData extends ControllerDataBase { public release: boolean; - public native?: boolean; + public compiled?: boolean; public hmr: boolean; public env: any; public watch?: boolean; @@ -35,7 +35,7 @@ export class PrepareData extends ControllerDataBase { } this.release = data.release; - this.native = data.native; + this.compiled = data.compiled; this.hmr = data.hmr || data.useHotModuleReload; this.env = { ...env, diff --git a/lib/declarations.d.ts b/lib/declarations.d.ts index b0b6e030fb..71fe354b4e 100644 --- a/lib/declarations.d.ts +++ b/lib/declarations.d.ts @@ -693,7 +693,7 @@ interface IOptions inspector: boolean; // the counterpart to --chrome background: string; hmr: boolean; - native: boolean; + compiled: boolean; link: boolean; performance: Object; cleanupLogFile: string; diff --git a/lib/definitions/prepare.d.ts b/lib/definitions/prepare.d.ts index e15f0111bd..e0ac8f2016 100644 --- a/lib/definitions/prepare.d.ts +++ b/lib/definitions/prepare.d.ts @@ -10,7 +10,7 @@ declare global { * Compile a release build to native code with no JavaScript runtime. * Unset defers to `nativeRelease` in nativescript.config. */ - native?: boolean; + compiled?: boolean; hmr: boolean; env: any; watch?: boolean; diff --git a/lib/definitions/project.d.ts b/lib/definitions/project.d.ts index 10fee1ed42..428fc8b47c 100644 --- a/lib/definitions/project.d.ts +++ b/lib/definitions/project.d.ts @@ -207,8 +207,8 @@ interface INsConfig { bundler?: BundlerType; /** * Release builds compile the app to native code with - * `@nativescript/native-release` instead of bundling it for the - * JavaScript runtime. `--native` / `--no-native` override it per command. + * `@nativescript/compiler` instead of bundling it for the + * JavaScript runtime. `--compiled` / `--no-compiled` override it per command. */ nativeRelease?: boolean; ios?: INsConfigIOS; diff --git a/lib/options.ts b/lib/options.ts index b4f59ea54d..9f47c572ae 100644 --- a/lib/options.ts +++ b/lib/options.ts @@ -202,7 +202,7 @@ export class Options { alias: "r", hasSensitiveValue: false, }, - native: { type: OptionType.Boolean, hasSensitiveValue: false }, + compiled: { type: OptionType.Boolean, hasSensitiveValue: false }, markingMode: { type: OptionType.Boolean, hasSensitiveValue: false }, var: { type: OptionType.Object, hasSensitiveValue: true }, default: { type: OptionType.Boolean, hasSensitiveValue: false }, @@ -518,8 +518,8 @@ export class Options { this.argv.emulator = this.argv.simulator; } - // a native build has no debug variant: it is the release build - if (this.argv.native) { + // a compiled build has no debug variant: it is the release build + if (this.argv.compiled) { this.argv.release = true; } diff --git a/lib/services/native-release-service.ts b/lib/services/compiled-release-service.ts similarity index 89% rename from lib/services/native-release-service.ts rename to lib/services/compiled-release-service.ts index c5f68d8e5a..026484b1d7 100644 --- a/lib/services/native-release-service.ts +++ b/lib/services/compiled-release-service.ts @@ -1,6 +1,6 @@ import * as path from "path"; import { resolvePackagePath } from "@rigor789/resolve-package-path"; -import { NATIVE_RELEASE_PACKAGE_NAME } from "../constants"; +import { COMPILER_PACKAGE_NAME } from "../constants"; import { IChildProcess, IErrors, IFileSystem } from "../common/declarations"; import { IPlatformData } from "../definitions/platform"; import { IProjectData, IProjectDataService } from "../definitions/project"; @@ -13,21 +13,21 @@ import { IGradleCommandService } from "../definitions/gradle"; import { IOSProvisionService } from "./ios-provision-service"; import { injector } from "../common/yok"; -export interface INativeReleaseOptions { +export interface ICompiledReleaseOptions { projectDir: string; platform: string; release?: boolean; - native?: boolean; + compiled?: boolean; } /** - * A release build compiled by `@nativescript/native-release` into Swift or + * A release build compiled by `@nativescript/compiler` into Swift or * Kotlin: no JavaScript runtime, no webpack bundle and no `platforms/` * runtime project. Prepare writes the native project to - * `platforms/native/`; build compiles it there and returns the + * `platforms/compiled/`; build compiles it there and returns the * .app, .ipa, .apk or .aab for the deploy services to install. */ -export class NativeReleaseService { +export class CompiledReleaseService { constructor( private $childProcess: IChildProcess, private $errors: IErrors, @@ -42,18 +42,18 @@ export class NativeReleaseService { ) {} /** - * `--native` (or `--no-native`) wins; otherwise `nativeRelease` in + * `--compiled` (or `--no-compiled`) wins; otherwise `nativeRelease` in * nativescript.config, where `ios.nativeRelease` and `android.nativeRelease` * override the top-level value. Debug builds always run on the JavaScript * runtime. */ - public isNativeRelease(options: INativeReleaseOptions): boolean { + public isCompiledRelease(options: ICompiledReleaseOptions): boolean { if (!options.release) { return false; } - if (typeof options.native === "boolean") { - return options.native; + if (typeof options.compiled === "boolean") { + return options.compiled; } const nsConfig = this.$projectDataService.getProjectData( @@ -68,7 +68,7 @@ export class NativeReleaseService { private getProjectRoot(projectData: IProjectData, platform: string): string { return path.join( projectData.platformsDir, - "native", + "compiled", platform.toLowerCase(), ); } @@ -83,7 +83,7 @@ export class NativeReleaseService { !this.$mobileHelper.isAndroidPlatform(platform) ) { this.$errors.fail( - `A native release build is available for iOS and Android, not ${platformData.normalizedPlatformName}.`, + `A compiled release build is available for iOS and Android, not ${platformData.normalizedPlatformName}.`, ); } @@ -107,7 +107,7 @@ export class NativeReleaseService { stdio: "inherit", }); } catch (err) { - this.$errors.fail(`The native release compile failed. ${err.message}`); + this.$errors.fail(`The compile to native code failed. ${err.message}`); } } @@ -132,7 +132,7 @@ export class NativeReleaseService { : await this.buildIOS(projectData, buildData); if (!this.$fs.exists(packageFile)) { this.$errors.fail( - `The native release build finished without producing ${packageFile}.`, + `The compiled release build finished without producing ${packageFile}.`, ); } @@ -147,14 +147,14 @@ export class NativeReleaseService { } private getPackageDir(projectData: IProjectData): string { - const packageDir = resolvePackagePath(NATIVE_RELEASE_PACKAGE_NAME, { + const packageDir = resolvePackagePath(COMPILER_PACKAGE_NAME, { paths: [projectData.projectDir], }); if (!packageDir) { this.$errors.fail( - `A native release build needs the ${NATIVE_RELEASE_PACKAGE_NAME} compiler in the project. ` + - `Install it with 'npm install --save-dev ${NATIVE_RELEASE_PACKAGE_NAME}', ` + - `or build on the JavaScript runtime with --no-native.`, + `A compiled release build needs ${COMPILER_PACKAGE_NAME} in the project. ` + + `Install it with 'npm install --save-dev ${COMPILER_PACKAGE_NAME}', ` + + `or build on the JavaScript runtime with --no-compiled.`, ); } @@ -212,7 +212,7 @@ export class NativeReleaseService { "generic/platform=iOS Simulator", "build", ], - { cwd: projectRoot, message: "Xcode build (native release)..." }, + { cwd: projectRoot, message: "Xcode build (compiled release)..." }, ); return path.join( @@ -250,7 +250,7 @@ export class NativeReleaseService { "archive", ...automaticSigning, ], - { cwd: projectRoot, message: "Xcode archive (native release)..." }, + { cwd: projectRoot, message: "Xcode archive (compiled release)..." }, ); if (!buildData.teamId && !buildData.provision) { @@ -402,7 +402,7 @@ export class NativeReleaseService { await this.$gradleCommandService.executeCommand(args, { cwd: projectRoot, - message: "Gradle build (native release)...", + message: "Gradle build (compiled release)...", gradlePath: buildData.gradlePath ?? path.join(this.getPackageDir(projectData), "kit-android", "gradlew"), @@ -430,4 +430,4 @@ export class NativeReleaseService { ); } } -injector.register("nativeReleaseService", NativeReleaseService); +injector.register("compiledReleaseService", CompiledReleaseService); diff --git a/test/controllers/prepare-controller.ts b/test/controllers/prepare-controller.ts index 1095654947..254fba23e7 100644 --- a/test/controllers/prepare-controller.ts +++ b/test/controllers/prepare-controller.ts @@ -151,7 +151,7 @@ describe("prepareController", () => { }); }); - describe("preparePlatform for a native release", () => { + describe("preparePlatform for a compiled release", () => { _.each(["ios", "android"], (platform) => { it(`compiles to native code instead of bundling or adding the ${platform} runtime`, async () => { const injector = createTestInjector({ hasNativeChanges: false }); @@ -160,8 +160,8 @@ describe("prepareController", () => { isAddPlatformCalled = true; }; const nativePrepareCalls: any[] = []; - Object.assign(injector.resolve("nativeReleaseService"), { - isNativeRelease: (data: IPrepareData) => data.release, + Object.assign(injector.resolve("compiledReleaseService"), { + isCompiledRelease: (data: IPrepareData) => data.release, prepare: async (platformData: any, projectData: any) => { nativePrepareCalls.push({ platformData, projectData }); }, diff --git a/test/options.ts b/test/options.ts index 4843f08b02..7c23c78681 100644 --- a/test/options.ts +++ b/test/options.ts @@ -487,8 +487,8 @@ describe("options", () => { expectedHmrValue: false, }, { - name: "should set hmr to false by default when --native option is provided", - args: ["--native"], + name: "should set hmr to false by default when --compiled option is provided", + args: ["--compiled"], expectedHmrValue: false, }, ]; @@ -508,27 +508,27 @@ describe("options", () => { }); }); - describe("native option", () => { + describe("compiled option", () => { const testCases = [ { name: "is a release build", - args: ["--native"], - expected: { native: true, release: true }, + args: ["--compiled"], + expected: { compiled: true, release: true }, }, { name: "stays a release build with --release", - args: ["--release", "--native"], - expected: { native: true, release: true }, + args: ["--release", "--compiled"], + expected: { compiled: true, release: true }, }, { name: "is left unset without the flag, so the config decides", args: ["--release"], - expected: { native: undefined, release: true }, + expected: { compiled: undefined, release: true }, }, { - name: "can be turned off with --no-native", - args: ["--release", "--no-native"], - expected: { native: false, release: true }, + name: "can be turned off with --no-compiled", + args: ["--release", "--no-compiled"], + expected: { compiled: false, release: true }, }, ]; @@ -542,7 +542,7 @@ describe("options", () => { testCase.args.forEach(() => process.argv.pop()); assert.deepStrictEqual( - { native: options.native, release: options.release }, + { compiled: options.compiled, release: options.release }, testCase.expected, ); assert.isFalse(isExecutionStopped); diff --git a/test/services/native-release-service.ts b/test/services/compiled-release-service.ts similarity index 89% rename from test/services/native-release-service.ts rename to test/services/compiled-release-service.ts index d83ef7835b..13cc3eb000 100644 --- a/test/services/native-release-service.ts +++ b/test/services/compiled-release-service.ts @@ -4,7 +4,7 @@ import * as os from "os"; import * as path from "path"; import { Yok } from "../../lib/common/yok"; import { IInjector } from "../../lib/common/definitions/yok"; -import { NativeReleaseService } from "../../lib/services/native-release-service"; +import { CompiledReleaseService } from "../../lib/services/compiled-release-service"; import { IGradleCommandOptions } from "../../lib/definitions/gradle"; interface ISpawnCall { @@ -33,7 +33,7 @@ function createProjectData(nsConfig: any = {}): any { }; } -function createService(nsConfig: any = {}): NativeReleaseService { +function createService(nsConfig: any = {}): CompiledReleaseService { const injector: IInjector = new Yok(); injector.register("childProcess", { spawnFromEvent: async ( @@ -53,7 +53,10 @@ function createService(nsConfig: any = {}): NativeReleaseService { }); injector.register("exportOptionsPlistService", {}); injector.register("fs", { - exists: () => true, + exists: (file: string) => + path.basename(file) === "ns-native-project.json" + ? fs.existsSync(file) + : true, readJson: (file: string) => JSON.parse(fs.readFileSync(file, "utf8")), deleteDirectory: (): void => undefined, deleteFile: (): void => undefined, @@ -90,9 +93,9 @@ function createService(nsConfig: any = {}): NativeReleaseService { xcodebuildCalls.push(args); }, }); - injector.register("nativeReleaseService", NativeReleaseService); + injector.register("compiledReleaseService", CompiledReleaseService); - return injector.resolve("nativeReleaseService"); + return injector.resolve("compiledReleaseService"); } function installCompilerPackage(): string { @@ -100,13 +103,13 @@ function installCompilerPackage(): string { projectDir, "node_modules", "@nativescript", - "native-release", + "compiler", ); fs.mkdirSync(packageDir, { recursive: true }); fs.writeFileSync( path.join(packageDir, "package.json"), JSON.stringify({ - name: "@nativescript/native-release", + name: "@nativescript/compiler", bin: { "ns-native": "bin/ns-native.js" }, }), ); @@ -118,10 +121,10 @@ const platformData = (platform: string): any => ({ normalizedPlatformName: platform, }); -describe("nativeReleaseService", () => { +describe("compiledReleaseService", () => { beforeEach(() => { projectDir = fs.realpathSync( - fs.mkdtempSync(path.join(os.tmpdir(), "native-release-")), + fs.mkdtempSync(path.join(os.tmpdir(), "compiled-release-")), ); spawnCalls = []; gradleCalls = []; @@ -134,11 +137,11 @@ describe("nativeReleaseService", () => { fs.rmSync(projectDir, { recursive: true, force: true }); }); - describe("isNativeRelease", () => { + describe("isCompiledRelease", () => { const testCases = [ { - name: "a debug build, even with --native", - options: { release: false, native: true }, + name: "a debug build, even with --compiled", + options: { release: false, compiled: true }, nsConfig: { nativeRelease: true }, expected: false, }, @@ -149,8 +152,8 @@ describe("nativeReleaseService", () => { expected: false, }, { - name: "a release build with --native", - options: { release: true, native: true }, + name: "a release build with --compiled", + options: { release: true, compiled: true }, nsConfig: {}, expected: true, }, @@ -161,8 +164,8 @@ describe("nativeReleaseService", () => { expected: true, }, { - name: "a release build with --no-native over the config", - options: { release: true, native: false }, + name: "a release build with --no-compiled over the config", + options: { release: true, compiled: false }, nsConfig: { nativeRelease: true }, expected: false, }, @@ -184,7 +187,7 @@ describe("nativeReleaseService", () => { it(`is ${testCase.expected} for ${testCase.name}`, () => { const service = createService(testCase.nsConfig); assert.equal( - service.isNativeRelease({ + service.isCompiledRelease({ projectDir, platform: "Android", ...testCase.options, @@ -196,7 +199,7 @@ describe("nativeReleaseService", () => { }); describe("prepare", () => { - it("runs the project's compiler with the CLI's node, writing to platforms/native/", async () => { + it("runs the project's compiler with the CLI's node, writing to platforms/compiled/", async () => { const packageDir = installCompilerPackage(); const service = createService(); @@ -210,7 +213,7 @@ describe("nativeReleaseService", () => { "--platform", "ios", "--out", - path.join(projectDir, "platforms", "native", "ios"), + path.join(projectDir, "platforms", "compiled", "ios"), "--name", "demo", "--bundle", @@ -230,7 +233,7 @@ describe("nativeReleaseService", () => { assert.include( error.message, - "npm install --save-dev @nativescript/native-release", + "npm install --save-dev @nativescript/compiler", ); assert.lengthOf(spawnCalls, 0); }); @@ -271,7 +274,7 @@ describe("nativeReleaseService", () => { const projectRoot = path.join( projectDir, "platforms", - "native", + "compiled", "android", ); assert.lengthOf(gradleCalls, 1); diff --git a/test/stubs.ts b/test/stubs.ts index d1ab2a4d5c..360e4689c9 100644 --- a/test/stubs.ts +++ b/test/stubs.ts @@ -1522,8 +1522,8 @@ export class InjectorStub extends Yok implements IInjector { getDeviceByIdentifier: (): Mobile.IDevice => undefined, }); this.register("terminalSpinnerService", TerminalSpinnerServiceStub); - this.register("nativeReleaseService", { - isNativeRelease: () => false, + this.register("compiledReleaseService", { + isCompiledRelease: () => false, }); } } From a1f2fdedc44703c753fe006152cde8f1423c50e3 Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Sun, 4 Oct 2026 19:40:07 -0700 Subject: [PATCH 4/8] Read the compiled release from release.compiled in nativescript.config release.compiled replaces nativeRelease, and ios.release / android.release override it per platform; --compiled / --no-compiled still win. The release object also carries the compiler's options, which replace nativeReleaseOptions. --- .../project/testing/build-android.md | 2 +- docs/man_pages/project/testing/build-ios.md | 2 +- docs/man_pages/project/testing/run-android.md | 2 +- docs/man_pages/project/testing/run-ios.md | 2 +- lib/definitions/prepare.d.ts | 2 +- lib/definitions/project.d.ts | 38 +++++++++++++++---- lib/services/compiled-release-service.ts | 14 ++++--- test/services/compiled-release-service.ts | 21 +++++++--- 8 files changed, 58 insertions(+), 25 deletions(-) diff --git a/docs/man_pages/project/testing/build-android.md b/docs/man_pages/project/testing/build-android.md index 17ae0f214a..6552337bd7 100644 --- a/docs/man_pages/project/testing/build-android.md +++ b/docs/man_pages/project/testing/build-android.md @@ -20,7 +20,7 @@ General | `$ ns build android [--compileSdk ] [--key-store-path ` is a valid Android API level. For example: 28, 29. The minimum supported SDK is 28. <% if(isHtml) { %> For a complete list of the Android API levels and their corresponding Android versions, click [here](http://developer.android.com/guide/topics/manifest/uses-sdk-element.html#platform).<% } %> * `--clean` - If set, forces the complete rebuild of the native application. * `--release` - If set, produces a release build. Otherwise, produces a debug build. When set, you must also specify the `--key-store-*` options. -* `--compiled` - Compiles the release build to native code with `@nativescript/compiler` (a devDependency of the project): no JavaScript runtime in the app. Implies `--release`. Set `nativeRelease: true` in `nativescript.config.ts` to make it the default for release builds, and `--no-compiled` to opt out for one build. +* `--compiled` - Compiles the release build to native code with `@nativescript/compiler` (a devDependency of the project): no JavaScript runtime in the app. Implies `--release`. Set `release: { compiled: true }` in `nativescript.config.ts` to make it the default for release builds, and `--no-compiled` to opt out for one build. * `--key-store-path` - Specifies the file path to the keystore file (P12) which you want to use to code sign your APK. You can use the `--key-store-*` options along with `--release` to produce a signed release build. You need to specify all `--key-store-*` options. * `--key-store-password` - Provides the password for the keystore file specified with `--key-store-path`. You can use the `--key-store-*` options along with `--release` to produce a signed release build. You need to specify all `--key-store-*` options. * `--key-store-alias` - Provides the alias for the keystore file specified with `--key-store-path`. You can use the `--key-store-*` options along with `--release` to produce a signed release build. You need to specify all `--key-store-*` options. diff --git a/docs/man_pages/project/testing/build-ios.md b/docs/man_pages/project/testing/build-ios.md index 27422034b2..1046a967f6 100644 --- a/docs/man_pages/project/testing/build-ios.md +++ b/docs/man_pages/project/testing/build-ios.md @@ -22,7 +22,7 @@ General | `$ ns build ios [--for-device] [--release [--compiled]] [--copy-to ; } +/** + * Release builds compiled to native code by `@nativescript/compiler`, and the + * compiler's options. + */ +interface INsConfigRelease { + /** + * Release builds compile the app to native code instead of bundling it for + * the JavaScript runtime. `--compiled` / `--no-compiled` override it per command. + */ + compiled?: boolean; + /** + * Packages the compiled build replaces with a module of the app's, as + * `'package': 'path'`. + */ + pluginReplacements?: Record; + /** + * Checkouts of plugins' TypeScript source, as `'package': 'path'`, used + * instead of fetching the published revision. + */ + pluginSources?: Record; + /** + * Build despite properties core declares that the compiled build does not + * apply, with a warning for each. + */ + allowUnimplementedProperties?: boolean; +} + interface INsConfigPlaform { id?: string; /** - * Overrides the top-level `nativeRelease` for this platform. + * Merged over the top-level `release` for this platform; object options merge by key. */ - nativeRelease?: boolean; + release?: INsConfigRelease; } interface IOSSPMPackageBase { @@ -205,12 +232,7 @@ interface INsConfig { webpackConfigPath?: string; bundlerConfigPath?: string; bundler?: BundlerType; - /** - * Release builds compile the app to native code with - * `@nativescript/compiler` instead of bundling it for the - * JavaScript runtime. `--compiled` / `--no-compiled` override it per command. - */ - nativeRelease?: boolean; + release?: INsConfigRelease; ios?: INsConfigIOS; android?: INsConfigAndroid; visionos?: INSConfigVisionOS; diff --git a/lib/services/compiled-release-service.ts b/lib/services/compiled-release-service.ts index 026484b1d7..11e2eaf068 100644 --- a/lib/services/compiled-release-service.ts +++ b/lib/services/compiled-release-service.ts @@ -42,10 +42,10 @@ export class CompiledReleaseService { ) {} /** - * `--compiled` (or `--no-compiled`) wins; otherwise `nativeRelease` in - * nativescript.config, where `ios.nativeRelease` and `android.nativeRelease` - * override the top-level value. Debug builds always run on the JavaScript - * runtime. + * `--compiled` (or `--no-compiled`) wins; otherwise `release.compiled` in + * nativescript.config, where `ios.release.compiled` and + * `android.release.compiled` override the top-level value. Debug builds + * always run on the JavaScript runtime. */ public isCompiledRelease(options: ICompiledReleaseOptions): boolean { if (!options.release) { @@ -59,10 +59,12 @@ export class CompiledReleaseService { const nsConfig = this.$projectDataService.getProjectData( options.projectDir, ).nsConfig; - const platformConfig: { nativeRelease?: boolean } = + const platformConfig: { release?: { compiled?: boolean } } = nsConfig?.[options.platform.toLowerCase() as "ios" | "android"]; - return !!(platformConfig?.nativeRelease ?? nsConfig?.nativeRelease); + return !!( + platformConfig?.release?.compiled ?? nsConfig?.release?.compiled + ); } private getProjectRoot(projectData: IProjectData, platform: string): string { diff --git a/test/services/compiled-release-service.ts b/test/services/compiled-release-service.ts index 13cc3eb000..e27fea893c 100644 --- a/test/services/compiled-release-service.ts +++ b/test/services/compiled-release-service.ts @@ -142,7 +142,7 @@ describe("compiledReleaseService", () => { { name: "a debug build, even with --compiled", options: { release: false, compiled: true }, - nsConfig: { nativeRelease: true }, + nsConfig: { release: { compiled: true } }, expected: false, }, { @@ -158,27 +158,36 @@ describe("compiledReleaseService", () => { expected: true, }, { - name: "a release build with nativeRelease in the config", + name: "a release build with release.compiled in the config", options: { release: true }, - nsConfig: { nativeRelease: true }, + nsConfig: { release: { compiled: true } }, expected: true, }, { name: "a release build with --no-compiled over the config", options: { release: true, compiled: false }, - nsConfig: { nativeRelease: true }, + nsConfig: { release: { compiled: true } }, expected: false, }, { name: "a release build whose platform opts out in the config", options: { release: true }, - nsConfig: { nativeRelease: true, android: { nativeRelease: false } }, + nsConfig: { + release: { compiled: true }, + android: { release: { compiled: false } }, + }, expected: false, }, + { + name: "a release build with --compiled over the platform's opt-out", + options: { release: true, compiled: true }, + nsConfig: { android: { release: { compiled: false } } }, + expected: true, + }, { name: "a release build whose platform alone opts in", options: { release: true }, - nsConfig: { android: { nativeRelease: true } }, + nsConfig: { android: { release: { compiled: true } } }, expected: true, }, ]; From dd2cdb1f109a2d06bc5455bb1e0321f43a66712a Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Thu, 8 Oct 2026 14:53:48 -0700 Subject: [PATCH 5/8] Compiled release: unsigned device archives say so to xcodebuild The compiler's generated project now leaves device builds signable, so an archive in Xcode signs with the team chosen there. Without --team-id (no team, or --provision, which signs at export) the archive passes CODE_SIGNING_ALLOWED=NO itself. --- lib/services/compiled-release-service.ts | 4 +++- test/services/compiled-release-service.ts | 2 ++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/lib/services/compiled-release-service.ts b/lib/services/compiled-release-service.ts index 11e2eaf068..563de051a7 100644 --- a/lib/services/compiled-release-service.ts +++ b/lib/services/compiled-release-service.ts @@ -232,6 +232,8 @@ export class CompiledReleaseService { // Signing settings given to xcodebuild reach every target, and the kit's // Swift package rejects a provisioning profile, so with --provision the // archive stays unsigned and the export signs the app with the profile. + // The generated project leaves device builds signable (for Xcode), so an + // archive without a team says it is unsigned here. const automaticSigning = buildData.teamId && !buildData.provision ? [ @@ -240,7 +242,7 @@ export class CompiledReleaseService { `DEVELOPMENT_TEAM=${buildData.teamId}`, "-allowProvisioningUpdates", ] - : []; + : ["CODE_SIGNING_ALLOWED=NO"]; this.$fs.deleteDirectory(archivePath); await this.$xcodebuildCommandService.executeCommand( [ diff --git a/test/services/compiled-release-service.ts b/test/services/compiled-release-service.ts index e27fea893c..5c83e8f137 100644 --- a/test/services/compiled-release-service.ts +++ b/test/services/compiled-release-service.ts @@ -358,6 +358,7 @@ describe("compiledReleaseService", () => { assert.lengthOf(xcodebuildCalls, 1); assert.include(xcodebuildCalls[0], "archive"); assert.notInclude(xcodebuildCalls[0], "CODE_SIGNING_ALLOWED=YES"); + assert.include(xcodebuildCalls[0], "CODE_SIGNING_ALLOWED=NO"); assert.lengthOf(warnings, 1); assert.match(result, /build\/demo\.ipa$/); }); @@ -372,6 +373,7 @@ describe("compiledReleaseService", () => { ); assert.lengthOf(xcodebuildCalls, 2); + assert.include(xcodebuildCalls[0], "CODE_SIGNING_ALLOWED=NO"); assert.isFalse( xcodebuildCalls[0].some((arg) => arg.startsWith("PROVISIONING_PROFILE"), From 31ab3e7a568243244a853d510f26c441125f8f3d Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Thu, 8 Oct 2026 21:03:36 -0700 Subject: [PATCH 6/8] compiled release: build the project or workspace the compiler generated The compiler generates the Xcode project and names it in ns-native-project.json; XcodeGen runs here only for a compiler that does not. --- lib/services/compiled-release-service.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/lib/services/compiled-release-service.ts b/lib/services/compiled-release-service.ts index 563de051a7..7913a2184d 100644 --- a/lib/services/compiled-release-service.ts +++ b/lib/services/compiled-release-service.ts @@ -170,13 +170,14 @@ export class CompiledReleaseService { const projectRoot = this.getProjectRoot(projectData, "ios"); const name = projectData.projectName; - // A project with CocoaPods or Swift packages is generated and integrated by - // the compiler, which names the workspace to build. + // The compiler generates the Xcode project (and integrates CocoaPods) and names + // the project or workspace to build; a compiler that does not is run through XcodeGen here. const integrated = path.join(projectRoot, "ns-native-project.json"); - const workspace = this.$fs.exists(integrated) - ? this.$fs.readJson(integrated).workspace + const generated: { workspace?: string; project?: string } | null = this.$fs.exists(integrated) + ? this.$fs.readJson(integrated) : null; - if (!workspace) { + const workspace = generated?.workspace ?? null; + if (!generated) { try { await this.$childProcess.spawnFromEvent( "xcodegen", @@ -196,7 +197,7 @@ export class CompiledReleaseService { const projectArgs = [ ...(workspace ? ["-workspace", workspace] - : ["-project", `${name}.xcodeproj`]), + : ["-project", generated?.project ?? `${name}.xcodeproj`]), "-scheme", name, "-configuration", From f86e31bb62fae738fecd9d8f008c275cbc0937da Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Fri, 9 Oct 2026 06:32:19 -0700 Subject: [PATCH 7/8] feat: ns compiled verify compares a compiled release with its JavaScript release Builds both releases of the project, then runs the project's @nativescript/compiler ns-compiled-verify on them: the same steps (the project's verify.json, or a shot after launch) on one iOS Simulator, compared pixel by pixel, failing where a screenshot differs or an app stops running. --- .../project/testing/compiled-verify.md | 46 ++++++++++ lib/bootstrap.ts | 1 + lib/commands/compiled-verify.ts | 89 +++++++++++++++++++ lib/services/compiled-release-service.ts | 12 ++- 4 files changed, 147 insertions(+), 1 deletion(-) create mode 100644 docs/man_pages/project/testing/compiled-verify.md create mode 100644 lib/commands/compiled-verify.ts diff --git a/docs/man_pages/project/testing/compiled-verify.md b/docs/man_pages/project/testing/compiled-verify.md new file mode 100644 index 0000000000..90e75f1cb2 --- /dev/null +++ b/docs/man_pages/project/testing/compiled-verify.md @@ -0,0 +1,46 @@ +<% if (isJekyll) { %>--- +title: ns compiled verify +position: 20 +---<% } %> + +# ns compiled verify + +### Description + +Builds the project's JavaScript release and its compiled release (`--compiled`), runs both the same way on one iOS Simulator and compares them: a screenshot of each step, pixel by pixel, and whether either app stops running. Use it before shipping a compiled release. + +The steps are the project's `verify.json`, when it has one. Each screen launches the app afresh; coordinates are points on the simulator's screen: + +```json +{ + "screens": [ + { "name": "home", "steps": [["shot", "start"], ["tap", 200, 400], ["wait", 1], ["type", "Ada"], ["shot", "typed"]] } + ] +} +``` + +Steps: `shot `, `tap [seconds held]`, `taps [count]`, `swipe `, `drag [seconds]`, `type `, `wait `, `openurl `. Without `verify.json`, the app is compared once it settles after launch. + +The screenshots and `report.json` are written to `platforms/compiled/verify`. The command fails when a screenshot differs or an app stops running. + +<% if(isConsole && (isWindows || isLinux)) { %>WARNING: You can run this command only on macOS systems.<% } %> + +### Commands + +Usage | Synopsis +---|--- +General | `$ ns compiled verify [ios]` + +### Prerequisites + +* `@nativescript/compiler` as a devDependency of the project. +* Xcode with an iOS Simulator runtime. + +<% if(isHtml) { %> + +### Related Commands + +Command | Description +----------|---------- +[build ios](build-ios.html) | Builds the project for iOS, `--compiled` to compile it to native code. +<% } %> diff --git a/lib/bootstrap.ts b/lib/bootstrap.ts index b606e0b8a9..0e34542877 100644 --- a/lib/bootstrap.ts +++ b/lib/bootstrap.ts @@ -184,6 +184,7 @@ injector.requireCommand("run|android", "./commands/run"); injector.requireCommand("run|vision", "./commands/run"); injector.requireCommand("run|visionos", "./commands/run"); injector.requireCommand("typings", "./commands/typings"); +injector.requireCommand("compiled|verify", "./commands/compiled-verify"); injector.requireCommand("preview", "./commands/preview"); diff --git a/lib/commands/compiled-verify.ts b/lib/commands/compiled-verify.ts new file mode 100644 index 0000000000..28e970ce63 --- /dev/null +++ b/lib/commands/compiled-verify.ts @@ -0,0 +1,89 @@ +import * as path from "path"; +import { IChildProcess, IErrors, IFileSystem } from "../common/declarations"; +import { ICommand, ICommandParameter } from "../common/definitions/commands"; +import { injector } from "../common/yok"; +import { IProjectData } from "../definitions/project"; +import { CompiledReleaseService } from "../services/compiled-release-service"; + +/** + * `ns compiled verify [ios]`: builds the JavaScript release and the compiled + * release of the project, runs both the same way on one simulator and compares + * them, a screenshot of each step pixel by pixel and whether either stops + * running. The steps are the project's `verify.json`, when it has one + * (`{ "screens": [{ "name": "home", "steps": [["shot", "start"], ["tap", 200, 400], ["shot", "tapped"]] }] }`); + * without it, a screenshot once the app settles. + */ +export class CompiledVerifyCommand implements ICommand { + public allowedParameters: ICommandParameter[] = []; + + constructor( + private $childProcess: IChildProcess, + private $compiledReleaseService: CompiledReleaseService, + private $errors: IErrors, + private $fs: IFileSystem, + private $logger: ILogger, + private $projectData: IProjectData, + ) {} + + public async execute(args: string[]): Promise { + const platform = (args[0] ?? "ios").toLowerCase(); + if (platform !== "ios") { + this.$errors.fail( + "ns compiled verify compares iOS builds; Android is to follow.", + ); + } + this.$projectData.initializeProjectData(); + const projectDir = this.$projectData.projectDir; + const cli = process.argv[1]; + const ns = (...buildArgs: string[]) => + this.$childProcess.spawnFromEvent( + process.execPath, + [cli, "build", "ios", ...buildArgs], + "close", + { cwd: projectDir, stdio: "inherit" }, + ); + + this.$logger.info("Building the JavaScript release..."); + await ns("--release", "--no-compiled"); + this.$logger.info("Building the compiled release..."); + await ns("--compiled"); + + const js = this.findApp( + path.join(this.$projectData.platformsDir, "ios", "build", "Release-iphonesimulator"), + ); + const compiled = this.findApp( + path.join(this.$projectData.platformsDir, "compiled", "ios", "DerivedData", "Build", "Products", "Release-iphonesimulator"), + ); + const steps = path.join(projectDir, "verify.json"); + const out = path.join(this.$projectData.platformsDir, "compiled", "verify"); + const verify = this.$compiledReleaseService.getCompilerBin( + this.$projectData, + "ns-compiled-verify", + ); + try { + await this.$childProcess.spawnFromEvent( + process.execPath, + [verify, "--js", js, "--compiled", compiled, "--out", out, ...(this.$fs.exists(steps) ? ["--steps", steps] : [])], + "close", + { cwd: projectDir, stdio: "inherit" }, + ); + } catch { + this.$errors.fail( + `The compiled release differs from its JavaScript release: see ${out}`, + ); + } + } + + private findApp(dir: string): string { + const app = this.$fs.exists(dir) + ? this.$fs.readDirectory(dir).find((f) => f.endsWith(".app")) + : undefined; + if (!app) { + this.$errors.fail(`No simulator build in ${dir}.`); + } + + return path.join(dir, app); + } +} + +injector.registerCommand("compiled|verify", CompiledVerifyCommand); diff --git a/lib/services/compiled-release-service.ts b/lib/services/compiled-release-service.ts index 7913a2184d..a787b1374a 100644 --- a/lib/services/compiled-release-service.ts +++ b/lib/services/compiled-release-service.ts @@ -142,10 +142,20 @@ export class CompiledReleaseService { } private getCompilerPath(projectData: IProjectData): string { + return this.getCompilerBin(projectData, "ns-native"); + } + + /** A command of the project's `@nativescript/compiler` (`ns-native`, `ns-compiled-verify`), as a script for Node. */ + public getCompilerBin(projectData: IProjectData, name: string): string { const packageDir = this.getPackageDir(projectData); const { bin } = this.$fs.readJson(path.join(packageDir, "package.json")); + if (!bin?.[name]) { + this.$errors.fail( + `The project's ${COMPILER_PACKAGE_NAME} has no ${name}: update it.`, + ); + } - return path.join(packageDir, bin["ns-native"]); + return path.join(packageDir, bin[name]); } private getPackageDir(projectData: IProjectData): string { From 857e0bb052b5ba927e66bc1537b9e6d813bd1649 Mon Sep 17 00:00:00 2001 From: Nathan Walker Date: Fri, 9 Oct 2026 11:02:02 -0700 Subject: [PATCH 8/8] Compiled builds run the project's before- and after-build hooks The same buildIOS and buildAndroid hooks the platform services run, with the same arguments; projectRoot is the compiled project. --- lib/services/compiled-release-service.ts | 34 +++++++++++++++++++---- test/services/compiled-release-service.ts | 31 +++++++++++++++++++++ 2 files changed, 59 insertions(+), 6 deletions(-) diff --git a/lib/services/compiled-release-service.ts b/lib/services/compiled-release-service.ts index a787b1374a..22cb01238e 100644 --- a/lib/services/compiled-release-service.ts +++ b/lib/services/compiled-release-service.ts @@ -1,7 +1,7 @@ import * as path from "path"; import { resolvePackagePath } from "@rigor789/resolve-package-path"; import { COMPILER_PACKAGE_NAME } from "../constants"; -import { IChildProcess, IErrors, IFileSystem } from "../common/declarations"; +import { IChildProcess, IErrors, IFileSystem, IHooksService } from "../common/declarations"; import { IPlatformData } from "../definitions/platform"; import { IProjectData, IProjectDataService } from "../definitions/project"; import { @@ -12,6 +12,7 @@ import { import { IGradleCommandService } from "../definitions/gradle"; import { IOSProvisionService } from "./ios-provision-service"; import { injector } from "../common/yok"; +import { hook } from "../common/helpers"; export interface ICompiledReleaseOptions { projectDir: string; @@ -34,6 +35,7 @@ export class CompiledReleaseService { private $exportOptionsPlistService: IExportOptionsPlistService, private $fs: IFileSystem, private $gradleCommandService: IGradleCommandService, + public $hooksService: IHooksService, private $iOSProvisionService: IOSProvisionService, private $logger: ILogger, private $mobileHelper: Mobile.IMobileHelper, @@ -127,11 +129,11 @@ export class CompiledReleaseService { this.$fs.deleteDirectory(path.join(projectRoot, "build")); } - const packageFile = this.$mobileHelper.isAndroidPlatform( - platformData.platformNameLowerCase, - ) - ? await this.buildAndroid(projectData, buildData) - : await this.buildIOS(projectData, buildData); + const platform = platformData.platformNameLowerCase; + const projectRoot = this.getProjectRoot(projectData, platform); + const packageFile = this.$mobileHelper.isAndroidPlatform(platform) + ? await this.buildAndroidProject(projectRoot, projectData, buildData) + : await this.buildIOSProject(projectRoot, projectData, buildData); if (!this.$fs.exists(packageFile)) { this.$errors.fail( `The compiled release build finished without producing ${packageFile}.`, @@ -141,6 +143,26 @@ export class CompiledReleaseService { return packageFile; } + // The platform services' build hooks, with the arguments they pass, so a project's + // before-/after-build hooks run for a compiled build too; projectRoot is the compiled project. + @hook("buildIOS") + public async buildIOSProject( + projectRoot: string, + projectData: IProjectData, + buildData: IiOSBuildData, + ): Promise { + return this.buildIOS(projectData, buildData); + } + + @hook("buildAndroid") + public async buildAndroidProject( + projectRoot: string, + projectData: IProjectData, + buildData: IAndroidBuildData, + ): Promise { + return this.buildAndroid(projectData, buildData); + } + private getCompilerPath(projectData: IProjectData): string { return this.getCompilerBin(projectData, "ns-native"); } diff --git a/test/services/compiled-release-service.ts b/test/services/compiled-release-service.ts index 5c83e8f137..bbedcd6933 100644 --- a/test/services/compiled-release-service.ts +++ b/test/services/compiled-release-service.ts @@ -19,6 +19,7 @@ let gradleCalls: { args: string[]; options: IGradleCommandOptions }[]; let xcodebuildCalls: string[][]; let writtenFiles: { [path: string]: string }; let warnings: string[]; +let hookCalls: { name: string; args: any }[]; function createProjectData(nsConfig: any = {}): any { return { @@ -93,6 +94,15 @@ function createService(nsConfig: any = {}): CompiledReleaseService { xcodebuildCalls.push(args); }, }); + injector.register("hooksService", { + hookArgsName: "hookArgs", + executeBeforeHooks: async (name: string, args: any) => { + hookCalls.push({ name: `before-${name}`, args }); + }, + executeAfterHooks: async (name: string, args: any) => { + hookCalls.push({ name: `after-${name}`, args }); + }, + }); injector.register("compiledReleaseService", CompiledReleaseService); return injector.resolve("compiledReleaseService"); @@ -131,6 +141,7 @@ describe("compiledReleaseService", () => { xcodebuildCalls = []; writtenFiles = {}; warnings = []; + hookCalls = []; }); afterEach(() => { @@ -346,6 +357,26 @@ describe("compiledReleaseService", () => { ); }); + it("runs the project's build hooks around the compiled build, with the compiled project as projectRoot", async () => { + const service = createService(); + + await service.build( + platformData("iOS"), + createProjectData(), + { release: true, buildForDevice: false }, + ); + + assert.deepStrictEqual( + hookCalls.map((c) => c.name), + ["before-buildIOS", "after-buildIOS"], + ); + assert.equal( + hookCalls[0].args.hookArgs.projectRoot, + path.join(projectDir, "platforms", "compiled", "ios"), + ); + assert.equal(hookCalls[0].args.hookArgs.buildData.release, true); + }); + it("archives an unsigned device build without --team-id or --provision", async () => { const service = createService();