415 lines
14 KiB
Plaintext
415 lines
14 KiB
Plaintext
// Copyright 2015-present 650 Industries. All rights reserved.
|
|
/**
|
|
* @flow
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
// Set EXPO_VIEW_DIR to universe/exponent to test locally
|
|
|
|
import mkdirp from 'mkdirp';
|
|
import fs from 'fs-extra';
|
|
import JsonFile from '@expo/json-file';
|
|
import path from 'path';
|
|
import rimraf from 'rimraf';
|
|
import glob from 'glob-promise';
|
|
import uuid from 'uuid';
|
|
import yesno from 'yesno';
|
|
|
|
import {
|
|
isDirectory,
|
|
parseSdkMajorVersion,
|
|
saveImageToPathAsync,
|
|
rimrafDontThrow,
|
|
} from './ExponentTools';
|
|
|
|
import * as IosPlist from './IosPlist';
|
|
import * as IosNSBundle from './IosNSBundle';
|
|
import * as IosWorkspace from './IosWorkspace';
|
|
import * as AndroidShellApp from './AndroidShellApp';
|
|
import * as OldAndroidDetach from './OldAndroidDetach';
|
|
|
|
import Api from '../Api';
|
|
import ErrorCode from '../ErrorCode';
|
|
import * as ProjectUtils from '../project/ProjectUtils';
|
|
import UserManager from '../User';
|
|
import XDLError from '../XDLError';
|
|
import StandaloneBuildFlags from './StandaloneBuildFlags';
|
|
import StandaloneContext from './StandaloneContext';
|
|
import * as UrlUtils from '../UrlUtils';
|
|
import * as Utils from '../Utils';
|
|
import * as Versions from '../Versions';
|
|
|
|
function yesnoAsync(question) {
|
|
return new Promise(resolve => {
|
|
yesno.ask(question, null, ok => {
|
|
resolve(ok);
|
|
});
|
|
});
|
|
}
|
|
|
|
export async function detachAsync(projectRoot: string, options: any) {
|
|
let user = await UserManager.ensureLoggedInAsync();
|
|
|
|
if (!user) {
|
|
throw new Error('Internal error -- somehow detach is being run in offline mode.');
|
|
}
|
|
|
|
let username = user.username;
|
|
let { exp } = await ProjectUtils.readConfigJsonAsync(projectRoot);
|
|
let experienceName = `@${username}/${exp.slug}`;
|
|
let experienceUrl = `exp://exp.host/${experienceName}`;
|
|
|
|
// Check to make sure project isn't fully detached already
|
|
let hasIosDirectory = isDirectory(path.join(projectRoot, 'ios'));
|
|
let hasAndroidDirectory = isDirectory(path.join(projectRoot, 'android'));
|
|
|
|
if (hasIosDirectory && hasAndroidDirectory) {
|
|
throw new XDLError(
|
|
ErrorCode.DIRECTORY_ALREADY_EXISTS,
|
|
'Error detaching. `ios` and `android` directories already exist.'
|
|
);
|
|
}
|
|
|
|
// Project was already detached on Windows or Linux
|
|
if (!hasIosDirectory && hasAndroidDirectory && process.platform === 'darwin') {
|
|
let response = await yesnoAsync(
|
|
`This will add an Xcode project and leave your existing Android project alone. Enter 'yes' to continue:`
|
|
);
|
|
if (!response) {
|
|
console.log('Exiting...');
|
|
return false;
|
|
}
|
|
}
|
|
|
|
if (hasIosDirectory && !hasAndroidDirectory) {
|
|
throw new Error('`ios` directory already exists. Please remove it and try again.');
|
|
}
|
|
|
|
console.log('Validating project manifest...');
|
|
const configName = await ProjectUtils.configFilenameAsync(projectRoot);
|
|
if (!exp.name) {
|
|
throw new Error(`${configName} is missing \`name\``);
|
|
}
|
|
|
|
if (!exp.android || !exp.android.package) {
|
|
throw new Error(
|
|
`${configName} is missing android.package field. See https://docs.expo.io/versions/latest/guides/configuration.html#package`
|
|
);
|
|
}
|
|
|
|
if (!exp.sdkVersion) {
|
|
throw new Error(`${configName} is missing \`sdkVersion\``);
|
|
}
|
|
|
|
let majorSdkVersion = parseSdkMajorVersion(exp.sdkVersion);
|
|
if (majorSdkVersion < 16) {
|
|
throw new Error(`${configName} must be updated to SDK 16.0.0 or newer to be detached.`);
|
|
}
|
|
|
|
const versions = await Versions.versionsAsync();
|
|
let sdkVersionConfig = versions.sdkVersions[exp.sdkVersion];
|
|
if (
|
|
!sdkVersionConfig ||
|
|
!sdkVersionConfig.androidExpoViewUrl ||
|
|
!sdkVersionConfig.iosExpoViewUrl
|
|
) {
|
|
if (process.env.EXPO_VIEW_DIR) {
|
|
console.warn(
|
|
`Detaching is not supported for SDK ${exp.sdkVersion}; ignoring this because you provided EXPO_VIEW_DIR`
|
|
);
|
|
sdkVersionConfig = {};
|
|
} else {
|
|
throw new Error(`Detaching is not supported for SDK version ${exp.sdkVersion}`);
|
|
}
|
|
}
|
|
|
|
// Modify exp.json
|
|
exp.isDetached = true;
|
|
|
|
if (!exp.detach) {
|
|
exp.detach = {};
|
|
}
|
|
|
|
if (!exp.detach.scheme) {
|
|
let detachedUUID = uuid.v4().replace(/-/g, '');
|
|
exp.detach.scheme = `exp${detachedUUID}`;
|
|
}
|
|
|
|
let expoDirectory = path.join(projectRoot, '.expo-source');
|
|
mkdirp.sync(expoDirectory);
|
|
|
|
const context = StandaloneContext.createUserContext(projectRoot, exp, experienceUrl);
|
|
|
|
// iOS
|
|
let isIosSupported = true;
|
|
if (process.platform !== 'darwin') {
|
|
if (options && options.force) {
|
|
console.warn(
|
|
`You are not running macOS, but have provided the --force option, so we will attempt to generate an iOS project anyway. This might fail.`
|
|
);
|
|
} else {
|
|
console.warn(`Skipping iOS because you are not running macOS.`);
|
|
isIosSupported = false;
|
|
}
|
|
}
|
|
if (!hasIosDirectory && isIosSupported) {
|
|
await detachIOSAsync(context);
|
|
exp = IosWorkspace.addDetachedConfigToExp(exp, context);
|
|
exp.detach.iosExpoViewUrl = sdkVersionConfig.iosExpoViewUrl;
|
|
}
|
|
|
|
// Android
|
|
if (!hasAndroidDirectory) {
|
|
let androidDirectory = path.join(expoDirectory, 'android');
|
|
rimraf.sync(androidDirectory);
|
|
mkdirp.sync(androidDirectory);
|
|
if (Versions.gteSdkVersion(exp, '24.0.0')) {
|
|
await detachAndroidAsync(context, sdkVersionConfig.androidExpoViewUrl);
|
|
} else {
|
|
await OldAndroidDetach.detachAndroidAsync(
|
|
projectRoot,
|
|
androidDirectory,
|
|
exp.sdkVersion,
|
|
experienceUrl,
|
|
exp,
|
|
sdkVersionConfig.androidExpoViewUrl
|
|
);
|
|
}
|
|
exp.detach.androidExpoViewUrl = sdkVersionConfig.androidExpoViewUrl;
|
|
}
|
|
|
|
console.log('Writing ExpoKit configuration...');
|
|
// Update exp.json/app.json
|
|
// if we're writing to app.json, we need to place the configuration under the expo key
|
|
const nameToWrite = await ProjectUtils.configFilenameAsync(projectRoot);
|
|
if (nameToWrite === 'app.json') {
|
|
exp = { expo: exp };
|
|
}
|
|
await fs.writeFile(path.join(projectRoot, nameToWrite), JSON.stringify(exp, null, 2));
|
|
|
|
console.log(
|
|
'Finished detaching your project! Look in the `android` and `ios` directories for the respective native projects. Follow the ExpoKit guide at https://docs.expo.io/versions/latest/guides/expokit.html to get your project running.\n'
|
|
);
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Create a detached Expo iOS app pointing at the given project.
|
|
*/
|
|
async function detachIOSAsync(context: StandaloneContext) {
|
|
await IosWorkspace.createDetachedAsync(context);
|
|
|
|
console.log('Configuring iOS project...');
|
|
await IosNSBundle.configureAsync(context);
|
|
|
|
console.log(`iOS detach is complete!`);
|
|
}
|
|
|
|
async function regexFileAsync(filename, regex, replace) {
|
|
let file = await fs.readFile(filename);
|
|
let fileString = file.toString();
|
|
await fs.writeFile(filename, fileString.replace(regex, replace));
|
|
}
|
|
|
|
async function detachAndroidAsync(context: StandaloneContext, expoViewUrl: string) {
|
|
if (context.type !== 'user') {
|
|
throw new Error(`detachAndroidAsync only supports user standalone contexts`);
|
|
}
|
|
|
|
console.log('Moving Android project files...');
|
|
let androidProjectDirectory = path.join(context.data.projectPath, 'android');
|
|
let tmpExpoDirectory;
|
|
if (process.env.EXPO_VIEW_DIR) {
|
|
// Only for testing
|
|
await AndroidShellApp.copyInitialShellAppFilesAsync(
|
|
path.join(process.env.EXPO_VIEW_DIR, 'android'),
|
|
androidProjectDirectory,
|
|
true
|
|
);
|
|
} else {
|
|
tmpExpoDirectory = path.join(context.data.projectPath, 'temp-android-directory');
|
|
mkdirp.sync(tmpExpoDirectory);
|
|
console.log('Downloading Android code...');
|
|
await Api.downloadAsync(expoViewUrl, tmpExpoDirectory, { extract: true });
|
|
await AndroidShellApp.copyInitialShellAppFilesAsync(
|
|
tmpExpoDirectory,
|
|
androidProjectDirectory,
|
|
true
|
|
);
|
|
}
|
|
|
|
console.log('Updating Android app...');
|
|
await AndroidShellApp.runShellAppModificationsAsync(context, true);
|
|
|
|
// Clean up
|
|
console.log('Cleaning up Android...');
|
|
if (!process.env.EXPO_VIEW_DIR) {
|
|
rimrafDontThrow(tmpExpoDirectory);
|
|
}
|
|
console.log('Android detach is complete!\n');
|
|
}
|
|
|
|
async function ensureBuildConstantsExistsIOSAsync(configFilePath: string) {
|
|
// EXBuildConstants is included in newer ExpoKit projects.
|
|
// create it if it doesn't exist.
|
|
const doesBuildConstantsExist = fs.existsSync(
|
|
path.join(configFilePath, 'EXBuildConstants.plist')
|
|
);
|
|
if (!doesBuildConstantsExist) {
|
|
await IosPlist.createBlankAsync(configFilePath, 'EXBuildConstants');
|
|
console.log('Created `EXBuildConstants.plist` because it did not exist yet');
|
|
}
|
|
}
|
|
|
|
async function _getIosExpoKitVersionThrowErrorAsync(iosProjectDirectory: string) {
|
|
let expoKitVersion = '';
|
|
const podfileLockPath = path.join(iosProjectDirectory, 'Podfile.lock');
|
|
try {
|
|
const podfileLock = await fs.readFile(podfileLockPath, 'utf8');
|
|
const expoKitVersionRegex = /ExpoKit\/Core\W?\(([0-9\.]+)\)/gi;
|
|
let match = expoKitVersionRegex.exec(podfileLock);
|
|
expoKitVersion = match[1];
|
|
} catch (e) {
|
|
throw new Error(
|
|
`Unable to read ExpoKit version from Podfile.lock. Make sure your project depends on ExpoKit. (${e})`
|
|
);
|
|
}
|
|
return expoKitVersion;
|
|
}
|
|
|
|
async function prepareDetachedBuildIosAsync(projectDir: string, args: any) {
|
|
const { exp } = await ProjectUtils.readConfigJsonAsync(projectDir);
|
|
if (exp) {
|
|
return prepareDetachedUserContextIosAsync(projectDir, exp, args);
|
|
} else {
|
|
return prepareDetachedServiceContextIosAsync(projectDir, args);
|
|
}
|
|
}
|
|
|
|
async function prepareDetachedServiceContextIosAsync(projectDir: string, args: any) {
|
|
// service context
|
|
// TODO: very brittle hack: the paths here are hard coded to match the single workspace
|
|
// path generated inside IosShellApp. When we support more than one path, this needs to
|
|
// be smarter.
|
|
const workspaceSourcePath = path.join(projectDir, 'default');
|
|
const buildFlags = StandaloneBuildFlags.createIos('Release', { workspaceSourcePath });
|
|
const context = StandaloneContext.createServiceContext(
|
|
path.join(projectDir, '..', '..'),
|
|
null,
|
|
null,
|
|
null,
|
|
buildFlags,
|
|
null,
|
|
null
|
|
);
|
|
const { iosProjectDirectory, supportingDirectory } = IosWorkspace.getPaths(context);
|
|
const expoKitVersion = await _getIosExpoKitVersionThrowErrorAsync(iosProjectDirectory);
|
|
|
|
// use prod api keys if available
|
|
const prodApiKeys = await _readDefaultApiKeysAsync(
|
|
path.join(context.data.expoSourcePath, '__internal__', 'keys.json')
|
|
);
|
|
|
|
await IosPlist.modifyAsync(supportingDirectory, 'EXBuildConstants', constantsConfig => {
|
|
// verify that we are actually in a service context and not a misconfigured project
|
|
const contextType = constantsConfig.STANDALONE_CONTEXT_TYPE;
|
|
if (contextType !== 'service') {
|
|
throw new Error(
|
|
'Unable to configure a project which has no app.json and also no STANDALONE_CONTEXT_TYPE.'
|
|
);
|
|
}
|
|
constantsConfig.EXPO_RUNTIME_VERSION = expoKitVersion;
|
|
if (prodApiKeys) {
|
|
constantsConfig.DEFAULT_API_KEYS = prodApiKeys;
|
|
}
|
|
return constantsConfig;
|
|
});
|
|
return;
|
|
}
|
|
|
|
async function _readDefaultApiKeysAsync(jsonFilePath: string) {
|
|
if (fs.existsSync(jsonFilePath)) {
|
|
let keys = {};
|
|
const allKeys = await new JsonFile(jsonFilePath).readAsync();
|
|
const validKeys = ['AMPLITUDE_KEY', 'GOOGLE_MAPS_IOS_API_KEY'];
|
|
for (const key in allKeys) {
|
|
if (allKeys.hasOwnProperty(key) && validKeys.includes(key)) {
|
|
keys[key] = allKeys[key];
|
|
}
|
|
}
|
|
return keys;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
async function prepareDetachedUserContextIosAsync(projectDir: string, exp: any, args: any) {
|
|
const context = StandaloneContext.createUserContext(projectDir, exp);
|
|
let { iosProjectDirectory, supportingDirectory } = IosWorkspace.getPaths(context);
|
|
|
|
console.log(`Preparing iOS build at ${iosProjectDirectory}...`);
|
|
// These files cause @providesModule naming collisions
|
|
// but are not available until after `pod install` has run.
|
|
let podsDirectory = path.join(iosProjectDirectory, 'Pods');
|
|
if (!isDirectory(podsDirectory)) {
|
|
throw new Error(`Can't find directory ${podsDirectory}, make sure you've run pod install.`);
|
|
}
|
|
let rnPodDirectory = path.join(podsDirectory, 'React');
|
|
if (isDirectory(rnPodDirectory)) {
|
|
let rnFilesToDelete = await glob(rnPodDirectory + '/**/*.@(js|json)');
|
|
if (rnFilesToDelete) {
|
|
for (let i = 0; i < rnFilesToDelete.length; i++) {
|
|
await fs.unlink(rnFilesToDelete[i]);
|
|
}
|
|
}
|
|
}
|
|
// insert expo development url into iOS config
|
|
if (!args.skipXcodeConfig) {
|
|
// populate EXPO_RUNTIME_VERSION from ExpoKit pod version
|
|
const expoKitVersion = await _getIosExpoKitVersionThrowErrorAsync(iosProjectDirectory);
|
|
|
|
// populate development url
|
|
let devUrl = await UrlUtils.constructManifestUrlAsync(projectDir);
|
|
|
|
// populate default api keys
|
|
const defaultApiKeys = await _readDefaultApiKeysAsync(
|
|
path.join(podsDirectory, 'ExpoKit', 'template-files', 'keys.json')
|
|
);
|
|
|
|
await ensureBuildConstantsExistsIOSAsync(supportingDirectory);
|
|
await IosPlist.modifyAsync(supportingDirectory, 'EXBuildConstants', constantsConfig => {
|
|
constantsConfig.developmentUrl = devUrl;
|
|
constantsConfig.EXPO_RUNTIME_VERSION = expoKitVersion;
|
|
if (defaultApiKeys) {
|
|
constantsConfig.DEFAULT_API_KEYS = defaultApiKeys;
|
|
}
|
|
return constantsConfig;
|
|
});
|
|
}
|
|
}
|
|
|
|
export async function prepareDetachedBuildAsync(projectDir: string, args: any) {
|
|
if (args.platform === 'ios') {
|
|
await prepareDetachedBuildIosAsync(projectDir, args);
|
|
} else {
|
|
let { exp } = await ProjectUtils.readConfigJsonAsync(projectDir);
|
|
let buildConstantsFileName = Versions.gteSdkVersion(exp, '24.0.0')
|
|
? 'DetachBuildConstants.java'
|
|
: 'ExponentBuildConstants.java';
|
|
|
|
let androidProjectDirectory = path.join(projectDir, 'android');
|
|
let expoBuildConstantsMatches = await glob(
|
|
androidProjectDirectory + '/**/' + buildConstantsFileName
|
|
);
|
|
if (expoBuildConstantsMatches && expoBuildConstantsMatches.length) {
|
|
let expoBuildConstants = expoBuildConstantsMatches[0];
|
|
let devUrl = await UrlUtils.constructManifestUrlAsync(projectDir);
|
|
await regexFileAsync(
|
|
expoBuildConstants,
|
|
/DEVELOPMENT_URL \= \"[^\"]*\"\;/,
|
|
`DEVELOPMENT_URL = "${devUrl}";`
|
|
);
|
|
}
|
|
}
|
|
}
|