From 9744fa92833ad2fda5d3342b70ffe7d5de2f6e6a Mon Sep 17 00:00:00 2001 From: Blake Friedman Date: Thu, 6 Jun 2024 07:06:29 -0700 Subject: [PATCH] cli support to bundle, build & upload (#44722) Summary: Pull Request resolved: https://github.com/facebook/react-native/pull/44722 Add support for bundling, building and uploading on iOS. I've verified these locally and will enable on CircleCI to validate. Changelog: [Internal] Reviewed By: cipolleschi Differential Revision: D57915365 fbshipit-source-id: 1e73918b31f70d337de4d3aee934c8acf88c86d0 --- .flowconfig | 3 + packages/helloworld/cli.flow.js | 318 +++++++++++++++++--------- packages/helloworld/lib/cli.js | 2 +- packages/helloworld/lib/filesystem.js | 11 + packages/helloworld/lib/ios.js | 12 +- packages/helloworld/package.json | 2 + 6 files changed, 235 insertions(+), 113 deletions(-) diff --git a/.flowconfig b/.flowconfig index 6480cfcecff..b0420c2bcce 100644 --- a/.flowconfig +++ b/.flowconfig @@ -21,6 +21,9 @@ ; Generated build output /packages/.*/dist +; helloworld +/packages/helloworld/ios/Pods/ + [untyped] .*/node_modules/@react-native-community/cli/.*/.* diff --git a/packages/helloworld/cli.flow.js b/packages/helloworld/cli.flow.js index e3b1ccfaf62..7888e9eef94 100644 --- a/packages/helloworld/cli.flow.js +++ b/packages/helloworld/cli.flow.js @@ -9,28 +9,24 @@ * @oncall react_native */ -import type {IOSDevice} from './lib/ios'; - import {run} from './lib/cli'; -import {pauseWatchman} from './lib/filesystem'; +import {getExistingPath, pauseWatchman} from './lib/filesystem'; import { bootSimulator, getSimulatorDetails, getXcodeBuildSettings, + hasPodInstalled, launchApp, launchSimulator, } from './lib/ios'; import {app, apple} from '@react-native/core-cli-utils'; -import chalk from 'chalk'; -import {program} from 'commander'; +import {Option, program} from 'commander'; import {readFileSync} from 'fs'; import {Listr} from 'listr2'; import path from 'path'; program.version(JSON.parse(readFileSync('./package.json', 'utf8')).version); -const FIRST = 1; - const bootstrap = program.command('bootstrap'); const cwd = { @@ -39,105 +35,188 @@ const cwd = { root: __dirname, }; +const possibleHermescPaths = [ + // OSS checkout + path.join(cwd.ios, 'Pods/hermes-engine/destroot/bin/hermesc'), + // internal + path.join(cwd.ios, 'Pods/hermes-engine/build_host_hermesc/bin/hermesc'), +]; + +type BootstrapOptions = { + arch: 'old' | 'new', + jsvm: 'hermes' | 'jsc', + frameworks?: 'static' | 'dynamic', +}; + bootstrap .command('ios') .description('Bootstrap iOS') - .option('--hermes', 'Enable Hermes', true) - .option('--new-architecture', 'Enable new architecture', true) - .action(async (_, options: {newArchitecture: boolean, hermes: boolean}) => { + .addOption( + new Option('--arch ', "Choose React Native's architecture") + .choices(['new', 'old']) + .default('new'), + ) + .addOption( + new Option( + '--frameworks ', + 'Use frameworks instead of static libraries', + ) + .choices(['static', 'dynamic']) + .default(undefined), + ) + .addOption( + new Option('--jsvm ', 'Choose VM used on device') + .choices(['jsc', 'hermes']) + .default('hermes'), + ) + .action(async ({jsvm, arch, frameworks}: BootstrapOptions) => { await run( apple.bootstrap({ cwd: cwd.ios, - hermes: options.hermes, - newArchitecture: options.newArchitecture, + frameworks, + hermes: jsvm === 'hermes', + newArchitecture: arch === 'new', }), ); }); -const build = program.command('build'); - type BuildOptions = { - newArchitecture: boolean, - hermes: boolean, - onlyBuild: boolean, device: string, + arch: 'old' | 'new', + prod: boolean, }; +const optionalBool = (value: string | void) => value?.toLowerCase() === 'true'; + +type BuildSettings = { + appPath: string, + bundleId: string, + bundleBuildDir: string, + bundleResourceDir: string, +}; + +const getBuildSettings = (mode: 'Debug' | 'Release'): BuildSettings => { + const xcode = getXcodeBuildSettings(cwd.ios, mode)[0].buildSettings; + return { + appPath: path.join(xcode.TARGET_BUILD_DIR, xcode.EXECUTABLE_FOLDER_PATH), + bundleId: xcode.PRODUCT_BUNDLE_IDENTIFIER, + bundleBuildDir: xcode.CONFIGURATION_BUILD_DIR, + bundleResourceDir: path.join( + xcode.CONFIGURATION_BUILD_DIR, + xcode.UNLOCALIZED_RESOURCES_FOLDER_PATH, + ), + }; +}; + +const build = program.command('build'); + build .command('ios') - .description('Builds & run your app for iOS') - .option('--new-architecture', 'Enable new architecture') - .option('--hermes', 'Use Hermes or point to a prebuilt tarball', true) - .option('--only-build', 'Build but do not run', false) - .option('--device', 'Any simulator or a specific device', 'simulator') - .action(async (options: BuildOptions) => { - let device: IOSDevice; - try { - device = await getSimulatorDetails(options.device); - } catch (e) { - console.log(chalk.bold.red(e.message)); - process.exit(1); - } + .description('Builds your app for iOS') + .option( + '--device', + 'Any simulator or a specific device (choices: "simulator", "device", other)', + 'simulator', + ) + .option('--prod', 'Production build', () => true, false) + .action(async ({device: _device, prod}: BuildOptions) => { + const mode = prod ? 'Release' : 'Debug'; - if (device == null) { - return; + let destination = 'simulator'; + switch (_device) { + case 'simulator': + break; + case 'device': + const device = await getSimulatorDetails(_device); + destination = `id=${device.udid}`; + break; + default: + destination = _device; + break; } - const settings = { - appPath: '', - bundleId: '', - bundleBuildDir: '', - bundleResourceDir: '', - }; - await pauseWatchman(async () => { - await run({ - buildSettings: { - order: FIRST, - label: 'Getting your build settings', - action: (): void => { - const xcode = getXcodeBuildSettings(cwd.ios)[0].buildSettings; - settings.appPath = path.join( - xcode.TARGET_BUILD_DIR, - xcode.EXECUTABLE_FOLDER_PATH, - ); - settings.bundleId = xcode.PRODUCT_BUNDLE_IDENTIFIER; - settings.bundleBuildDir = xcode.CONFIGURATION_BUILD_DIR; - settings.bundleResourceDir = path.join( - xcode.CONFIGURATION_BUILD_DIR, - xcode.UNLOCALIZED_RESOURCES_FOLDER_PATH, - ); - }, - }, - }); - - // Metro: src -> js - const jsBundlePath = path.join( - settings.bundleBuildDir, - 'main.jsbundle.js', - ); - // Hermes: js -> Hermes Byte Code - const binaryBundlePath = path.join( - settings.bundleResourceDir, - 'main.jsbundle', - ); - await run( apple.build({ isWorkspace: true, name: 'HelloWorld.xcworkspace', - mode: 'Debug', + mode, scheme: 'HelloWorld', cwd: cwd.ios, env: { - FORCE_BUNDLING: 'true', + SKIP_BUNDLING: 'true', }, - destination: `id=${device.udid}`, + destination, }), ); + }); + }); - await run( - app.bundle({ +type BundleOptions = { + prod: boolean, + watch: boolean, +}; + +const bundle = program.command('bundle'); + +bundle + .command('ios') + .option('--watch', 'Watch and update JS changes', optionalBool, false) + .option('--prod', 'Production build', () => true, false) + .action(async ({prod, watch}: BundleOptions) => { + const mode = prod ? 'Release' : 'Debug'; + + const isHermesInstalled = hasPodInstalled(cwd.ios, 'hermes-engine'); + const settings = await getBuildSettings(mode); + + // Metro: src -> js + const jsBundlePath = path.join(settings.bundleBuildDir, 'main.jsbundle.js'); + // Hermes: js -> Hermes Byte Code + const binaryBundlePath = path.join( + settings.bundleResourceDir, + 'main.jsbundle', + ); + + // Validate only after initial build, as hermesc may not be prebuilt + const hermesc = getExistingPath(possibleHermescPaths); + + if (hermesc == null) { + throw new Error( + `Unable to find hermesc at:\n-${possibleHermescPaths + .map(line => ' - ' + line) + .join('\n')}`, + ); + } + + const bundler = watch + ? app.bundle({ + mode: 'watch', + cwd: cwd.root, + entryFile: 'index.js', + platform: 'ios', + outputJsBundle: jsBundlePath, + minify: false, + optimize: false, + outputSourceMap: settings.bundleResourceDir, + outputBundle: binaryBundlePath, + dev: !prod, + jsvm: isHermesInstalled ? 'hermes' : 'jsc', + hermes: { + path: path.join(cwd.ios, 'Pods', 'hermes-engine'), + hermesc: 'build_host_hermesc/bin/hermesc', + }, + callback: metroProcess => { + const readline = require('readline'); + readline.emitKeypressEvents(process.stdin); + process.stdout.write('Press any key to close Metro...'); + // $FlowFixMe[prop-missing] + process.stdin.setRawMode(true); + process.stdin.once('keypress', () => { + metroProcess.kill('SIGTERM'); + }); + }, + }) + : app.bundle({ mode: 'bundle', cwd: cwd.root, entryFile: 'index.js', @@ -147,49 +226,66 @@ build optimize: false, outputSourceMap: settings.bundleResourceDir, outputBundle: binaryBundlePath, - dev: true, - jsvm: 'hermes', + dev: !prod, + jsvm: isHermesInstalled ? 'hermes' : 'jsc', hermes: { path: path.join(cwd.ios, 'Pods/hermes-engine'), hermesc: 'build_host_hermesc/bin/hermesc', }, - }), - ); + }); + + // JS Bundle + await run(bundler); + }); + +type ShipOptions = { + prod: boolean, + device: string, +}; + +const installAndRun = program.command('install-and-run'); + +installAndRun + .command('ios') + .option('--prod', 'Production build') + .option('--device', 'Any simulator or a specific device', 'simulator') + .action(async ({device: _device, prod}: ShipOptions) => { + const [device, settings] = await Promise.all([ + getSimulatorDetails(_device), + getBuildSettings(prod ? 'Release' : 'Debug'), + ]); + + const {install} = apple.ios.install({ + cwd: cwd.ios, + device: device.udid, + appPath: settings.appPath, + bundleId: settings.bundleId, }); - if (!options.onlyBuild) { - const {install} = apple.ios.install({ - cwd: cwd.ios, - device: device.udid, - appPath: settings.appPath, - bundleId: settings.bundleId, - }); - - await new Listr([ - { - title: 'Booting simulator', - task: (_: mixed, task) => { - if (device.state === 'Booted') { - task.skip('Simulator currently Booted'); - } else { - return bootSimulator(device); - } - }, + await new Listr([ + { + title: 'Booting simulator', + task: (_: mixed, task) => { + if (device.state === 'Booted') { + task.skip('Simulator currently Booted'); + } else { + return bootSimulator(device); + } }, - { - title: 'Launching simulator', - task: () => launchSimulator(device), - }, - { - title: 'Installing app on simulator', - task: () => install.action(), - }, - { - title: 'Launching app on simulator', - task: () => launchApp(device.udid, settings.bundleId), - }, - ]).run(); - } + }, + { + title: 'Launching simulator', + task: () => launchSimulator(device), + }, + { + title: 'Installing app on simulator', + task: () => install.action(), + }, + { + title: 'Launching app on simulator', + task: () => launchApp(device.udid, settings.bundleId), + }, + ]).run(); }); if (require.main === module) { diff --git a/packages/helloworld/lib/cli.js b/packages/helloworld/lib/cli.js index 722f09b0c6d..e942981f83b 100644 --- a/packages/helloworld/lib/cli.js +++ b/packages/helloworld/lib/cli.js @@ -34,7 +34,7 @@ export function trim( type ExecaPromiseMetaized = Promise & child_process$ChildProcess; -export function observe(result: ExecaPromiseMetaized): TaskResult { +export function observe(result: ExecaPromiseMetaized): TaskResult<{}, string> { const obs = new Observable(observer => { result.stderr.on('data', (data: Buffer) => data diff --git a/packages/helloworld/lib/filesystem.js b/packages/helloworld/lib/filesystem.js index 5833dee2e6a..1f1e9cf2b57 100644 --- a/packages/helloworld/lib/filesystem.js +++ b/packages/helloworld/lib/filesystem.js @@ -11,6 +11,7 @@ import {execSync, spawn} from 'child_process'; import debug from 'debug'; +import {existsSync} from 'fs'; const logWatchman = debug('helloworld:cli:watchman'); @@ -19,6 +20,7 @@ export async function pauseWatchman(command: () => Promise) { try { const raw: string = execSync('watchman watch-project .', { cwd: process.cwd(), + stdio: 'pipe', }).toString(); const {watch} = JSON.parse(raw); @@ -47,3 +49,12 @@ export async function pauseWatchman(command: () => Promise) { } } } + +export function getExistingPath(paths: $ReadOnlyArray): string | null { + for (const path of paths) { + if (existsSync(path)) { + return path; + } + } + return null; +} diff --git a/packages/helloworld/lib/ios.js b/packages/helloworld/lib/ios.js index f4c697062b5..c966c1f98bd 100644 --- a/packages/helloworld/lib/ios.js +++ b/packages/helloworld/lib/ios.js @@ -13,6 +13,8 @@ import type {XcodeBuildSettings} from './xcode'; import type {Result} from 'execa'; import execa from 'execa'; +import fs from 'fs'; +import path from 'path'; export type IOSDevice = { lastBootedAt: Date, @@ -127,6 +129,7 @@ export async function launchApp( export function getXcodeBuildSettings( iosProjectFolder: string, + mode: 'Release' | 'Debug', ): XcodeBuildSettings[] { const {stdout} = execa.sync( 'xcodebuild', @@ -136,7 +139,7 @@ export function getXcodeBuildSettings( '-scheme', 'HelloWorld', '-configuration', - 'Debug', + mode, '-sdk', 'iphonesimulator', '-showBuildSettings', @@ -146,3 +149,10 @@ export function getXcodeBuildSettings( ); return JSON.parse(stdout); } + +export function hasPodInstalled(iosPath: string, pkg: string): boolean { + const containsPackage = new RegExp(`\n - ${pkg} \\(.+\\):?\n`); + return containsPackage.test( + fs.readFileSync(path.join(iosPath, 'Podfile.lock'), 'utf8'), + ); +} diff --git a/packages/helloworld/package.json b/packages/helloworld/package.json index cf062ca20ef..766c765fcd7 100644 --- a/packages/helloworld/package.json +++ b/packages/helloworld/package.json @@ -5,6 +5,8 @@ "scripts": { "bootstrap": "node ./cli.js bootstrap", "build": "node ./cli.js build", + "bundle": "node ./cli.js bundle ", + "install-and-run": "node ./cli.js install-and-run", "launch": "node ./scripts/metro.js", "lint": "eslint .", "test": "jest"