Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

build and install an iOS app #44465

Closed
wants to merge 6 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/core-cli-utils/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"version": "0.75.0-main",
"description": "React Native CLI library for Frameworks to build on",
"license": "MIT",
"main": "./src/index.js",
"main": "./src/index.flow.js",
"repository": {
"type": "git",
"url": "git+https://github.com/facebook/react-native.git",
Expand Down
2 changes: 2 additions & 0 deletions packages/core-cli-utils/src/index.flow.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,13 @@
// @babel/register doesn't like export {foo} from './bar'; statements,
// so we have to jump through hoops here.
import {tasks as _android} from './private/android.js';
import {tasks as _app} from './private/app.js';
import {tasks as _apple} from './private/apple.js';
import {tasks as _clean} from './private/clean.js';
import * as _version from './public/version.js';

export const android = _android;
export const app = _app;
export const apple = _apple;
export const clean = _clean;
export const version = _version;
Expand Down
238 changes: 238 additions & 0 deletions packages/core-cli-utils/src/private/app.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,238 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow strict-local
* @format
* @oncall react_native
*/

import type {Task} from './types';
import type {ExecaPromise} from 'execa';

import {task} from './utils';
import debug from 'debug';
import execa from 'execa';
import fs from 'fs';
import path from 'path';

const log = debug('core-cli-utils');

type BundlerOptions = {
// Metro's config: https://metrobundler.dev/docs/configuration/
config?: string,
// Typically index.{ios,android}.js
entryFile: string,
+platform: 'ios' | 'android' | string,
dev: boolean,
// Metro built main bundle
outputJsBundle: string,
minify: boolean,
optimize: boolean,
// Generate a source map file
outputSourceMap: string,
// Where to pass the final bundle. Typically this is the App's resource
// folder, however this is app specific. React Native will need to know where
// this is to bootstrap your application. See:
// - Android: https://reactnative.dev/docs/integration-with-existing-apps?language=kotlin#creating-a-release-build-in-android-studio
// - iOS: https://reactnative.dev/docs/integration-with-existing-apps?language=swift#2-event-handler
outputBundle: string,
cwd: string,

target: 'hermes' | 'jsc',
hermes?: HermesConfig,

...Bundler,
};

type HermesConfig = {
// iOS: Pods/hermes-engine/destroot/bin/hermesc
hermesc: string,
};

type BundlerWatch = {
+mode: 'watch',
callback?: (metro: ExecaPromise) => void,
};

type BundlerBuild = {
+mode: 'bundle',
};

type Bundler = BundlerWatch | BundlerBuild;

const FIRST = 1,
SECOND = 2,
THIRD = 3,
FOURTH = 4;

function getNodePackagePath(packageName: string): string {
// $FlowIgnore[prop-missing] type definition is incomplete
return require.resolve(packageName, {cwd: [process.cwd(), ...module.paths]});
}

function metro(...args: $ReadOnlyArray<string>): ExecaPromise {
log(
`🚇 ${getNodePackagePath(path.join('metro', 'src', 'cli.js'))} ${args.join(' ')} `,
);
return execa('node', [
getNodePackagePath(path.join('metro', 'src', 'cli.js')),
...args,
]);
}

export const tasks = {
bundle: (
options: BundlerOptions,
...args: $ReadOnlyArray<string>
): Bundle => {
const steps: Bundle = {
/* eslint-disable sort-keys */
validate: task(FIRST, 'Check if Metro is available', () => {
try {
require('metro');
} catch {
throw new Error('Metro is not available');
}
}),
javascript: task(SECOND, 'Metro watching for changes', () =>
metro('serve', ...args),
),
};

return options.mode === 'bundle'
? Object.assign(steps, bundleApp(options, ...args))
: steps;
},
};

type Bundle = {
validate?: Task<void>,
javascript: Task<ExecaPromise>,
sourcemap?: Task<void>,
validateHermesc?: Task<ExecaPromise>,
convert?: Task<ExecaPromise>,
compose?: Task<ExecaPromise>,
};

const bundleApp = (
options: BundlerOptions,
...metroArgs: $ReadOnlyArray<string>
) => {
if (options.outputJsBundle === options.outputBundle) {
throw new Error('outputJsBundle and outputBundle cannot be the same.');
}
// When using Hermes, Metro should generate the JS bundle to an intermediate file
// to then be converted to bytecode in the outputBundle. Otherwise just write to
// the outputBundle directly.
let output =
options.target === 'hermes' ? options.outputJsBundle : options.outputBundle;

// TODO: Fix this by not using Metro CLI, which appends a .js extension
if (output === options.outputJsBundle && !output.endsWith('.js')) {
log(
`Appending .js to outputBundle (because metro cli does if it's missing): ${output}`,
);
output += '.js';
}

const isSourceMaps = options.outputSourceMap != null;
const bundle: Bundle = {
javascript: task(SECOND, 'Metro generating an .jsbundle', () => {
const args = [
'--platform',
options.platform,
'--dev',
options.dev ? 'true' : 'false',
'--reset-cache',
'--out',
output,
];
if (options.target === 'hermes' && !options.dev) {
// Hermes doesn't require JS minification
args.push('--minify', 'false');
} else {
args.push('--minify', options.minify ? 'true' : 'false');
}
if (isSourceMaps) {
args.push('--source-map');
}
return metro('build', options.entryFile, ...args, ...metroArgs);
}),
};

if (options.target === 'jsc') {
return bundle;
}

// $FlowIgnore[incompatible-use] We know it's a Hermes config
const hermesc: string = options.hermes.hermesc;

/*
* Hermes only tasks:
*/
let composeSourceMaps;
if (isSourceMaps) {
bundle.sourcemap = task(
FIRST,
'Check if SourceMap script available',
() => {
composeSourceMaps = getNodePackagePath(
'react-native/scripts/compose-source-maps.js',
);
},
);
}

bundle.validateHermesc = task(FIRST, 'Check if Hermesc is available', () =>
execa(hermesc, ['--version']),
);

bundle.convert = task(
THIRD,
'Hermesc converting .jsbundle → bytecode',
() => {
const args = [
'-emit-binary',
'-max-diagnostic-width=80',
options.dev === true ? '-Og' : '-O',
];
if (isSourceMaps) {
args.push('-output-source-map');
}
args.push(`-out=${options.outputBundle}`, output);
return execa(hermesc, args, {cwd: options.cwd});
},
);

bundle.compose = task(FOURTH, 'Compose Hermes and Metro source maps', () => {
if (composeSourceMaps == null) {
throw new Error(
'Unable to find the compose-source-map.js script in react-native',
);
}
const metroSourceMap = output.replace(/(\.js)?$/, '.map');
const hermesSourceMap = options.outputBundle + '.map';
const compose = execa(
'node',
[
composeSourceMaps,
metroSourceMap,
hermesSourceMap,
`-o ${options.outputSourceMap}`,
],
{
cwd: options.cwd,
},
);
compose.finally(() => {
fs.rmSync(metroSourceMap, {force: true});
fs.rmSync(hermesSourceMap, {force: true});
});
return compose;
});

return bundle;
};
30 changes: 24 additions & 6 deletions packages/core-cli-utils/src/private/apple.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,11 @@ type AppleBuildOptions = {
};

type AppleBootstrapOption = {
// Enabled by default
// Configure what type of VM and how we'd like it built
// - true: build Hermes from source
// - false: use JSC
// - 'Debug' | 'Release': download a prebuilt copy of Hermes
hermes: boolean,
newArchitecture: boolean,
...AppleOptions,
};
Expand Down Expand Up @@ -75,12 +79,26 @@ export const tasks = {
cwd: options.cwd,
}),
),
installDependencies: task(THIRD, 'Install CocoaPods dependencies', () =>
execa('bundle', ['exec', 'pod', 'install'], {
installDependencies: task(THIRD, 'Install CocoaPods dependencies', () => {
const env = {
RCT_NEW_ARCH_ENABLED: options.newArchitecture ? '1' : '0',
HERMES: '1',
};
if (options.hermes != null) {
switch (typeof options.hermes) {
case 'string':
env.HERMES = '1';
break;
case 'boolean':
env.HERMES = options.hermes ? '1' : '0';
break;
}
}
return execa('bundle', ['exec', 'pod', 'install'], {
cwd: options.cwd,
env: {RCT_NEW_ARCH_ENABLED: options.newArchitecture ? '1' : '0'},
}),
),
env,
});
}),
}),

// 2. Build the iOS app using a setup environment
Expand Down
23 changes: 23 additions & 0 deletions packages/helloworld/__tests__/cli-tests.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow strict-local
* @format
* @oncall react_native
*/

import {execSync} from 'child_process';

describe('helloworld/cli', () => {
it('handles arguments piped as string', () => {
expect(
execSync('echo "pong=hello" | node ./cli.js ping', {
cwd: '../',
encoding: 'utf8',
}),
).toEqual('Donkey Kong!');
});
});