diff --git a/.ado/templates/react-native-macos-init.yml b/.ado/templates/react-native-macos-init.yml index a880a34ac60942..73a00785bb1564 100644 --- a/.ado/templates/react-native-macos-init.yml +++ b/.ado/templates/react-native-macos-init.yml @@ -87,7 +87,7 @@ steps: - task: CmdLine@2 displayName: Init new project inputs: - script: react-native init testcli + script: npx react-native init testcli workingDirectory: $(Agent.BuildDirectory) - task: CmdLine@2 @@ -96,4 +96,8 @@ steps: script: npx react-native-macos-init --version latest --overwrite --prerelease workingDirectory: $(Agent.BuildDirectory)/testcli - # TODO: react-native run-macos and test when implemented \ No newline at end of file + - task: CmdLine@2 + displayName: Run macos + inputs: + script: npx react-native run-macos + workingDirectory: $(Agent.BuildDirectory)/testcli diff --git a/local-cli/generator-macos/index.js b/local-cli/generator-macos/index.js index 7b406dd2b81d01..de97222c78db09 100644 --- a/local-cli/generator-macos/index.js +++ b/local-cli/generator-macos/index.js @@ -59,10 +59,14 @@ function copyProjectTemplateAndReplace( { from: path.join(srcRootPath, 'metro.config.macos.js'), to: 'metro.config.macos.js' }, ].forEach((mapping) => copyAndReplaceWithChangedCallback(mapping.from, destPath, mapping.to, templateVars, options.overwrite)); - console.log(chalk.white.bold('To run your app on macOS:')); - console.log(chalk.white(` open ${macOSDir}/${xcodeProjName}`)); - console.log(chalk.white(' yarn start:macos')); - console.log(chalk.white.bold(`In Xcode switch to the ${projectNameMacOS} scheme then click Run.`)); + console.log(` + ${chalk.blue(`Run instructions for ${chalk.bold('macOS')}`)}: + • npx react-native run-macos + ${chalk.dim('- or -')} + • Open ${macOSDir}/${xcodeProjName} in Xcode or run "xed -b ${macOSDir}" + • yarn start:macos + • Hit the Run button +`); } function installDependencies(options) { diff --git a/local-cli/generator-macos/templates/macos/HelloWorld.xcodeproj/project.pbxproj b/local-cli/generator-macos/templates/macos/HelloWorld.xcodeproj/project.pbxproj index c18882a55b02b8..67304bf2f9abf4 100644 --- a/local-cli/generator-macos/templates/macos/HelloWorld.xcodeproj/project.pbxproj +++ b/local-cli/generator-macos/templates/macos/HelloWorld.xcodeproj/project.pbxproj @@ -1299,7 +1299,7 @@ "-ObjC", "-lc++", ); - PRODUCT_BUNDLE_IDENTIFIER = "org.reactjs.native.HelloWorld.$(PRODUCT_NAME:rfc1034identifier)"; + PRODUCT_BUNDLE_IDENTIFIER = "org.reactjs.native.$(PRODUCT_NAME:rfc1034identifier)"; PRODUCT_NAME = HelloWorld; VERSIONING_SYSTEM = "apple-generic"; }; @@ -1317,7 +1317,7 @@ "-ObjC", "-lc++", ); - PRODUCT_BUNDLE_IDENTIFIER = "org.reactjs.native.HelloWorld.$(PRODUCT_NAME:rfc1034identifier)"; + PRODUCT_BUNDLE_IDENTIFIER = "org.reactjs.native.$(PRODUCT_NAME:rfc1034identifier)"; PRODUCT_NAME = HelloWorld; VERSIONING_SYSTEM = "apple-generic"; }; @@ -1337,7 +1337,7 @@ "-ObjC", "-lc++", ); - PRODUCT_BUNDLE_IDENTIFIER = "org.reactjs.native.HelloWorld.$(PRODUCT_NAME:rfc1034identifier)"; + PRODUCT_BUNDLE_IDENTIFIER = "org.reactjs.native.$(PRODUCT_NAME:rfc1034identifier)"; PRODUCT_NAME = HelloWorld; SDKROOT = macosx; TARGETED_DEVICE_FAMILY = 1; @@ -1358,7 +1358,7 @@ "-ObjC", "-lc++", ); - PRODUCT_BUNDLE_IDENTIFIER = "org.reactjs.native.HelloWorld.$(PRODUCT_NAME:rfc1034identifier)"; + PRODUCT_BUNDLE_IDENTIFIER = "org.reactjs.native.$(PRODUCT_NAME:rfc1034identifier)"; PRODUCT_NAME = HelloWorld; SDKROOT = macosx; TARGETED_DEVICE_FAMILY = 1; diff --git a/local-cli/runMacOS/findXcodeProject.js b/local-cli/runMacOS/findXcodeProject.js new file mode 100644 index 00000000000000..8f0e3162e3f776 --- /dev/null +++ b/local-cli/runMacOS/findXcodeProject.js @@ -0,0 +1,37 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + * @format + * @ts-check + */ +'use strict'; + +const path = require('path'); + +/** + * @param {string[]} files + */ +function findXcodeProject(files) { + const sortedFiles = files.sort(); + for (let i = sortedFiles.length - 1; i >= 0; i--) { + const fileName = files[i]; + const ext = path.extname(fileName); + + if (ext === '.xcworkspace') { + return { + name: fileName, + isWorkspace: true, + }; + } + if (ext === '.xcodeproj') { + return { + name: fileName, + isWorkspace: false, + }; + } + } + + return null; +} + +module.exports = findXcodeProject; diff --git a/local-cli/runMacOS/runMacOS.js b/local-cli/runMacOS/runMacOS.js new file mode 100644 index 00000000000000..eded6de85f7c87 --- /dev/null +++ b/local-cli/runMacOS/runMacOS.js @@ -0,0 +1,318 @@ +/** + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + * @format + * @ts-check + */ +'use strict'; + +const child_process = require('child_process'); +const fs = require('fs'); +const path = require('path'); +const chalk = require('chalk'); +const findXcodeProject = require('./findXcodeProject'); +const { + logger, + CLIError, + getDefaultUserTerminal, +} = require('@react-native-community/cli-tools'); + +/** + * @param {string[]} _ + * @param {Object.} ctx + * @param {{configuration: string, scheme?: string, projectPath: string, packager: boolean, verbose: boolean, port: number, terminal: string | undefined}} args + */ +function runMacOS(_, ctx, args) { + if (!fs.existsSync(args.projectPath)) { + throw new CLIError( + 'macOS project folder not found. Are you sure this is a React Native project?', + ); + } + + process.chdir(args.projectPath); + + const xcodeProject = findXcodeProject(fs.readdirSync('.')); + if (!xcodeProject) { + throw new CLIError( + `Could not find Xcode project files in "${args.projectPath}" folder`, + ); + } + + const inferredSchemeName = + path.basename(xcodeProject.name, path.extname(xcodeProject.name)) + + '-macOS'; + const scheme = args.scheme || inferredSchemeName; + + logger.info( + `Found Xcode ${ + xcodeProject.isWorkspace ? 'workspace' : 'project' + } "${chalk.bold(xcodeProject.name)}"`, + ); + + return run(xcodeProject, scheme, args); +} + +/** + * @param {{name: string, isWorkspace: boolean}} xcodeProject + * @param {string} scheme + * @param {{configuration: string, scheme?: string, projectPath: string, packager: boolean, verbose: boolean, port: number, terminal: string | undefined}} args + */ +async function run(xcodeProject, scheme, args) { + const appName = await buildProject(xcodeProject, scheme, args); + + const appPath = getBuildPath( + xcodeProject, + args.configuration, + appName, + scheme, + ); + + const bundleID = child_process + .execFileSync( + '/usr/libexec/PlistBuddy', + [ + '-c', + 'Print:CFBundleIdentifier', + path.join(appPath, 'Contents/Info.plist'), + ], + {encoding: 'utf8'}, + ) + .trim(); + + logger.info( + `Launching app "${chalk.bold(bundleID)}" from "${chalk.bold(appPath)}"`, + ); + + child_process.exec( + 'open -b ' + bundleID + ' -a ' + appPath, + (error, stdout, stderr) => { + if (error) { + logger.error('Failed to launch the app', stderr); + } else { + logger.success('Successfully launched the app'); + } + }, + ); +} + +/** + * @param {{name: string, isWorkspace: boolean}} xcodeProject + * @param {string} scheme + * @param {{configuration: string, scheme?: string, projectPath: string, packager: boolean, verbose: boolean, port: number, terminal: string | undefined}} args + */ +function buildProject(xcodeProject, scheme, args) { + return new Promise((resolve, reject) => { + const xcodebuildArgs = [ + xcodeProject.isWorkspace ? '-workspace' : '-project', + xcodeProject.name, + '-configuration', + args.configuration, + '-scheme', + scheme, + '-UseModernBuildSystem=NO', + ]; + logger.info( + `Building ${chalk.dim( + `(using "xcodebuild ${xcodebuildArgs.join(' ')}")`, + )}`, + ); + let xcpretty; + if (!args.verbose) { + xcpretty = + xcprettyAvailable() && + child_process.spawn('xcpretty', [], { + stdio: ['pipe', process.stdout, process.stderr], + }); + } + const buildProcess = child_process.spawn( + 'xcodebuild', + xcodebuildArgs, + getProcessOptions(args), + ); + let buildOutput = ''; + let errorOutput = ''; + buildProcess.stdout.on('data', data => { + const stringData = data.toString(); + buildOutput += stringData; + if (xcpretty) { + xcpretty.stdin.write(data); + } else { + if (logger.isVerbose()) { + logger.debug(stringData); + } else { + process.stdout.write('.'); + } + } + }); + buildProcess.stderr.on('data', data => { + errorOutput += data; + }); + buildProcess.on('close', code => { + if (xcpretty) { + xcpretty.stdin.end(); + } else { + process.stdout.write('\n'); + } + if (code !== 0) { + reject( + new CLIError( + ` + Failed to build macOS project. + + We ran "xcodebuild" command but it exited with error code ${code}. To debug build + logs further, consider building your app with Xcode.app, by opening + ${xcodeProject.name}. + `, + buildOutput + '\n' + errorOutput, + ), + ); + return; + } + resolve(getProductName(buildOutput) || scheme); + }); + }); +} + +/** + * @param {string} buildSettings + */ +function getTargetBuildDir(buildSettings) { + const settings = JSON.parse(buildSettings); + + // Find app in all building settings - look for WRAPPER_EXTENSION: 'app', + for (const i in settings) { + const wrapperExtension = settings[i].buildSettings.WRAPPER_EXTENSION; + if (wrapperExtension === 'app') { + return settings[i].buildSettings.TARGET_BUILD_DIR; + } + } + + return null; +} + +/** + * @param {{name: string, isWorkspace: boolean}} xcodeProject + * @param {string} configuration + * @param {string} appName + * @param {string} scheme + */ +function getBuildPath(xcodeProject, configuration, appName, scheme) { + const buildSettings = child_process.execFileSync( + 'xcodebuild', + [ + xcodeProject.isWorkspace ? '-workspace' : '-project', + xcodeProject.name, + '-scheme', + scheme, + '-sdk', + 'macosx', + '-configuration', + configuration, + '-showBuildSettings', + '-json', + ], + {encoding: 'utf8'}, + ); + const targetBuildDir = getTargetBuildDir(buildSettings); + if (!targetBuildDir) { + throw new CLIError('Failed to get the target build directory.'); + } + + return `${targetBuildDir}/${appName}.app`; +} + +/** + * @param {string} buildOutput + */ +function getProductName(buildOutput) { + const productNameMatch = /export FULL_PRODUCT_NAME="?(.+).app"?$/m.exec( + buildOutput, + ); + return productNameMatch ? productNameMatch[1] : null; +} + +function xcprettyAvailable() { + try { + child_process.execSync('xcpretty --version', { + stdio: [0, 'pipe', 'ignore'], + }); + } catch (error) { + return false; + } + return true; +} + +/** + * @param {Object} args + * @param {boolean} args.packager + * @param {string|undefined} args.terminal + * @param {number} args.port + */ +function getProcessOptions({packager, terminal, port}) { + if (packager) { + return { + env: { + ...process.env, + RCT_TERMINAL: terminal, + RCT_METRO_PORT: port.toString(), + }, + }; + } + + return { + env: { + ...process.env, + RCT_TERMINAL: terminal, + RCT_NO_LAUNCH_PACKAGER: 'true', + }, + }; +} + +module.exports = { + name: 'run-macos', + description: 'builds your app and starts it', + func: runMacOS, + examples: [ + { + desc: 'Run the macOS app', + cmd: 'react-native run-macos', + }, + ], + options: [ + { + name: '--configuration [string]', + description: 'Explicitly set the scheme configuration to use', + default: 'Debug', + }, + { + name: '--scheme [string]', + description: 'Explicitly set Xcode scheme to use', + }, + { + name: '--project-path [string]', + description: + 'Path relative to project root where the Xcode project ' + + '(.xcodeproj) lives.', + default: 'macos', + }, + { + name: '--no-packager', + description: 'Do not launch packager while building', + }, + { + name: '--verbose', + description: 'Do not use xcpretty even if installed', + }, + { + name: '--port [number]', + default: process.env.RCT_METRO_PORT || 8081, + parse: val => Number(val), + }, + { + name: '--terminal [string]', + description: + 'Launches the Metro Bundler in a new window using the specified terminal path.', + default: getDefaultUserTerminal, + }, + ], +}; diff --git a/react-native.config.js b/react-native.config.js index 5d0dd83bc72f6a..8c1195d3f1d666 100644 --- a/react-native.config.js +++ b/react-native.config.js @@ -16,9 +16,10 @@ const path = require('path'); const isReactNativeMacOS = path.basename(__dirname) === 'react-native-macos'; const iosCommands = isReactNativeMacOS ? [] : ios.commands; const androidCommands = isReactNativeMacOS ? [] : android.commands; +const macosCommands = [require('./local-cli/runMacOS/runMacOS')]; module.exports = { - commands: [...iosCommands, ...androidCommands], + commands: [...iosCommands, ...androidCommands, ...macosCommands], platforms: { ios: { linkConfig: ios.linkConfig, diff --git a/scripts/packager.sh b/scripts/packager.sh index 1df530fe407a58..b688d53b5abcc8 100755 --- a/scripts/packager.sh +++ b/scripts/packager.sh @@ -18,10 +18,13 @@ source "${THIS_DIR}/.packager.env" source "${THIS_DIR}/node-binary.sh" # When running react-native tests, react-native doesn't live in node_modules but in the PROJECT_ROOT -if [ ! -d "$PROJECT_ROOT/node_modules/react-native" ]; +EXTRA_ARGS= +if [ ! -d "$PROJECT_ROOT/node_modules/react-native-macos" ]; then PROJECT_ROOT="$THIS_DIR/.." +else + EXTRA_ARGS=--use-react-native-macos fi # Start packager from PROJECT_ROOT cd "$PROJECT_ROOT" || exit -"$NODE_BINARY" "$REACT_NATIVE_ROOT/cli.js" start "$@" +"$NODE_BINARY" "$REACT_NATIVE_ROOT/cli.js" start "$@" "$EXTRA_ARGS"