mirror of
https://github.com/expo/turtle.git
synced 2026-11-14 19:12:55 -10:00
Compare commits
48
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d8726a1fc | ||
|
|
1d773f35cf | ||
|
|
3daa678540 | ||
|
|
616331b47b | ||
|
|
0b9ab78453 | ||
|
|
3e345ddaf8 | ||
|
|
4866c06a02 | ||
|
|
c36723d96c | ||
|
|
28b0266ba2 | ||
|
|
84a19ee261 | ||
|
|
8f3be65326 | ||
|
|
5d539a1bb2 | ||
|
|
833c9c1de5 | ||
|
|
ce74ab3161 | ||
|
|
62df247645 | ||
|
|
9bc4d75e9b | ||
|
|
b4d4b8a6c3 | ||
|
|
88d75c7581 | ||
|
|
03be505cd0 | ||
|
|
05568676ee | ||
|
|
903e44bd29 | ||
|
|
7697542b44 | ||
|
|
3baf4bcade | ||
|
|
c7e98c5858 | ||
|
|
fc48d27e42 | ||
|
|
fdc702f273 | ||
|
|
9fda74dfa4 | ||
|
|
6bab5f71ef | ||
|
|
3f1e9ce910 | ||
|
|
8fb54f3f27 | ||
|
|
069ef7e1c9 | ||
|
|
c0a742fa21 | ||
|
|
fca72875dc | ||
|
|
15ed26d4cd | ||
|
|
4c7af801f1 | ||
|
|
5012ec669d | ||
|
|
8b77a48b82 | ||
|
|
c6946eca25 | ||
|
|
9627ecb80c | ||
|
|
48c78f386a | ||
|
|
0dc251e023 | ||
|
|
d984061134 | ||
|
|
ff6401ba6b | ||
|
|
b443760699 | ||
|
|
0f0e86d17f | ||
|
|
f6697c0029 | ||
|
|
22f802164d | ||
|
|
aaa6e61061 |
No files matched your search
@@ -257,11 +257,11 @@ jobs:
|
||||
- run: yarn
|
||||
- save_yarn_cache
|
||||
- restore_workingdir_cache:
|
||||
sdk_version: sdk37
|
||||
sdk_version: sdk39
|
||||
platform: android
|
||||
- run:
|
||||
name: Fetch Android shell app
|
||||
command: SDK_VERSION=sdk37 ./scripts/android/fetchRemoteAndroidTarball.sh
|
||||
command: SDK_VERSION=sdk39 ./scripts/android/fetchRemoteAndroidTarball.sh
|
||||
- run:
|
||||
name: Run Android smoke tests
|
||||
command: |
|
||||
@@ -269,7 +269,7 @@ jobs:
|
||||
--run "./scripts/tests/runAndroidSmokeTestsCI.sh"
|
||||
- save_workingdir_cache:
|
||||
workingdir: ~/turtle/workingdir
|
||||
sdk_version: sdk37
|
||||
sdk_version: sdk39
|
||||
platform: android
|
||||
|
||||
android_staging_deploy:
|
||||
|
||||
@@ -7,6 +7,84 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## Unreleased
|
||||
|
||||
- Updated SDK 40 Android, iOS, and client tarballs for patch release.
|
||||
|
||||
# [0.20.6] - 2021-01-25
|
||||
|
||||
- Update tarballs for SDK 40 patch release (3e345dd)
|
||||
|
||||
# [0.20.5] - 2020-12-23
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated SDK 40 Android tarball to fix `expo-location` background permissions on Android <9.
|
||||
|
||||
# [0.20.4] - 2020-12-17
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated SDK 40 iOS tarball to fix `expo-notifications` not emitting initial notification response.
|
||||
|
||||
# [0.20.3] - 2020-12-10
|
||||
|
||||
### Fixed
|
||||
|
||||
- Verify jq installation before proceeding with release ([#281](https://github.com/expo/turtle/pull/281)).
|
||||
- Fixed the iOS build when running offline and user lacks elevated permissions.
|
||||
|
||||
# [0.20.2] - 2020-12-09
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated SDK 40 Android tarball to fix `expo-notifications` installation identifier changing as a bug.
|
||||
|
||||
# [0.20.1] - 2020-12-08
|
||||
|
||||
### Changed
|
||||
|
||||
- Update SDK 40 iOS and Android tarballs for release
|
||||
|
||||
# [0.20.0] - 2020-11-30
|
||||
|
||||
- Add SDK 40 tarballs in preparation for SDK 40 beta
|
||||
|
||||
# [0.19.1] - 2020-11-19
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the iOS build using Xcode 12.1 by runnig `xcrun simctl list` before the build phase.
|
||||
- Rebuild iOS tarballs (SDK 36, 37, 38, 39, Ad-Hoc Client) with Xcode 12.1 containing iOS 14 image fix.
|
||||
|
||||
# [0.19.0] - 2020-11-13
|
||||
|
||||
### Changed
|
||||
|
||||
- Rebuild iOS tarballs (SDK 36, 37, 38, 39, Ad-Hoc Client) with Xcode 12.1.
|
||||
|
||||
# [0.18.9] - 2020-11-09
|
||||
|
||||
### Fixed
|
||||
|
||||
- Updated xdl to 58.0.20 to fix an issue with updates.checkAutomatically not being applied properly to SDK 39 Android apps.
|
||||
|
||||
# [0.18.8] - 2020-10-27
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix issues with Reanimated v2 in Android SDK 39 tarball.
|
||||
|
||||
# [0.18.7] - 2020-10-23
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix issue with updates in self-hosted Android apps ([#10746](https://github.com/expo/expo/issues/10746)) in the SDK 39 tarball
|
||||
|
||||
# [0.18.6] - 2020-10-22
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix issues with notifications ([10624](https://github.com/expo/expo/pull/10624) and [10608](https://github.com/expo/expo/pull/10608)) in the Android SDK 39 tarball.
|
||||
|
||||
# [0.18.5] - 2020-10-07
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -21,7 +21,7 @@ You can use it both on your CI and your private computer.
|
||||
|
||||
## Installation
|
||||
|
||||
[Installation instructions and documentation here.](https://docs.expo.io/versions/latest/distribution/turtle-cli)
|
||||
[Installation instructions and documentation here.](https://docs.expo.io/distribution/turtle-cli/)
|
||||
|
||||
## Usage example
|
||||
|
||||
|
||||
+30
-5
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "turtle-cli",
|
||||
"version": "0.18.6",
|
||||
"version": "0.20.7",
|
||||
"description": "The command-line tool for building standalone Expo apps",
|
||||
"preferGlobal": true,
|
||||
"scripts": {
|
||||
@@ -67,17 +67,20 @@
|
||||
"registry": "https://registry.npmjs.org/"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@expo/babel-preset-cli": "^0.1.3",
|
||||
"@expo/babel-preset-cli": "^0.2.18",
|
||||
"@types/color-string": "^1.5.0",
|
||||
"@types/decompress": "^4.2.3",
|
||||
"@types/fs-extra": "^5.0.4",
|
||||
"@types/ioredis": "^4.16.6",
|
||||
"@types/jest": "^23.3.9",
|
||||
"@types/probe-image-size": "^5.0.1",
|
||||
"@types/latest-version": "^4.0.1",
|
||||
"@types/joi": "^13.4.3",
|
||||
"@types/lodash": "^4.14.129",
|
||||
"@types/node-forge": "^0.8.6",
|
||||
"@types/npm-package-arg": "^6.1.0",
|
||||
"@types/progress": "^2.0.1",
|
||||
"@types/ncp": "^2.0.1",
|
||||
"@types/raven": "^2.5.1",
|
||||
"@types/request": "^2.47.1",
|
||||
"@types/request-promise": "^4.1.44",
|
||||
@@ -87,6 +90,7 @@
|
||||
"@types/uuid": "^3.4.3",
|
||||
"@types/webpack": "^4.32.1",
|
||||
"@types/webpack-dev-server": "^3.1.7",
|
||||
"@types/url-join": "^4.0.0",
|
||||
"@types/which": "^1.3.1",
|
||||
"husky": "^1.3.1",
|
||||
"jest": "^26.1.0",
|
||||
@@ -105,12 +109,21 @@
|
||||
"typescript": "^3.9.5"
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/bunyan": "4.0.0",
|
||||
"@expo/config": "~3.3.9",
|
||||
"@expo/spawn-async": "^1.4.2",
|
||||
"@expo/xdl": "58.0.13",
|
||||
"@expo/spawn-async": "~1.5.0",
|
||||
"@expo/plist": "0.0.11",
|
||||
"@expo/json-file": "8.2.26",
|
||||
"@google-cloud/logging-bunyan": "^2.0.3",
|
||||
"async-retry": "^1.2.1",
|
||||
"aws-sdk": "^2.226.1",
|
||||
"axios": "0.21.1",
|
||||
"ncp": "2.0.0",
|
||||
"minimatch": "3.0.4",
|
||||
"indent-string": "3.2.0",
|
||||
"pascal-case": "2.0.1",
|
||||
"node-forge": "0.7.6",
|
||||
"glob": "7.1.6",
|
||||
"bunyan": "^1.8.12",
|
||||
"bunyan-debug-stream": "^1.0.8",
|
||||
"bunyan-sentry-stream": "^1.2.1",
|
||||
@@ -122,18 +135,30 @@
|
||||
"ioredis": "^4.17.3",
|
||||
"is-semver": "^1.0.8",
|
||||
"joi": "^13.2.0",
|
||||
"latest-version": "5.1.0",
|
||||
"lodash": "^4.17.11",
|
||||
"p-map": "3.0.0",
|
||||
"p-retry": "4.1.0",
|
||||
"probe-image-size": "^6.0.0",
|
||||
"p-timeout": "3.1.0",
|
||||
"package-json": "6.4.0",
|
||||
"progress": "^2.0.0",
|
||||
"prompts": "^2.3.2",
|
||||
"raven": "^2.6.2",
|
||||
"request": "^2.88.0",
|
||||
"request-progress": "^3.0.0",
|
||||
"request-promise": "^4.2.5",
|
||||
"replace-string": "1.1.0",
|
||||
"rimraf": "^2.6.2",
|
||||
"semver": "^6.0.0",
|
||||
"sharp": "0.24.1",
|
||||
"source-map-support": "^0.5.6",
|
||||
"tar": "^4.4.6",
|
||||
"url-join": "4.0.0",
|
||||
"uuid": "^3.2.1",
|
||||
"which": "^1.3.1"
|
||||
"which": "^1.3.1",
|
||||
"xcode": "^2.1.0",
|
||||
"xmldom": "0.1.27"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@expo/traveling-fastlane-darwin": "1.11.4"
|
||||
|
||||
@@ -2,6 +2,12 @@
|
||||
|
||||
set -eo pipefail
|
||||
|
||||
if ! command -v jq &> /dev/null
|
||||
then
|
||||
echo "jq is not installed and is required by the release script. Install it through homebrew or your package manager of choice."
|
||||
exit
|
||||
fi
|
||||
|
||||
ROOT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )"/.. && pwd )"
|
||||
|
||||
if [[ -z "$GITHUB_TOKEN" || -z "$CIRCLE_API_USER_TOKEN" ]]; then
|
||||
|
||||
@@ -2,6 +2,12 @@
|
||||
|
||||
set -eo pipefail
|
||||
|
||||
if ! command -v jq &> /dev/null
|
||||
then
|
||||
echo "jq is not installed and is required by the release script. Install it through homebrew or your package manager of choice."
|
||||
exit
|
||||
fi
|
||||
|
||||
ROOT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )"/.. && pwd )"
|
||||
|
||||
$ROOT_DIR/node_modules/.bin/release-it -c $ROOT_DIR/.release-it.beta.json --preRelease=beta
|
||||
@@ -25,7 +25,7 @@ export REDIS_CONFIG_URL="noop"
|
||||
export DATADOG_DISABLED="true"
|
||||
export TURTLE_SDK_VERSIONS_SECRET_TOKEN="noop"
|
||||
|
||||
pushd $ROOT_DIR/workingdir/android/${SDK_VERSION:-sdk37} >/dev/null 2>&1
|
||||
pushd $ROOT_DIR/workingdir/android/${SDK_VERSION:-sdk39} >/dev/null 2>&1
|
||||
yarn
|
||||
popd >/dev/null 2>&1
|
||||
|
||||
|
||||
@@ -2,6 +2,12 @@
|
||||
|
||||
set -eo pipefail
|
||||
|
||||
if ! command -v jq &> /dev/null
|
||||
then
|
||||
echo "jq is not installed and is required by the release script. Install it through homebrew or your package manager of choice."
|
||||
exit
|
||||
fi
|
||||
|
||||
DEPLOY_ENDPOINT_URL="https://circleci.com/api/v1.1/project/github/expo/turtle-cli-example/tree/updates-do-not-remove-me"
|
||||
|
||||
curl -X POST \
|
||||
|
||||
@@ -1 +1 @@
|
||||
s3://exp-artifacts/android-shell-builder-c190edcb99e8068f63dc527a9b0f1d5aa72bbdaf.tar.gz
|
||||
s3://exp-artifacts/android-shell-builder-d292dcd0cc277ab665957244a8b15b9981debd29.tar.gz
|
||||
@@ -0,0 +1 @@
|
||||
s3://exp-artifacts/android-shell-builder-277393d97877f56ba3639ab153c69f109d248436.tar.gz
|
||||
@@ -1 +1 @@
|
||||
s3://exp-artifacts/ios-expo-client-401ff3fb71a1350c00bd0886f7eb4fd462bdf0c0.tar.gz
|
||||
s3://exp-artifacts/ios-expo-client-dd6b6b4840c7a99725f1ec9a9a3970de91e90b1d.tar.gz
|
||||
@@ -1 +1 @@
|
||||
s3://exp-artifacts/ios-shell-builder-sdk-latest-661a51216c2241dcfdd930517968cd1d0e0ee237.tar.gz
|
||||
s3://exp-artifacts/ios-shell-builder-sdk-latest-0983415327f49592d81f0bd3dc8c46a3572c19b0.tar.gz
|
||||
@@ -1 +1 @@
|
||||
s3://exp-artifacts/ios-shell-builder-sdk-latest-30eb0558ff6a77acf56bb045e98b84f6a5e1478a.tar.gz
|
||||
s3://exp-artifacts/ios-shell-builder-sdk-latest-df407aadb7208fc389564aed400c39263294eb76.tar.gz
|
||||
@@ -1 +1 @@
|
||||
s3://exp-artifacts/ios-shell-builder-sdk-latest-6cde8e00cd7bdf7a933cd8aad7f8e86981ea68a2.tar.gz
|
||||
s3://exp-artifacts/ios-shell-builder-sdk-latest-ba6ec1974977bebf8fd2db22a1c87f6a0b36ffa5.tar.gz
|
||||
@@ -1 +1 @@
|
||||
s3://exp-artifacts/ios-shell-builder-sdk-latest-798b04f87b8f1518a0eb4009578cb216963922f0.tar.gz
|
||||
s3://exp-artifacts/ios-shell-builder-sdk-latest-4a9a038998f6be13e8a2900a95afbb9f96dd023d.tar.gz
|
||||
@@ -0,0 +1 @@
|
||||
s3://exp-artifacts/ios-shell-builder-sdk-latest-dd6b6b4840c7a99725f1ec9a9a3970de91e90b1d.tar.gz
|
||||
@@ -1 +1 @@
|
||||
[{"scope":"https://sqs.us-west-2.amazonaws.com:443","method":"POST","path":"/","body":"Action=ReceiveMessage&MaxNumberOfMessages=1&QueueUrl=https%3A%2F%2Fsqs.us-west-2.amazonaws.com%2F274251141632%2Fturtle_jobs_local_dominik_android_in.fifo&Version=2012-11-05&VisibilityTimeout=30","status":200,"response":"<?xml version=\"1.0\"?><ReceiveMessageResponse xmlns=\"http://queue.amazonaws.com/doc/2012-11-05/\"><ReceiveMessageResult><Message><MessageId>f5017f35-ce75-4a6c-acaf-4ccc3d73899f</MessageId><ReceiptHandle>AQEBQjEPeX13Rkr+iSNEuh9J+WKCinb45vRFxWMojfZlD8f5gXi1fncRIotDjji9Q5lkhEK6hjXnALwGXZTstFj3/EPMVV2MKPNN/6NA3cQ7wSd5/+EqP8ZFOC+wBBK46KdS82t46NJ3c0DqkXPImlUE7O2n1WnFiRrjJ+I/G7yYhf+rnxLoNWD8ZPFzzInr6K0Q4Fae8Wqowyc9N8dw2kksnzW3qto+W/Ped1MyF4/Ia6KBqPLQswHRI5WRrqxxaVKdi37XxHW1J71xxEskicTlSBqQmre+3gaCja6cDpXkBtQSNp1LC7nvt/OXZTmXvwtM</ReceiptHandle><MD5OfBody>792da27a1e1bc549ed89f35a5f66e38c</MD5OfBody><Body>{"id":"cd9ff227-575b-4eec-874f-d2b9f6187f05","experienceId":"da491dac-c6bf-4d54-b67a-ba7890e22fb2","experienceName":"@dsokal-local/turtle-cli-example","platform":"android","config":{"releaseChannel":"default","buildType":"apk"},"status":"sent-to-queue","sdkVersion":"37.0.0","accountId":"fbd6360b-e85d-485e-bf20-ab66adfeea16","priority":"normal","credentials":{"keystore":"/u3+7QAAAAIAAAABAAAAAQAscWd2NGNnOHRkaHZ5ZGd4bGwzcjFjbnJzenMxamJna3R6eGhoYnhic3pxPT0AAAFoV0/uKQAABQEwggT9MA4GCisGAQQBKgIRAQEFAASCBOkSPJLZo26jmvgZA63wglEdkZ2jnWIAicad3OsLcfS3h94el0TIaQ7K8BwyX02AKYIGR82+sqxYCKvgY+YIf4xOyrVLS+yMeOurLSydsf6eDVJb7Z+TDh0s46WdN/4u1wihN2/nfdyFb+oxCfGNyKFhG7At/pNYfJQFQ3dvUZf9bVYU6VKBzMY8ON4g8gV/mQVZzsdMuvEARuoTeaW84Hw6Si/QuG/cb1p1dDaCelB7DweStzLqmQMKLqKjOYA/l34/D2YS2Hte1TeMoBmLHNeXAjahkp/WfYbfY0QNMlbc/75gkavH3g9D+SPsz3wx8fzAHRb9mHewM8YD7OSBjVQQV0I6y4hHGhQTc5YUprJ7/Z2m5Miv6ERGu9K3ENIftAY+TXi8J9alrbxaCZF3dQfXLf03kDyom4Zl1z8ptADhxU4V0DWRMFWAKxkrvX1C/qiZmyaAqV4eJfXFSku9bOz0yoaUf3TQBW1shPx9o9gTUXBKdbTzl3B6mUMgG77qGfKcHh/hzrUh87McMaiHEgGzfsmHuKiNIOzCvLgmBo36GtP8zaRNox7zGQ4UF56AbOgc69yvA6W5O7fQD0G4GpqQmkKQR/3oJqE6uTULdNcq9yNku3frxLilsQDiQNffr1abTPgRtE4w5S8vi1bFhBr5o3qzwgonqH8jEK0QKHjpaASXdplCybFM1JplyZCS9DLE1dEtpbdnU47Ekx+JMB5aSJoDhmCORCcLnmqLv4m1KWDPpuib/Z4MYkJDAx/jvbSma/Yu3rXxOrMqED9SZKWaJr7LKCAwoxSwaCewRd1Ji/BP/Ibg5Jr6KWYCGVOPRZgmbtY652uAwFd4ZKIl3v8lC/A89BmqjikgxTjtuVKzLUSdvAVSp5GlDE3G7Mzswtk3VcwPNUnlBIfafry1DvSB8OJH18XoTdbP4yKdXSgYx9GhdFboTLlZTv8WA89xl9vkQK/UMVZT0oQF2c8aQ0RE7LtOOVhJ9/LpB2M64RzgQryeI1haA787y6i3TkGRU+NPmc7bnd5sr6MnzcJbm6eo+JelW/Ap+zo5B+BCkUGTzu5W0/E0HKSyeXPJpX9VdMpwuEVadKDn3mI3lrW4zgyak7y8mh7iUx/GsDD0Nv4wKf8N+/OuTqlGo18aWIuN04cpeikcTbrapvqed5m0/ZxjNaa5GGKAjpqK3STo7AfGrZf/DRSvz8MUacBHnniJFqXFyXH4TmyoBSgPo5LUqzIBnwMnhbwHYwF0GKKyht9UF9kqeZGXfUXqltQQ3G4LSKL1gcwBwlcbX5UGk0tDMq6/vEMpVmg4knh+jz+g5GQ0kN0oIrf0HkbH4NsM235YrSylVe+ncr+S4GD7oMBXICO016CQnAMdyWD0rzGGlRE6XniFvmiI0QzUTCIAsgJ3Iaufy166w0oqhoOEaKq/bQSPI7m9ATHrALy6+ACaq1Sct41fQ8J5LwPoERKmtziyi+roZ6hvn8cnAUdZoS7YjHEQ5bPSKcdPPCwoMxP/aUpYjXnAfcvR22XneuI6E9/g88a5XImSXVx7Q1zD1mAxXvNTIUy9hNKaak8qMxnkhepCcFKrofBrqG6Qi7aWdq3Dspj6NwEPLgHmUlXkfHfHWczz2YoK6qgLmdHTpwyF6GU6BzxZTe5iDxtBZSsr6s1WRk8s6KMXBUBc4y4AAAABAAVYLjUwOQAAA2EwggNdMIICRaADAgECAgRje5iNMA0GCSqGSIb3DQEBCwUAMF8xCzAJBgNVBAYTAlVTMQkwBwYDVQQIEwAxCTAHBgNVBAcTADEJMAcGA1UEChMAMQkwBwYDVQQLEwAxJDAiBgNVBAMTG2NvbS5leHBvLnR1cnRsZS5jbGkuZXhhbXBsZTAeFw0xOTAxMTYxNTM4MDJaFw00NjA2MDMxNTM4MDJaMF8xCzAJBgNVBAYTAlVTMQkwBwYDVQQIEwAxCTAHBgNVBAcTADEJMAcGA1UEChMAMQkwBwYDVQQLEwAxJDAiBgNVBAMTG2NvbS5leHBvLnR1cnRsZS5jbGkuZXhhbXBsZTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAJjpLpdLvSMyE37A6E1BZ6j9wfqNC09CAVZAhsuiyocYhZH/lKCvx/giht4Pf0n5rXeOMNBjsrNwl+qxylOHwhcHAFgUiXgr15tQr+wsEA0ddDztFDzlND+HORurKXxgg82WYSwpGqLmahTJ2UvfcJrPsNp+hQgjit2zt4oyLDT+LoQrR7Mw6cxhxdCCVYNqrXVusOH0D2xubh39lfA6YCOAjXSzJ+C79nuAXwNmOmSqyYaHvMf4LCQ10qmAtXy+chucW4G5xzNkbkkIzye9hfev/YF4Xyt5HMH+WfgJLY9rfiEVa2A/t2/XhNBZYzb48GUPn4FORKzT37nrhs8xdIkCAwEAAaMhMB8wHQYDVR0OBBYEFKJv1T1PHa7r0KhPYvrXL7iCEjf9MA0GCSqGSIb3DQEBCwUAA4IBAQAUlJYtz2+GwpXDeyHTewwFs7hPgQO1oMJxtHitkc+rWPR8PwGall9WH+Wz5VXq5R1ZwsodgNRw5gLbZQpLb+/qYYyk8TSiTG1W4hKjHv6mWnSRQKSve/Zr5Z9tZt12Sc1xGYQ2QlMtvDNoxvcSguOoE0CfYzeK6hQteE873HSUIdqIRilK7EkfJexkFI/+4UWTZ4OP3MkKl0GKHTrHzcovzsmY/f1dkB0aByOyFpR17P6tIzd0Xsub3kgZr71PMb/5tvo2GePTmNVb46CUAhhBvt4BZBmr5ehh7oV+pHJrrVkPltMV8ddggNxpdWwE7F4Q5wKaGu0YPpaDZYv7jIeXdT074zft6plJHUWNi9CQgArR9Cs=","keystorePassword":"28eeeda02db44ed9afc1c1d4a0f2caa6","keystoreAlias":"QGV4cG8tdHVydGxlL3R1cnRsZS1jbGktZXhhbXBsZQ==","keyPassword":"a6a53453c5534673900dac983e40a693"},"manifest":{"ios":{"buildNumber":"13","supportsTablet":true,"bundleIdentifier":"com.expo.turtle.cli.example"},"icon":"./assets/icon.png","namLine truncated
|
||||
[{"scope":"https://sqs.us-west-2.amazonaws.com:443","method":"POST","path":"/","body":"Action=ReceiveMessage&MaxNumberOfMessages=1&QueueUrl=https%3A%2F%2Fsqs.us-west-2.amazonaws.com%2F274251141632%2Fturtle_jobs_local_dominik_android_in.fifo&Version=2012-11-05&VisibilityTimeout=30","status":200,"response":"<?xml version=\"1.0\"?><ReceiveMessageResponse xmlns=\"http://queue.amazonaws.com/doc/2012-11-05/\"><ReceiveMessageResult/><ResponseMetadata><RequestId>8de5bfc7-bc8e-5ee3-9e04-e787ff880c47</RequestId></ResponseMetadata></ReceiveMessageResponse>","rawHeaders":["x-amzn-RequestId","8de5bfc7-bc8e-5ee3-9e04-e787ff880c47","Date","Tue, 01 Dec 2020 13:10:35 GMT","Content-Type","text/xml","Content-Length","240"]},{"scope":"https://sqs.us-west-2.amazonaws.com:443","method":"POST","path":"/","body":"Action=ReceiveMessage&MaxNumberOfMessages=1&QueueUrl=https%3A%2F%2Fsqs.us-west-2.amazonaws.com%2F274251141632%2Fturtle_jobs_local_dominik_android_in_priority.fifo&Version=2012-11-05&VisibilityTimeout=30","status":200,"response":"<?xml version=\"1.0\"?><ReceiveMessageResponse xmlns=\"http://queue.amazonaws.com/doc/2012-11-05/\"><ReceiveMessageResult/><ResponseMetadata><RequestId>0bb998bb-3156-5159-b16f-c0fb725a5d65</RequestId></ResponseMetadata></ReceiveMessageResponse>","rawHeaders":["x-amzn-RequestId","0bb998bb-3156-5159-b16f-c0fb725a5d65","Date","Tue, 01 Dec 2020 13:10:41 GMT","Content-Type","text/xml","Content-Length","240"]},{"scope":"https://sqs.us-west-2.amazonaws.com:443","method":"POST","path":"/","body":"Action=ReceiveMessage&MaxNumberOfMessages=1&QueueUrl=https%3A%2F%2Fsqs.us-west-2.amazonaws.com%2F274251141632%2Fturtle_jobs_local_dominik_android_in.fifo&Version=2012-11-05&VisibilityTimeout=30","status":200,"response":"<?xml version=\"1.0\"?><ReceiveMessageResponse xmlns=\"http://queue.amazonaws.com/doc/2012-11-05/\"><ReceiveMessageResult/><ResponseMetadata><RequestId>feef69fc-27a3-5af5-83af-31bc64b1b9d7</RequestId></ResponseMetadata></ReceiveMessageResponse>","rawHeaders":["x-amzn-RequestId","feef69fc-27a3-5af5-83af-31bc64b1b9d7","Date","Tue, 01 Dec 2020 13:10:47 GMT","Content-Type","text/xml","Content-Length","240"]},{"scope":"https://sqs.us-west-2.amazonaws.com:443","method":"POST","path":"/","body":"Action=ReceiveMessage&MaxNumberOfMessages=1&QueueUrl=https%3A%2F%2Fsqs.us-west-2.amazonaws.com%2F274251141632%2Fturtle_jobs_local_dominik_android_in_priority.fifo&Version=2012-11-05&VisibilityTimeout=30","status":200,"response":"<?xml version=\"1.0\"?><ReceiveMessageResponse xmlns=\"http://queue.amazonaws.com/doc/2012-11-05/\"><ReceiveMessageResult><Message><MessageId>f39aaec9-47e0-4d02-bdb7-cf467fe2ea19</MessageId><ReceiptHandle>AQEBBI8nzAywIPCH57h1Iptt39uq60E5dzlj+Er50gtGqH+esoJ6iFdxoPDxvtD0xfT0qlyKSqlmoBDkQwh36tqsfqdT5FcFF8ejmPMRVIE8hexofzEbacxxw+3cBfgra2EFAik49TJbMT5pwxyaf42y+qwyWKOZ5N62VzRgGtnpj5ilNw1dt0lUzCdsfhCu7sne+dfJ8oMpbuy6T+ttoEYUS6VVKmv8f1BzuujHoKMy0kJip8r3zFoaigWE2p1bpFby9b9oBFTFT1ycD618YTu+Un5S9Q7Q6/nqJ7RPM2BfK2iPxBMk+UWmGfJ/otX11Zc0p8qx/MyKuYY/480Zta//xQ==</ReceiptHandle><MD5OfBody>1942fb5be12b59fc93b8052cf960f7d8</MD5OfBody><Body>{"id":"686530e4-c025-4a01-b84b-041e6d4f7fb3","experienceId":"4d928c1c-5be9-4380-8fd5-48a6a4805839","experienceName":"@testtest2/sdk-39-test","platform":"android","config":{"releaseChannel":"default","buildType":"apk"},"status":"sent-to-queue","sdkVersion":"39.0.0","accountId":"1ecd48ee-e393-4f6b-9f77-311f2e0d2b39","priority":"high","credentials":{"keystore":"/u3+7QAAAAIAAAABAAAAAQAgcWhybGMzcjB6eG4wbWk5enpnc3Rtemt0ZGd2emRhPT0AAAF2HlYoCQAABQEwggT9MA4GCisGAQQBKgIRAQEFAASCBOmzIBnRnHGBLgRUOJ1GGYiLjktnTeq/GXR/h4KZSSQ67PoiJqJnhrHLvj35Sr1swSL78q4j52IRV/a1mFbqQT4JFFy1ZdH2XgKUptY+kVc+ZrBI/awC8akIeYofWmKTNcS0Bc9IDT1VwIzwxxRrlnK/36Gh2m1vRuwVUdBChHjB4SHgC91F8wB0opY+RmiRhZqQevd8PBvZyqKenR5IWgZDXhsUYvvby4U4zMzvEgV3zNv7YGYM1Bb2ehJ8HdsW6O/58zFL3ufJ3+H2VonLxSn9YayninHoqkyMP40/mmnaFPz5jNItDkCQIeuudgQc8mrZlv4X0lM2ZjT4ZJDLdN4FCXV5iYDe4hopF8iF+DZmhMaZ1xFJ57boEaZ+WUcwsTkKOawusOGqW2JsvZbDMehOYLc8aqM2t28PrdYf3c7zspJALi6+tar2mwN66TX/AteeJRAk3r7HAiYPyle5gOffGgpmy0klLb5MEStkLQNNNsVMLJ7XSYrewzI6jXyAbMVc6PzbiVnCRPtoSy0TMUD7qQWMUGdePL1jxDlwhOVS3jgwOwHIFhUaLBbQOPROm1bF4hY3p25+LHFu954aDsYC3JlT6k9EOKIacKbJThq14r2Lb4EekfQlYTO/yst6ncVJcawWHYS1f93xWUlIjcpFnqJrLq2QVQfhafPWJmTS3K0lcYgFpfxvoU8RVn+ySaLOiy9J86l5YPmtpH/nFqr6BkqROy6dXps5x+D9vi3jVfYlYHnmqHxq7vyX5X+IMIMLvJ28SviuMKWb8Ikqmm7FSzlewJfF4xy/NLgQrQY6VxUahy92PZf3uR02ENolnj5nPSnh/B1FnIfrG46xFqlPg8imzen7hjKk1yXrdUjOT4WcjLdD/K/heVVHKUnsn3QCBt6eOgB8MpG9XzGRD+PAwloklASQx8iVSD67EauU2y1yayf4lNaQzdfnF3iFXSF25BU9XLJIRTtdRsTwmLRuLTmQcctLYD8hQCDQ2/njZtTkdY/AJeVlm44CdDaTLJpP8mVVscAOgH/S0DPi/oGGie8AYeKr8ZmBO1lQSN6l2LBJcAq2fh6AaUF5By21lT9FfR9KQd5WM87iUZk41nBIHQXcQSSt7H+vETEfm/8T9tOOwY5dMyf5QAYNCZuBoIFNCr0MOxe/Dcrsvd46KVbftR4HsJsQl53MnZoh4eLine truncated
|
||||
+31
-14
@@ -1,19 +1,27 @@
|
||||
import path from 'path';
|
||||
|
||||
import { LoggerDetach, ModuleVersion } from '@expo/xdl';
|
||||
import program, { Command } from 'commander';
|
||||
import fs from 'fs-extra';
|
||||
import { LoggerDetach, ModuleVersion } from '../xdl';
|
||||
|
||||
import * as commands from 'turtle/bin/commands';
|
||||
import logger from 'turtle/logger';
|
||||
|
||||
const { name, version } = JSON.parse(fs.readFileSync(path.join(__dirname, '../../package.json'), 'utf-8'));
|
||||
const { name, version } = JSON.parse(
|
||||
fs.readFileSync(path.join(__dirname, '../../package.json'), 'utf-8'),
|
||||
);
|
||||
|
||||
const ModuleVersionChecker = ModuleVersion.createModuleVersionChecker(name, version);
|
||||
const ModuleVersionChecker = ModuleVersion.createModuleVersionChecker(
|
||||
name,
|
||||
version,
|
||||
);
|
||||
|
||||
// @ts-ignore: Type mismatch
|
||||
LoggerDetach.configure(logger);
|
||||
|
||||
Command.prototype.asyncAction = function asyncAction(asyncFn: (...asyncFnArgs: any[]) => void): Command {
|
||||
Command.prototype.asyncAction = function asyncAction(
|
||||
asyncFn: (...asyncFnArgs: any[]) => void,
|
||||
): Command {
|
||||
return this.action(async (...args) => {
|
||||
try {
|
||||
await checkForUpdateAsync();
|
||||
@@ -33,19 +41,24 @@ export function run(programName: string) {
|
||||
|
||||
async function runAsync(programName: string) {
|
||||
program.version(version);
|
||||
Object.values(commands).forEach((command) => registerCommand(program, command));
|
||||
Object.values(commands).forEach((command) =>
|
||||
registerCommand(program, command),
|
||||
);
|
||||
program.parse(process.argv);
|
||||
|
||||
const subCommand = process.argv[2];
|
||||
if (subCommand) {
|
||||
const commandNames = program.commands.reduce((acc: string[], command: any) => {
|
||||
acc.push(command._name);
|
||||
const alias = command._alias;
|
||||
if (alias) {
|
||||
acc.push(alias);
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
const commandNames = program.commands.reduce(
|
||||
(acc: string[], command: any) => {
|
||||
acc.push(command._name);
|
||||
const alias = command._alias;
|
||||
if (alias) {
|
||||
acc.push(alias);
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
[],
|
||||
);
|
||||
if (!commandNames.includes(subCommand)) {
|
||||
logger.error(
|
||||
`"${subCommand}" is not an ${programName} command. See "${programName} --help" for the full list of commands.`,
|
||||
@@ -59,7 +72,11 @@ async function runAsync(programName: string) {
|
||||
const registerCommand = (prog: any, command: any) => command(prog);
|
||||
|
||||
async function checkForUpdateAsync() {
|
||||
const { updateIsAvailable, current, latest } = await ModuleVersionChecker.checkAsync();
|
||||
const {
|
||||
updateIsAvailable,
|
||||
current,
|
||||
latest,
|
||||
} = await ModuleVersionChecker.checkAsync();
|
||||
if (updateIsAvailable) {
|
||||
logger.warn(
|
||||
`There is a new version of ${name} available (${latest}).
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import path from 'path';
|
||||
import util from 'util';
|
||||
|
||||
import { ExponentTools } from '@expo/xdl';
|
||||
import fs from 'fs-extra';
|
||||
import _which from 'which';
|
||||
import { ExponentTools } from '../../../xdl';
|
||||
|
||||
import ensureAndroidSDKIsPresent from 'turtle/bin/setup/android/sdk';
|
||||
import { checkSystem, ensureShellAppIsPresent } from 'turtle/bin/setup/utils/common';
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import { ExponentTools } from '@expo/xdl';
|
||||
import decompress from 'decompress';
|
||||
import fs from 'fs-extra';
|
||||
import { ExponentTools } from '../../../xdl';
|
||||
|
||||
import * as utils from 'turtle/bin/setup/utils/common';
|
||||
import download from 'turtle/bin/setup/utils/downloader';
|
||||
|
||||
@@ -3,7 +3,7 @@ import trim from 'lodash/trim';
|
||||
import path from 'path';
|
||||
import semver from 'semver';
|
||||
|
||||
import { ExponentTools } from '@expo/xdl';
|
||||
import { ExponentTools } from '../../xdl';
|
||||
|
||||
import { checkSystem, ensureShellAppIsPresent } from 'turtle/bin/setup/utils/common';
|
||||
import { IToolDefinition } from 'turtle/bin/setup/utils/toolsDetector';
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import path from 'path';
|
||||
|
||||
import { ExponentTools } from '@expo/xdl';
|
||||
import fs from 'fs-extra';
|
||||
import tar from 'tar';
|
||||
import { ExponentTools } from '../../../xdl';
|
||||
|
||||
import download from 'turtle/bin/setup/utils/downloader';
|
||||
import { ensureToolsAreInstalled, IToolDefinition } from 'turtle/bin/setup/utils/toolsDetector';
|
||||
@@ -64,7 +64,7 @@ export function formatArtifactDownloadPath(uri: string) {
|
||||
|
||||
async function _readShellAppTarballS3Uri(sdkVersion: string, formatters: IShellAppFormaters) {
|
||||
const sdkMajorVersion = ExponentTools.parseSdkMajorVersion(sdkVersion);
|
||||
const filePath = formatters.formatShellAppTarballUriPath(sdkMajorVersion);
|
||||
const filePath = formatters.formatShellAppTarballUriPath(String(sdkMajorVersion));
|
||||
const data = await fs.readFile(filePath, 'utf8');
|
||||
return data.trim();
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import _url from 'url';
|
||||
|
||||
import { ConfigError, getExpoSDKVersion, ProjectConfig } from '@expo/config';
|
||||
import { ExponentTools } from '@expo/xdl';
|
||||
import _ from 'lodash';
|
||||
import uuid from 'uuid';
|
||||
import { ExponentTools } from '../../xdl';
|
||||
|
||||
import { ErrorWithCommandHelp } from 'turtle/bin/commands/ErrorWithCommandHelp';
|
||||
import { ErrorWithProgramHelp } from 'turtle/bin/commands/ErrorWithProgramHelp';
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import path from 'path';
|
||||
|
||||
import { AndroidShellApp, ImageUtils } from '@expo/xdl';
|
||||
import { AndroidShellApp, ImageUtils } from '../xdl';
|
||||
|
||||
import fs from 'fs-extra';
|
||||
import _ from 'lodash';
|
||||
import { ANDROID_BUILD_TYPES } from 'turtle/constants';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { IosIPABuilder as createIPABuilder } from '@expo/xdl';
|
||||
import _ from 'lodash';
|
||||
import { IosIPABuilder as createIPABuilder } from '../../xdl';
|
||||
|
||||
import { IContext } from 'turtle/builders/ios/context';
|
||||
import { logErrorOnce } from 'turtle/builders/utils/common';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as path from 'path';
|
||||
|
||||
import { ExponentTools, IosShellApp } from '@expo/xdl';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { ExponentTools, IosShellApp } from '../../xdl';
|
||||
|
||||
import { formatShellAppDirectory } from 'turtle/builders/utils/ios/workingdir';
|
||||
import config from 'turtle/config';
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import fs from 'fs-extra';
|
||||
import pick from 'lodash/pick';
|
||||
|
||||
import spawnAsync from '@expo/spawn-async';
|
||||
import buildArchive from 'turtle/builders/ios/archive';
|
||||
import { createBuilderContext, IContext } from 'turtle/builders/ios/context';
|
||||
import buildSimulator from 'turtle/builders/ios/simulator';
|
||||
@@ -51,6 +52,13 @@ async function initBuilder(ctx: IContext) {
|
||||
await fs.ensureDir(dir);
|
||||
await fs.chmod(dir, 0o755);
|
||||
}
|
||||
|
||||
if (config.builder.mode === 'online') {
|
||||
await spawnAsync('sudo', ['xcrun', 'simctl', 'list']);
|
||||
} else {
|
||||
// sudo not needed for CLI builds
|
||||
await spawnAsync('xcrun', ['simctl', 'list']);
|
||||
}
|
||||
}
|
||||
|
||||
async function cleanup(ctx: IContext) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import path from 'path';
|
||||
|
||||
import { ExponentTools } from '@expo/xdl';
|
||||
import { ExponentTools } from '../../../xdl';
|
||||
|
||||
import { IShellAppDirectoryConfig } from 'turtle/builders/utils/workingdir';
|
||||
import config from 'turtle/config';
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import path from 'path';
|
||||
|
||||
import spawnAsync from '@expo/spawn-async';
|
||||
import { IosCodeSigning } from '@expo/xdl';
|
||||
import fs from 'fs-extra';
|
||||
import { IosCodeSigning } from '../../../xdl';
|
||||
|
||||
import BuildError, { BuildErrorReason } from 'turtle/builders/BuildError';
|
||||
import { IJob } from 'turtle/job';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { IosKeychain } from '@expo/xdl';
|
||||
import { IosKeychain } from '../../../xdl';
|
||||
|
||||
import { IContext } from 'turtle/builders/ios/context';
|
||||
import * as fileUtils from 'turtle/builders/utils/file';
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import path from 'path';
|
||||
import util from 'util';
|
||||
|
||||
import { ImageUtils, IosShellApp } from '@expo/xdl';
|
||||
import copy from 'copy';
|
||||
import _ from 'lodash';
|
||||
import { ImageUtils, IosShellApp } from '../../../xdl';
|
||||
|
||||
import { IContext } from 'turtle/builders/ios/context';
|
||||
import * as commonUtils from 'turtle/builders/utils/common';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import path from 'path';
|
||||
|
||||
import { ExponentTools } from '@expo/xdl';
|
||||
import { ExponentTools } from '../../../xdl';
|
||||
|
||||
import { IShellAppDirectoryConfig } from 'turtle/builders/utils/workingdir';
|
||||
import config from 'turtle/config';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ExponentTools } from '@expo/xdl';
|
||||
import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
import { ExponentTools } from '../../xdl';
|
||||
|
||||
import config from 'turtle/config';
|
||||
import { IJob } from 'turtle/job';
|
||||
|
||||
+19
-8
@@ -1,16 +1,25 @@
|
||||
import { LoggerDetach } from '@expo/xdl';
|
||||
import { LoggerDetach } from './xdl';
|
||||
|
||||
import { findSupportedSdkVersions } from 'turtle/builders/utils/version';
|
||||
import config from 'turtle/config';
|
||||
import { doJob } from 'turtle/jobManager';
|
||||
import logger from 'turtle/logger';
|
||||
import setup from 'turtle/setup';
|
||||
import { checkShouldExit, setShouldExit, turtleVersion } from 'turtle/turtleContext';
|
||||
import {
|
||||
checkShouldExit,
|
||||
setShouldExit,
|
||||
turtleVersion,
|
||||
} from 'turtle/turtleContext';
|
||||
import { synchronizeFailedUpdates } from 'turtle/updatesManager';
|
||||
import Leader, { LeaderEvent } from 'turtle/utils/leader';
|
||||
import { setSupportedSdkVersions, setTurtleVersion } from 'turtle/utils/versions';
|
||||
import {
|
||||
setSupportedSdkVersions,
|
||||
setTurtleVersion,
|
||||
} from 'turtle/utils/versions';
|
||||
|
||||
process.on('unhandledRejection', (err) => logger.error({ err }, 'Unhandled promise rejection'));
|
||||
process.on('unhandledRejection', (err) =>
|
||||
logger.error({ err }, 'Unhandled promise rejection'),
|
||||
);
|
||||
|
||||
function handleExit() {
|
||||
if (checkShouldExit()) {
|
||||
@@ -27,9 +36,10 @@ process.on('SIGINT', handleExit);
|
||||
|
||||
async function main() {
|
||||
logger.info(
|
||||
'Starting Turtle... '
|
||||
+ `NODE_ENV=${config.env}, PLATFORM=${config.platform}, DEPLOYMENT_ENVIRONMENT=${config.deploymentEnv}`,
|
||||
'Starting Turtle... ' +
|
||||
`NODE_ENV=${config.env}, PLATFORM=${config.platform}, DEPLOYMENT_ENVIRONMENT=${config.deploymentEnv}`,
|
||||
);
|
||||
// @ts-ignore: Type mismatch
|
||||
LoggerDetach.configure(logger);
|
||||
if (setup[config.platform]) {
|
||||
await setup[config.platform]();
|
||||
@@ -40,7 +50,9 @@ async function main() {
|
||||
try {
|
||||
await setTurtleVersion(turtleVersion);
|
||||
logger.info(`Registered Turtle version (${turtleVersion}) in www`);
|
||||
const sdkVersions = (await findSupportedSdkVersions(config.platform)).map((version) => `${version}.0.0`);
|
||||
const sdkVersions = (await findSupportedSdkVersions(config.platform)).map(
|
||||
(version) => `${version}.0.0`,
|
||||
);
|
||||
await setSupportedSdkVersions(config.platform, sdkVersions);
|
||||
logger.info(`Registered supported SDK versions (${sdkVersions}) in www`);
|
||||
} catch (err) {
|
||||
@@ -59,7 +71,6 @@ async function main() {
|
||||
async function electLeaderAndSynchronize() {
|
||||
if (config.env === 'test') {
|
||||
return;
|
||||
|
||||
}
|
||||
const l = new Leader({
|
||||
key: config.leader.redisKey,
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import fs from 'fs-extra';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
/*
|
||||
A Cacher is used to wrap a fallible or expensive function and to memoize its results on disk
|
||||
in case it either fails or we don't need fresh results very often. It stores objects in JSON, and
|
||||
parses JSON from disk when returning an object.
|
||||
|
||||
It's constructed with a "refresher" callback which will be called for the results, a filename to use
|
||||
for the cache, and an optional TTL and boostrap file. The TTL (in milliseconds) can be used to speed
|
||||
up slow calls from the cache (for example checking npm published versions can be very slow). The
|
||||
bootstrap file can be used to "seed" the cache with a particular value stored in a file.
|
||||
|
||||
If there is a problem calling the refresher function or in performing the cache's disk I/O, errors
|
||||
will be stored in variables on the class. The only times Cacher will throw an exception are if it's
|
||||
not possible to create the cache directory (usually weird home directory permissions), or if getAsync()
|
||||
is called but no value can be provided. The latter will only occur if the refresher fails, no cache
|
||||
is available on disk (i.e. this is the first call or it has been recently cleared), and bootstrapping
|
||||
was not available (either a bootstrap file wasn't provided or reading/writing failed).
|
||||
|
||||
See src/__tests__/tools/FsCache-test.js for usage examples.
|
||||
*/
|
||||
class Cacher<T> {
|
||||
public refresher: () => Promise<T>;
|
||||
public filename: string;
|
||||
public bootstrapFile?: string;
|
||||
public ttlMilliseconds: number;
|
||||
|
||||
public readError?: any;
|
||||
public writeError?: any;
|
||||
|
||||
constructor(
|
||||
refresher: () => Promise<T>,
|
||||
filename: string,
|
||||
ttlMilliseconds?: number,
|
||||
bootstrapFile?: string,
|
||||
) {
|
||||
this.refresher = refresher;
|
||||
this.filename = path.join(getCacheDir(), filename);
|
||||
this.ttlMilliseconds = ttlMilliseconds || 0;
|
||||
this.bootstrapFile = bootstrapFile;
|
||||
}
|
||||
|
||||
public async getAsync(): Promise<T> {
|
||||
// Let user opt out of cache for debugging purposes
|
||||
if (process.env.SKIP_CACHE) {
|
||||
return await this.refresher();
|
||||
}
|
||||
|
||||
let mtime: Date;
|
||||
try {
|
||||
const stats = await fs.stat(this.filename);
|
||||
mtime = stats.mtime;
|
||||
} catch (e) {
|
||||
try {
|
||||
await fs.mkdirp(getCacheDir());
|
||||
|
||||
if (this.bootstrapFile) {
|
||||
const bootstrapContents = (
|
||||
await fs.readFile(this.bootstrapFile)
|
||||
).toString();
|
||||
|
||||
await fs.writeFile(this.filename, bootstrapContents, 'utf8');
|
||||
}
|
||||
} catch (e) {
|
||||
// intentional no-op
|
||||
}
|
||||
mtime = new Date(1989, 10, 19);
|
||||
}
|
||||
|
||||
let fromCache: T | null = null;
|
||||
let failedRefresh = null;
|
||||
|
||||
// if mtime + ttl >= now, attempt to fetch the value, otherwise read from disk
|
||||
// alternatively, if ttlMilliseconds is 0 we also update every time, regardless of the times.
|
||||
// this is a workaround for the issue described in https://github.com/expo/expo-cli/issues/1683
|
||||
if (
|
||||
this.ttlMilliseconds === 0 ||
|
||||
new Date().getTime() - mtime.getTime() > this.ttlMilliseconds
|
||||
) {
|
||||
try {
|
||||
fromCache = await this.refresher();
|
||||
|
||||
try {
|
||||
await fs.writeFile(this.filename, JSON.stringify(fromCache), 'utf8');
|
||||
} catch (e) {
|
||||
this.writeError = e;
|
||||
// do nothing, if the refresh succeeded it'll be returned, if the persist failed we don't care
|
||||
}
|
||||
} catch (e) {
|
||||
failedRefresh = e;
|
||||
}
|
||||
}
|
||||
|
||||
if (!fromCache) {
|
||||
try {
|
||||
fromCache = JSON.parse(await fs.readFile(this.filename, 'utf8'));
|
||||
} catch (e) {
|
||||
this.readError = e;
|
||||
// if this fails then we've exhausted our options and it should remain null
|
||||
}
|
||||
}
|
||||
|
||||
if (fromCache) {
|
||||
return fromCache;
|
||||
} else {
|
||||
if (failedRefresh) {
|
||||
throw new Error(
|
||||
`Unable to perform cache refresh for ${this.filename}: ${failedRefresh}`,
|
||||
);
|
||||
} else {
|
||||
throw new Error(
|
||||
`Unable to read ${this.filename}. ${this.readError || ''}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async clearAsync(): Promise<void> {
|
||||
try {
|
||||
await fs.unlink(this.filename);
|
||||
} catch (e) {
|
||||
this.writeError = e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getCacheDir(): string {
|
||||
const homeDir = os.homedir();
|
||||
if (process.env.XDG_CACHE_HOME) {
|
||||
return process.env.XDG_CACHE_HOME;
|
||||
} else if (process.platform === 'win32') {
|
||||
return path.join(homeDir, 'AppData', 'Local', 'Expo');
|
||||
} else if (process.platform === 'darwin') {
|
||||
// too many mac users have broken permissions on their ~/.cache directory
|
||||
return path.join(homeDir, '.expo', 'cache');
|
||||
} else {
|
||||
return path.join(homeDir, '.cache', 'expo');
|
||||
}
|
||||
}
|
||||
|
||||
export { Cacher, getCacheDir };
|
||||
@@ -0,0 +1,103 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import probeImageSize from 'probe-image-size';
|
||||
|
||||
import { spawnAsyncThrowError } from './detach/ExponentTools';
|
||||
import LoggerDetach from './detach/Logger';
|
||||
|
||||
/**
|
||||
* @param {string} projectDirname
|
||||
* @param {string} basename
|
||||
* @returns {} { width: number, height: number } image dimensions or null
|
||||
*/
|
||||
async function getImageDimensionsAsync(
|
||||
projectDirname: string,
|
||||
basename: string,
|
||||
): Promise<{ width?: number; height?: number } | null> {
|
||||
return await customGetImageDimensionsAsync(projectDirname, basename).catch(
|
||||
() => null,
|
||||
);
|
||||
}
|
||||
|
||||
async function customGetImageDimensionsWithImageProbeAsync(
|
||||
projectDirname: string,
|
||||
basename: string,
|
||||
): Promise<{ width: number; height: number }> {
|
||||
const imagePath = path.resolve(projectDirname, basename);
|
||||
const readStream = fs.createReadStream(imagePath);
|
||||
const { width, height } = await probeImageSize(readStream);
|
||||
readStream.destroy();
|
||||
return { width, height };
|
||||
}
|
||||
|
||||
let hasWarned = false;
|
||||
async function resizeImageAsync(
|
||||
iconSizePx: number,
|
||||
iconFilename: string,
|
||||
destinationIconPath: string,
|
||||
) {
|
||||
if (
|
||||
process.platform !== 'darwin' &&
|
||||
customResizeImageAsync === customResizeImageWithSipsAsync &&
|
||||
!hasWarned
|
||||
) {
|
||||
LoggerDetach.warn('`sips` utility may or may not work outside of macOS');
|
||||
hasWarned = true;
|
||||
}
|
||||
return customResizeImageAsync(iconSizePx, iconFilename, destinationIconPath);
|
||||
}
|
||||
|
||||
async function customResizeImageWithSipsAsync(
|
||||
iconSizePx: number,
|
||||
iconFilename: string,
|
||||
destinationIconPath: string,
|
||||
) {
|
||||
return spawnAsyncThrowError(
|
||||
'sips',
|
||||
['-Z', iconSizePx.toFixed(), iconFilename],
|
||||
{
|
||||
stdio: ['ignore', 'ignore', 'inherit'], // only stderr
|
||||
cwd: destinationIconPath,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Allow us to swap out the default implementations of image functions
|
||||
let customResizeImageAsync = customResizeImageWithSipsAsync;
|
||||
let customGetImageDimensionsAsync: (
|
||||
dirname: string,
|
||||
filename: string,
|
||||
) => Promise<{
|
||||
width?: number;
|
||||
height?: number;
|
||||
} | null> = customGetImageDimensionsWithImageProbeAsync;
|
||||
|
||||
// Allow users to provide an alternate implementation for our image resize function.
|
||||
// This is used internally in order to use sharp instead of sips in standalone builder.
|
||||
function setResizeImageFunction(
|
||||
fn: (
|
||||
iconSizePx: number,
|
||||
iconFilename: string,
|
||||
destinationIconPath: string,
|
||||
) => Promise<any>,
|
||||
) {
|
||||
customResizeImageAsync = fn;
|
||||
}
|
||||
|
||||
// Allow users to provide an alternate implementation for our image dimensions function.
|
||||
// This is used internally in order to use sharp instead of sips in standalone builder.
|
||||
function setGetImageDimensionsFunction(
|
||||
fn: (
|
||||
dirname: string,
|
||||
filename: string,
|
||||
) => Promise<{ width?: number; height?: number } | null>,
|
||||
) {
|
||||
customGetImageDimensionsAsync = fn;
|
||||
}
|
||||
|
||||
export {
|
||||
resizeImageAsync,
|
||||
setResizeImageFunction,
|
||||
setGetImageDimensionsFunction,
|
||||
getImageDimensionsAsync,
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
import latestVersionAsync from 'latest-version';
|
||||
import pTimeout from 'p-timeout';
|
||||
import npmPackageJson from 'package-json';
|
||||
import semver from 'semver';
|
||||
|
||||
import { Cacher } from './FsCache';
|
||||
|
||||
/** @deprecated just use the update-check npm package */
|
||||
function createModuleVersionChecker(name: string, currentVersion: string) {
|
||||
const UpdateCacher = new Cacher(
|
||||
async () => {
|
||||
const pkgJson = await pTimeout(
|
||||
npmPackageJson(name, { version: currentVersion }),
|
||||
2000,
|
||||
);
|
||||
return {
|
||||
latestVersion: await pTimeout(latestVersionAsync(name), 2000),
|
||||
deprecated: pkgJson.deprecated,
|
||||
};
|
||||
},
|
||||
`${name}-updates.json`,
|
||||
24 * 60 * 60 * 1000, // one day
|
||||
);
|
||||
|
||||
async function checkAsync() {
|
||||
const { latestVersion, deprecated } = await UpdateCacher.getAsync();
|
||||
return {
|
||||
updateIsAvailable: semver.gt(latestVersion, currentVersion),
|
||||
latest: latestVersion,
|
||||
current: currentVersion,
|
||||
deprecated,
|
||||
};
|
||||
}
|
||||
|
||||
return { checkAsync };
|
||||
}
|
||||
|
||||
export { createModuleVersionChecker };
|
||||
@@ -0,0 +1,24 @@
|
||||
export type ErrorCode = 'DIRECTORY_ALREADY_EXISTS' | 'INVALID_JSON';
|
||||
|
||||
const ERROR_PREFIX = 'Error: ';
|
||||
|
||||
export default class XDLError extends Error {
|
||||
public readonly name = 'XDLError';
|
||||
|
||||
public code: string;
|
||||
public isXDLError: true;
|
||||
|
||||
constructor(code: ErrorCode, message: string) {
|
||||
super('');
|
||||
|
||||
// If e.toString() was called to get `message` we don't want it to look
|
||||
// like "Error: Error:".
|
||||
if (message.startsWith(ERROR_PREFIX)) {
|
||||
message = message.substring(ERROR_PREFIX.length);
|
||||
}
|
||||
|
||||
this.message = message;
|
||||
this.code = code;
|
||||
this.isXDLError = true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
import fs from 'fs-extra';
|
||||
import { sync as globSync } from 'glob';
|
||||
import path from 'path';
|
||||
import logger from 'turtle/logger';
|
||||
|
||||
import { getImageDimensionsAsync, resizeImageAsync } from '../ImageUtils';
|
||||
import {
|
||||
regexFileAsync,
|
||||
saveImageToPathAsync,
|
||||
saveUrlToPathAsync,
|
||||
spawnAsyncThrowError,
|
||||
} from './ExponentTools';
|
||||
import {
|
||||
AnyStandaloneContext,
|
||||
IStandaloneContextDataUser,
|
||||
} from './StandaloneContext';
|
||||
|
||||
const iconScales = {
|
||||
mdpi: 1,
|
||||
hdpi: 1.5,
|
||||
xhdpi: 2,
|
||||
xxhdpi: 3,
|
||||
xxxhdpi: 4,
|
||||
};
|
||||
|
||||
async function regexFileInResSubfoldersAsync(
|
||||
oldText: string,
|
||||
newText: string,
|
||||
resDirPath: string,
|
||||
folderPrefix: string,
|
||||
folderSuffix: string,
|
||||
fileName: string,
|
||||
) {
|
||||
return Promise.all(
|
||||
Object.keys(iconScales).map(async (key) => {
|
||||
return regexFileAsync(
|
||||
oldText,
|
||||
newText,
|
||||
path.join(resDirPath, `${folderPrefix}${key}${folderSuffix}`, fileName),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
let hasShownResizeErrorWindowsLinux = false;
|
||||
|
||||
async function resizeIconsAsync(
|
||||
context: AnyStandaloneContext,
|
||||
resPath: string,
|
||||
prefix: string,
|
||||
mdpiSize: number,
|
||||
filename: string,
|
||||
url: string,
|
||||
isDetached: boolean,
|
||||
) {
|
||||
const baseImagePath = path.join(resPath, filename);
|
||||
|
||||
try {
|
||||
if (isDetached) {
|
||||
const data = context.data as IStandaloneContextDataUser;
|
||||
await saveImageToPathAsync(data.projectPath, url, baseImagePath);
|
||||
} else {
|
||||
await saveUrlToPathAsync(url, baseImagePath);
|
||||
}
|
||||
} catch (e) {
|
||||
throw new Error(`Failed to save icon file to disk. (${e})`);
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
Object.entries(iconScales).map(async ([folderSuffix, iconScale]) => {
|
||||
// adaptive icons (mdpiSize 108) must be placed in a -v26 folder
|
||||
const subdirectoryName = `${prefix}${folderSuffix}${
|
||||
mdpiSize === 108 ? '-v26' : ''
|
||||
}`;
|
||||
const destinationPath = path.join(resPath, subdirectoryName);
|
||||
await spawnAsyncThrowError('/bin/cp', [baseImagePath, filename], {
|
||||
stdio: 'inherit',
|
||||
cwd: destinationPath,
|
||||
});
|
||||
|
||||
try {
|
||||
await resizeImageAsync(mdpiSize * iconScale, filename, destinationPath);
|
||||
} catch (e) {
|
||||
// Turtle should be able to resize images, so if it fails we want it to throw.
|
||||
// However, `sips` does not exist on Windows or Linux machines, so we expect
|
||||
// resizing images to error on these OSes and want the detach process to continue anyway.
|
||||
if (isDetached) {
|
||||
if (!hasShownResizeErrorWindowsLinux) {
|
||||
logger.warn(
|
||||
'Failed to resize app icons. ' +
|
||||
'Your full size icon will be copied to all android/app/src/main/res directories. ' +
|
||||
'For best quality, we recommend providing downscaled versions.',
|
||||
);
|
||||
hasShownResizeErrorWindowsLinux = true;
|
||||
}
|
||||
} else {
|
||||
throw new Error(`Failed to resize image: ${filename}. ${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
// reject non-square icons
|
||||
const dims = await getImageDimensionsAsync(destinationPath, filename);
|
||||
if (!dims) {
|
||||
// Again, only throw this error on Turtle -- we expect that this will fail
|
||||
// for some detach users but we don't want this to stop the whole process.
|
||||
if (!isDetached) {
|
||||
throw new Error(`Unable to read the dimensions of ${filename}`);
|
||||
}
|
||||
} else if (dims.width !== dims.height) {
|
||||
throw new Error(
|
||||
`Android icons must be square, the dimensions of ${filename} are ${dims}`,
|
||||
);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
await spawnAsyncThrowError('/bin/rm', [baseImagePath]);
|
||||
}
|
||||
|
||||
async function createAndWriteIconsToPathAsync(
|
||||
context: AnyStandaloneContext,
|
||||
resPath: string,
|
||||
isDetached: boolean,
|
||||
) {
|
||||
const manifest = context.config; // manifest or app.json
|
||||
let iconUrl =
|
||||
manifest.android && manifest.android.iconUrl
|
||||
? manifest.android.iconUrl
|
||||
: manifest.iconUrl;
|
||||
let notificationIconUrl = manifest.notification
|
||||
? manifest.notification.iconUrl
|
||||
: null;
|
||||
|
||||
if (isDetached) {
|
||||
// manifest is actually just app.json in this case, so iconUrl fields don't exist
|
||||
iconUrl =
|
||||
manifest.android && manifest.android.icon
|
||||
? manifest.android.icon
|
||||
: manifest.icon;
|
||||
notificationIconUrl = manifest.notification
|
||||
? manifest.notification.icon
|
||||
: null;
|
||||
}
|
||||
|
||||
let iconBackgroundUrl;
|
||||
let iconBackgroundColor;
|
||||
let iconForegroundUrl;
|
||||
if (manifest.android && manifest.android.adaptiveIcon) {
|
||||
iconBackgroundColor = manifest.android.adaptiveIcon.backgroundColor;
|
||||
if (isDetached) {
|
||||
iconForegroundUrl = manifest.android.adaptiveIcon.foregroundImage;
|
||||
iconBackgroundUrl = manifest.android.adaptiveIcon.backgroundImage;
|
||||
} else {
|
||||
iconForegroundUrl = manifest.android.adaptiveIcon.foregroundImageUrl;
|
||||
iconBackgroundUrl = manifest.android.adaptiveIcon.backgroundImageUrl;
|
||||
}
|
||||
}
|
||||
|
||||
if (iconUrl || iconForegroundUrl) {
|
||||
// Android 7 and below icon
|
||||
if (iconUrl) {
|
||||
globSync('**/ic_launcher.png', {
|
||||
cwd: resPath,
|
||||
absolute: true,
|
||||
}).forEach((filePath) => {
|
||||
fs.removeSync(filePath);
|
||||
});
|
||||
|
||||
await resizeIconsAsync(
|
||||
context,
|
||||
resPath,
|
||||
'mipmap-',
|
||||
48,
|
||||
'ic_launcher.png',
|
||||
iconUrl,
|
||||
isDetached,
|
||||
);
|
||||
}
|
||||
|
||||
// Adaptive icon foreground image
|
||||
if (iconForegroundUrl) {
|
||||
globSync('**/ic_foreground.png', {
|
||||
cwd: resPath,
|
||||
absolute: true,
|
||||
}).forEach((filePath) => {
|
||||
fs.removeSync(filePath);
|
||||
});
|
||||
|
||||
await resizeIconsAsync(
|
||||
context,
|
||||
resPath,
|
||||
'mipmap-',
|
||||
108,
|
||||
'ic_foreground.png',
|
||||
iconForegroundUrl,
|
||||
isDetached,
|
||||
);
|
||||
} else {
|
||||
// the OS's default method of coercing normal app icons to adaptive
|
||||
// makes them look quite different from using an actual adaptive icon (with xml)
|
||||
// so we need to support falling back to the old version on Android 8
|
||||
globSync('**/mipmap-*-v26/*', {
|
||||
cwd: resPath,
|
||||
absolute: true,
|
||||
dot: true,
|
||||
}).forEach((filePath) => {
|
||||
fs.removeSync(filePath);
|
||||
});
|
||||
|
||||
try {
|
||||
globSync('**/mipmap-*-v26', {
|
||||
cwd: resPath,
|
||||
absolute: true,
|
||||
}).forEach((filePath) => {
|
||||
fs.rmdirSync(filePath);
|
||||
});
|
||||
} catch (e) {
|
||||
// we don't want the entire detach script to fail if node
|
||||
// can't remove the directories for whatever reason.
|
||||
// people can remove the directories themselves if they need
|
||||
// so just fail silently here
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Adaptive icon background image or color
|
||||
if (iconBackgroundUrl) {
|
||||
await resizeIconsAsync(
|
||||
context,
|
||||
resPath,
|
||||
'mipmap-',
|
||||
108,
|
||||
'ic_background.png',
|
||||
iconBackgroundUrl,
|
||||
isDetached,
|
||||
);
|
||||
|
||||
await regexFileInResSubfoldersAsync(
|
||||
'@color/iconBackground',
|
||||
'@mipmap/ic_background',
|
||||
resPath,
|
||||
'mipmap-',
|
||||
'-v26',
|
||||
'ic_launcher.xml',
|
||||
);
|
||||
} else if (iconBackgroundColor) {
|
||||
await regexFileAsync(
|
||||
'"iconBackground">#FFFFFF',
|
||||
`"iconBackground">${iconBackgroundColor}`,
|
||||
path.join(resPath, 'values', 'colors.xml'),
|
||||
);
|
||||
}
|
||||
|
||||
// Remove Expo client notification icon resources
|
||||
globSync('**/shell_notification_icon.png', {
|
||||
cwd: resPath,
|
||||
absolute: true,
|
||||
}).forEach((filePath) => {
|
||||
fs.removeSync(filePath);
|
||||
});
|
||||
|
||||
// Add provided notification icon resources, falling back
|
||||
// to the app icon if no `notification.icon` is provided
|
||||
await resizeIconsAsync(
|
||||
context,
|
||||
resPath,
|
||||
'drawable-',
|
||||
24,
|
||||
'shell_notification_icon.png',
|
||||
notificationIconUrl ?? iconUrl,
|
||||
isDetached,
|
||||
);
|
||||
}
|
||||
|
||||
export { createAndWriteIconsToPathAsync };
|
||||
@@ -0,0 +1,42 @@
|
||||
export default function renderIntentFilters(intentFilters: any) {
|
||||
// returns an array of <intent-filter> tags:
|
||||
// [
|
||||
// `<intent-filter>
|
||||
// <data android:scheme="exp"/>
|
||||
// <data android:scheme="exps"/>
|
||||
//
|
||||
// <action android:name="android.intent.action.VIEW"/>
|
||||
//
|
||||
// <category android:name="android.intent.category.DEFAULT"/>
|
||||
// <category android:name="android.intent.category.BROWSABLE"/>
|
||||
// </intent-filter>`,
|
||||
// ...
|
||||
// ]
|
||||
return intentFilters.map((intentFilter: any) => {
|
||||
const autoVerify = intentFilter.autoVerify ? ' android:autoVerify="true"' : '';
|
||||
|
||||
return `<intent-filter${autoVerify}>
|
||||
${renderIntentFilterData(intentFilter.data)}
|
||||
<action android:name="android.intent.action.${intentFilter.action}"/>
|
||||
${renderIntentFilterCategory(intentFilter.category)}
|
||||
</intent-filter>`;
|
||||
});
|
||||
}
|
||||
|
||||
function renderIntentFilterDatumEntries(datum: any) {
|
||||
return Object.entries(datum)
|
||||
.map((entry) => `android:${entry[0]}="${entry[1]}"`)
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
function renderIntentFilterData(data: any) {
|
||||
return (Array.isArray(data) ? data : [data])
|
||||
.map((datum) => `<data ${renderIntentFilterDatumEntries(datum)}/>`)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function renderIntentFilterCategory(category: any) {
|
||||
return (Array.isArray(category) ? category : [category])
|
||||
.map((cat) => `<category android:name="android.intent.category.${cat}"/>`)
|
||||
.join('\n');
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,76 @@
|
||||
import fs from 'fs-extra';
|
||||
import pMap from 'p-map';
|
||||
import pRetry from 'p-retry';
|
||||
import path from 'path';
|
||||
import logger from 'turtle/logger';
|
||||
import url from 'url';
|
||||
import urlJoin from 'url-join';
|
||||
|
||||
import { saveUrlToPathAsync } from './ExponentTools';
|
||||
import { AnyStandaloneContext } from './StandaloneContext';
|
||||
|
||||
const EXPO_DOMAINS = ['expo.io', 'exp.host', 'expo.test', 'localhost'];
|
||||
export const DEFAULT_CDN_HOST = 'https://d1wp6m56sqw74a.cloudfront.net';
|
||||
export const ASSETS_DIR_DEFAULT_URL = `${DEFAULT_CDN_HOST}/~assets`;
|
||||
|
||||
type UrlResolver = (hash: string) => string;
|
||||
|
||||
export async function bundleAsync(
|
||||
context: any,
|
||||
assets: string[],
|
||||
dest: string,
|
||||
exportUrl?: string | null,
|
||||
) {
|
||||
if (!assets) {
|
||||
return;
|
||||
}
|
||||
|
||||
await fs.ensureDir(dest);
|
||||
|
||||
const urlResolver = createAssetsUrlResolver(context, exportUrl);
|
||||
await pMap(assets, (asset) => downloadAssetAsync(urlResolver, dest, asset), {
|
||||
concurrency: 5,
|
||||
});
|
||||
}
|
||||
|
||||
async function downloadAssetAsync(
|
||||
urlResolver: UrlResolver,
|
||||
dest: string,
|
||||
asset: string,
|
||||
) {
|
||||
const extensionIndex = asset.lastIndexOf('.');
|
||||
const prefixLength = 'asset_'.length;
|
||||
const hash =
|
||||
extensionIndex >= 0
|
||||
? asset.substring(prefixLength, extensionIndex)
|
||||
: asset.substring(prefixLength);
|
||||
logger.info(urlResolver(hash));
|
||||
await pRetry(
|
||||
() => saveUrlToPathAsync(urlResolver(hash), path.join(dest, asset)),
|
||||
{ retries: 3 },
|
||||
);
|
||||
}
|
||||
|
||||
function createAssetsUrlResolver(
|
||||
context: AnyStandaloneContext,
|
||||
exportUrl?: string | null,
|
||||
): UrlResolver {
|
||||
let assetsDirUrl = exportUrl ? `${exportUrl}/assets` : ASSETS_DIR_DEFAULT_URL;
|
||||
|
||||
if (context && context.published && context.published.url) {
|
||||
const { assetUrlOverride = './assets' } = context.config;
|
||||
const publishedUrl = context.published.url;
|
||||
const { hostname } = url.parse(publishedUrl);
|
||||
if (hostname == null) {
|
||||
throw new Error(
|
||||
`Could not resolve asset URLs relative to "${publishedUrl}". Published URL must be an absolute URL.`,
|
||||
);
|
||||
}
|
||||
const maybeExpoDomain = hostname.split('.').slice(-2).join('.');
|
||||
if (!EXPO_DOMAINS.includes(maybeExpoDomain)) {
|
||||
assetsDirUrl = url.resolve(publishedUrl, assetUrlOverride);
|
||||
}
|
||||
}
|
||||
|
||||
return (hash) => urlJoin(assetsDirUrl, hash);
|
||||
}
|
||||
@@ -0,0 +1,565 @@
|
||||
// Set EXPO_VIEW_DIR to universe/exponent to test locally
|
||||
|
||||
import { findConfigFile, getConfig } from '@expo/config';
|
||||
import JsonFile from '@expo/json-file';
|
||||
import spawnAsync from '@expo/spawn-async';
|
||||
import fs from 'fs-extra';
|
||||
import { sync as globSync } from 'glob';
|
||||
import isPlainObject from 'lodash/isPlainObject';
|
||||
import path from 'path';
|
||||
import process from 'process';
|
||||
import prompts from 'prompts';
|
||||
import rimraf from 'rimraf';
|
||||
import uuid from 'uuid';
|
||||
|
||||
import { Versions, EmbeddedAssets, UrlUtils, UserManager, Api } from '@expo/xdl';
|
||||
|
||||
import XDLError from '../XDLError';
|
||||
|
||||
import * as AndroidShellApp from './AndroidShellApp';
|
||||
import * as AssetBundle from './AssetBundle';
|
||||
import { isDirectory, regexFileAsync, rimrafDontThrow } from './ExponentTools';
|
||||
import * as IosNSBundle from './IosNSBundle';
|
||||
import * as IosPlist from './IosPlist';
|
||||
import * as IosWorkspace from './IosWorkspace';
|
||||
import logger from './Logger';
|
||||
import StandaloneBuildFlags from './StandaloneBuildFlags';
|
||||
import StandaloneContext from './StandaloneContext';
|
||||
import installPackagesAsync from './installPackagesAsync';
|
||||
|
||||
const SERVICE_CONTEXT_PROJECT_NAME = 'exponent-view-template';
|
||||
|
||||
async function yesnoAsync(message) {
|
||||
const { ok } = await prompts({
|
||||
type: 'confirm',
|
||||
name: 'ok',
|
||||
initial: true,
|
||||
message,
|
||||
});
|
||||
return ok;
|
||||
}
|
||||
|
||||
export async function detachAsync(projectRoot, options = {}) {
|
||||
const originalLogger = logger.loggerObj;
|
||||
logger.configure({
|
||||
trace: options.verbose ? console.trace.bind(console) : () => {},
|
||||
debug: options.verbose ? console.debug.bind(console) : () => {},
|
||||
info: options.verbose ? console.info.bind(console) : () => {},
|
||||
warn: console.warn.bind(console),
|
||||
error: console.error.bind(console),
|
||||
fatal: console.error.bind(console),
|
||||
});
|
||||
try {
|
||||
return await _detachAsync(projectRoot, options);
|
||||
} finally {
|
||||
logger.configure(originalLogger);
|
||||
}
|
||||
}
|
||||
|
||||
async function _detachAsync(projectRoot, options) {
|
||||
const user = await UserManager.ensureLoggedInAsync();
|
||||
|
||||
if (!user) {
|
||||
throw new Error('Internal error -- somehow detach is being run in offline mode.');
|
||||
}
|
||||
|
||||
const username = user.username;
|
||||
const { configName, configPath, configNamespace } = findConfigFile(projectRoot);
|
||||
let { exp } = getConfig(projectRoot);
|
||||
const experienceName = `@${username}/${exp.slug}`;
|
||||
const experienceUrl = `exp://exp.host/${experienceName}`;
|
||||
|
||||
// Check to make sure project isn't fully detached already
|
||||
const hasIosDirectory = isDirectory(path.join(projectRoot, 'ios'));
|
||||
const hasAndroidDirectory = isDirectory(path.join(projectRoot, 'android'));
|
||||
|
||||
if (hasIosDirectory && hasAndroidDirectory) {
|
||||
throw new XDLError(
|
||||
'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') {
|
||||
const response = await yesnoAsync(
|
||||
`This will add an Xcode project and leave your existing Android project alone. Enter 'yes' to continue:`
|
||||
);
|
||||
if (!response) {
|
||||
logger.info('Exiting...');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasIosDirectory && !hasAndroidDirectory) {
|
||||
throw new Error('`ios` directory already exists. Please remove it and try again.');
|
||||
}
|
||||
|
||||
logger.info('Validating project manifest...');
|
||||
if (!exp.name) {
|
||||
throw new Error(`${configName} is missing \`name\``);
|
||||
}
|
||||
|
||||
if (!exp.sdkVersion) {
|
||||
throw new Error(`${configName} is missing \`sdkVersion\``);
|
||||
}
|
||||
|
||||
if (!Versions.gteSdkVersion(exp, '25.0.0')) {
|
||||
throw new Error(
|
||||
`The app must be updated to SDK 25.0.0 or newer to be compatible with this tool.`
|
||||
);
|
||||
}
|
||||
|
||||
const versions = await Versions.versionsAsync();
|
||||
let sdkVersionConfig = versions.sdkVersions[exp.sdkVersion];
|
||||
if (
|
||||
!sdkVersionConfig ||
|
||||
(!sdkVersionConfig.androidExpoViewUrl && !sdkVersionConfig.iosExpoViewUrl)
|
||||
) {
|
||||
if (process.env.EXPO_VIEW_DIR) {
|
||||
logger.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}`);
|
||||
}
|
||||
}
|
||||
|
||||
exp.isDetached = true;
|
||||
|
||||
if (!exp.detach) {
|
||||
exp.detach = {};
|
||||
}
|
||||
|
||||
const detachedUUID = uuid.v4().replace(/-/g, '');
|
||||
const generatedScheme = `exp${detachedUUID}`;
|
||||
|
||||
if (!exp.detach.scheme && !Versions.gteSdkVersion(exp, '27.0.0')) {
|
||||
// set this for legacy purposes
|
||||
exp.detach.scheme = generatedScheme;
|
||||
}
|
||||
|
||||
const linkingWarning = `You have not specified a custom scheme for deep linking. A default value of ${generatedScheme} will be used. You can change this later by following the instructions in this guide: https://docs.expo.io/workflow/linking/`;
|
||||
if (!exp.scheme) {
|
||||
logger.info(linkingWarning);
|
||||
exp.scheme = generatedScheme;
|
||||
} else if (Array.isArray(exp.scheme) && exp.scheme.length === 0) {
|
||||
logger.info(linkingWarning);
|
||||
exp.scheme.push(generatedScheme);
|
||||
}
|
||||
|
||||
const expoDirectory = path.join(projectRoot, '.expo-source');
|
||||
fs.mkdirpSync(expoDirectory);
|
||||
const context = StandaloneContext.createUserContext(projectRoot, exp, experienceUrl);
|
||||
|
||||
// iOS
|
||||
let isIosSupported = true;
|
||||
if (process.platform !== 'darwin') {
|
||||
if (options && options.force) {
|
||||
logger.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 {
|
||||
logger.warn(`Skipping iOS because you are not running macOS.`);
|
||||
isIosSupported = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasIosDirectory && isIosSupported && sdkVersionConfig.iosExpoViewUrl) {
|
||||
if (!exp.ios) {
|
||||
exp.ios = {};
|
||||
}
|
||||
if (!exp.ios.bundleIdentifier) {
|
||||
logger.info(
|
||||
`You'll need to specify an iOS bundle identifier. See: https://docs.expo.io/versions/latest/config/app/#ios`
|
||||
);
|
||||
const { iosBundleIdentifier } = await prompts({
|
||||
type: 'text',
|
||||
name: 'iosBundleIdentifier',
|
||||
message: 'What would you like your iOS bundle identifier to be?',
|
||||
validate: value => /^[a-zA-Z][a-zA-Z0-9\-.]+$/.test(value),
|
||||
});
|
||||
exp.ios.bundleIdentifier = iosBundleIdentifier;
|
||||
}
|
||||
|
||||
await detachIOSAsync(context);
|
||||
exp = IosWorkspace.addDetachedConfigToExp(exp, context);
|
||||
exp.detach.iosExpoViewUrl = sdkVersionConfig.iosExpoViewUrl;
|
||||
}
|
||||
|
||||
// Android
|
||||
if (!hasAndroidDirectory && sdkVersionConfig.androidExpoViewUrl) {
|
||||
if (!exp.android) {
|
||||
exp.android = {};
|
||||
}
|
||||
if (!exp.android.package) {
|
||||
logger.info(
|
||||
`You'll need to specify an Android package name. See: https://docs.expo.io/versions/latest/config/app/#android`
|
||||
);
|
||||
const { androidPackage } = await prompts({
|
||||
type: 'text',
|
||||
name: 'androidPackage',
|
||||
message: 'What would you like your Android package name to be?',
|
||||
validate: value =>
|
||||
/^[a-zA-Z][a-zA-Z0-9_]*(\.[a-zA-Z][a-zA-Z0-9_]*)+$/.test(value)
|
||||
? true
|
||||
: "Invalid format of Android package name (only alphanumeric characters, '.' and '_' are allowed, and each '.' must be followed by a letter)",
|
||||
});
|
||||
exp.android.package = androidPackage;
|
||||
}
|
||||
|
||||
const androidDirectory = path.join(expoDirectory, 'android');
|
||||
rimraf.sync(androidDirectory);
|
||||
fs.mkdirpSync(androidDirectory);
|
||||
await detachAndroidAsync(context, sdkVersionConfig.androidExpoViewUrl);
|
||||
exp = AndroidShellApp.addDetachedConfigToExp(exp, context);
|
||||
exp.detach.androidExpoViewUrl = sdkVersionConfig.androidExpoViewUrl;
|
||||
}
|
||||
|
||||
logger.info('Writing ExpoKit configuration...');
|
||||
// if we're writing to app.json, we need to place the configuration under the expo key
|
||||
const config = configNamespace ? { [configNamespace]: exp } : exp;
|
||||
await fs.writeFile(configPath, JSON.stringify(config, null, 2));
|
||||
|
||||
const packagesToInstall = [];
|
||||
const nodeModulesPath = exp.nodeModulesPath
|
||||
? path.resolve(projectRoot, exp.nodeModulesPath)
|
||||
: projectRoot;
|
||||
|
||||
if (sdkVersionConfig && sdkVersionConfig.expoReactNativeTag) {
|
||||
packagesToInstall.push(
|
||||
`react-native@https://github.com/expo/react-native/archive/${sdkVersionConfig.expoReactNativeTag}.tar.gz`
|
||||
);
|
||||
} else if (process.env.EXPO_VIEW_DIR) {
|
||||
// ignore, using test directory
|
||||
} else {
|
||||
throw new Error(`Expo's fork of react-native does not support this SDK version.`);
|
||||
}
|
||||
|
||||
// Add expokitNpmPackage if it is supported. Was added before SDK 29.
|
||||
if (process.env.EXPO_VIEW_DIR) {
|
||||
logger.info(`Linking 'expokit' package...`);
|
||||
await spawnAsync('yarn', ['link'], {
|
||||
cwd: path.join(process.env.EXPO_VIEW_DIR, 'expokit-npm-package'),
|
||||
});
|
||||
await spawnAsync('yarn', ['link', 'expokit'], {
|
||||
cwd: nodeModulesPath,
|
||||
});
|
||||
} else if (sdkVersionConfig.expokitNpmPackage) {
|
||||
packagesToInstall.push(sdkVersionConfig.expokitNpmPackage);
|
||||
}
|
||||
|
||||
if (sdkVersionConfig) {
|
||||
const { packagesToInstallWhenEjecting } = sdkVersionConfig;
|
||||
if (isPlainObject(packagesToInstallWhenEjecting)) {
|
||||
Object.keys(packagesToInstallWhenEjecting).forEach(packageName => {
|
||||
packagesToInstall.push(`${packageName}@${packagesToInstallWhenEjecting[packageName]}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (packagesToInstall.length) {
|
||||
await installPackagesAsync(projectRoot, packagesToInstall, {
|
||||
packageManager: options.packageManager,
|
||||
});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a detached Expo iOS app pointing at the given project.
|
||||
*/
|
||||
async function detachIOSAsync(context) {
|
||||
await IosWorkspace.createDetachedAsync(context);
|
||||
|
||||
logger.info('Configuring iOS project...');
|
||||
await IosNSBundle.configureAsync(context);
|
||||
|
||||
logger.info(`iOS detach is complete!`);
|
||||
}
|
||||
|
||||
async function detachAndroidAsync(context, expoViewUrl) {
|
||||
if (context.type !== 'user') {
|
||||
throw new Error(`detachAndroidAsync only supports user standalone contexts`);
|
||||
}
|
||||
|
||||
logger.info('Moving Android project files...');
|
||||
const 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,
|
||||
context.data.exp.sdkVersion
|
||||
);
|
||||
} else {
|
||||
tmpExpoDirectory = path.join(context.data.projectPath, 'temp-android-directory');
|
||||
fs.mkdirpSync(tmpExpoDirectory);
|
||||
logger.info('Downloading Android code...');
|
||||
await Api.downloadAsync(expoViewUrl, tmpExpoDirectory, { extract: true });
|
||||
await AndroidShellApp.copyInitialShellAppFilesAsync(
|
||||
tmpExpoDirectory,
|
||||
androidProjectDirectory,
|
||||
true,
|
||||
context.data.exp.sdkVersion
|
||||
);
|
||||
}
|
||||
|
||||
logger.info('Updating Android app...');
|
||||
await AndroidShellApp.runShellAppModificationsAsync(context, context.data.exp.sdkVersion);
|
||||
|
||||
// Clean up
|
||||
logger.info('Cleaning up Android...');
|
||||
if (!process.env.EXPO_VIEW_DIR) {
|
||||
rimrafDontThrow(tmpExpoDirectory);
|
||||
}
|
||||
logger.info('Android detach is complete!\n');
|
||||
}
|
||||
|
||||
async function ensureBuildConstantsExistsIOSAsync(configFilePath) {
|
||||
// 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');
|
||||
logger.info('Created `EXBuildConstants.plist` because it did not exist yet');
|
||||
}
|
||||
}
|
||||
|
||||
async function _getIosExpoKitVersionThrowErrorAsync(iosProjectDirectory) {
|
||||
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;
|
||||
const 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 readNullableConfigJsonAsync(projectDir) {
|
||||
try {
|
||||
return getConfig(projectDir);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function prepareDetachedBuildIosAsync(projectDir, args) {
|
||||
const config = await readNullableConfigJsonAsync(projectDir);
|
||||
if (config && config.exp.name !== SERVICE_CONTEXT_PROJECT_NAME) {
|
||||
return prepareDetachedUserContextIosAsync(projectDir, config.exp, args);
|
||||
} else {
|
||||
return prepareDetachedServiceContextIosAsync(projectDir, args);
|
||||
}
|
||||
}
|
||||
|
||||
async function prepareDetachedServiceContextIosAsync(projectDir, args) {
|
||||
// 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 expoRootDir = path.join(projectDir, '..', '..');
|
||||
const workspaceSourcePath = path.join(projectDir, 'ios');
|
||||
const buildFlags = StandaloneBuildFlags.createIos('Release', { workspaceSourcePath });
|
||||
const context = StandaloneContext.createServiceContext(
|
||||
expoRootDir,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
/* testEnvironment */ 'none',
|
||||
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')
|
||||
);
|
||||
|
||||
const { exp } = getConfig(expoRootDir, { skipSDKVersionRequirement: true });
|
||||
|
||||
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;
|
||||
constantsConfig.API_SERVER_ENDPOINT =
|
||||
process.env.ENVIRONMENT === 'staging'
|
||||
? 'https://staging.exp.host/--/api/v2/'
|
||||
: 'https://exp.host/--/api/v2/';
|
||||
if (prodApiKeys) {
|
||||
constantsConfig.DEFAULT_API_KEYS = prodApiKeys;
|
||||
}
|
||||
if (exp && exp.sdkVersion) {
|
||||
constantsConfig.TEMPORARY_SDK_VERSION = exp.sdkVersion;
|
||||
}
|
||||
return constantsConfig;
|
||||
});
|
||||
}
|
||||
|
||||
async function _readDefaultApiKeysAsync(jsonFilePath) {
|
||||
if (fs.existsSync(jsonFilePath)) {
|
||||
const 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, exp, args) {
|
||||
const context = StandaloneContext.createUserContext(projectDir, exp);
|
||||
const { iosProjectDirectory, supportingDirectory } = IosWorkspace.getPaths(context);
|
||||
|
||||
logger.info(`Preparing iOS build at ${iosProjectDirectory}...`);
|
||||
// These files cause @providesModule naming collisions
|
||||
// but are not available until after `pod install` has run.
|
||||
const podsDirectory = path.join(iosProjectDirectory, 'Pods');
|
||||
if (!isDirectory(podsDirectory)) {
|
||||
throw new Error(`Can't find directory ${podsDirectory}, make sure you've run pod install.`);
|
||||
}
|
||||
const rnPodDirectory = path.join(podsDirectory, 'React');
|
||||
if (isDirectory(rnPodDirectory)) {
|
||||
const rnFilesToDelete = globSync('**/*.@(js|json)', {
|
||||
absolute: true,
|
||||
cwd: rnPodDirectory,
|
||||
});
|
||||
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
|
||||
const 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;
|
||||
}
|
||||
if (exp.sdkVersion) {
|
||||
constantsConfig.TEMPORARY_SDK_VERSION = exp.sdkVersion;
|
||||
}
|
||||
return constantsConfig;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function prepareDetachedBuildAsync(projectDir, args) {
|
||||
if (args.platform === 'ios') {
|
||||
await prepareDetachedBuildIosAsync(projectDir, args);
|
||||
} else {
|
||||
const expoBuildConstantsMatches = globSync('android/**/DetachBuildConstants.java', {
|
||||
absolute: true,
|
||||
cwd: projectDir,
|
||||
});
|
||||
if (expoBuildConstantsMatches && expoBuildConstantsMatches.length) {
|
||||
const expoBuildConstants = expoBuildConstantsMatches[0];
|
||||
const devUrl = await UrlUtils.constructManifestUrlAsync(projectDir);
|
||||
await regexFileAsync(
|
||||
/DEVELOPMENT_URL = "[^"]*";/,
|
||||
`DEVELOPMENT_URL = "${devUrl}";`,
|
||||
expoBuildConstants
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// args.dest: string,
|
||||
// This is the path where assets will be copied to. It should be
|
||||
// `$CONFIGURATION_BUILD_DIR/$UNLOCALIZED_RESOURCES_FOLDER_PATH` on iOS
|
||||
// (see `exponent-view-template.xcodeproj/project.pbxproj` for an example)
|
||||
// and `$buildDir/intermediates/assets/$targetPath` on Android (see
|
||||
// `android/app/expo.gradle` for an example).
|
||||
export async function bundleAssetsAsync(projectDir, args) {
|
||||
const options = await readNullableConfigJsonAsync(projectDir);
|
||||
if (!options || options.exp.name === SERVICE_CONTEXT_PROJECT_NAME) {
|
||||
// Don't run assets bundling for the service context.
|
||||
return;
|
||||
}
|
||||
const { exp } = options;
|
||||
const bundledManifestPath = EmbeddedAssets.getEmbeddedManifestPath(
|
||||
args.platform,
|
||||
projectDir,
|
||||
exp
|
||||
);
|
||||
if (!bundledManifestPath) {
|
||||
logger.warn(
|
||||
`Skipped assets bundling because the '${args.platform}.publishManifestPath' key is not specified in the app manifest.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let manifest;
|
||||
try {
|
||||
manifest = JSON.parse(await fs.readFile(bundledManifestPath, 'utf8'));
|
||||
} catch (ex) {
|
||||
throw new Error(
|
||||
`Error reading the manifest file. Make sure the path '${bundledManifestPath}' is correct.\n\nError: ${ex.message}`
|
||||
);
|
||||
}
|
||||
if (!manifest || !Object.keys(manifest).length) {
|
||||
throw new Error(`The manifest at '${bundledManifestPath}' was empty or invalid.`);
|
||||
}
|
||||
|
||||
await AssetBundle.bundleAsync(null, manifest.bundledAssets, args.dest, getExportUrl(manifest));
|
||||
}
|
||||
|
||||
/**
|
||||
* This function extracts the exported public URL that is set in the manifest
|
||||
* when the developer runs `expo export --public-url x`. We use this to ensure
|
||||
* that we fetch the resources from the appropriate place when doing builds
|
||||
* against self-hosted apps.
|
||||
*/
|
||||
function getExportUrl(manifest) {
|
||||
const { bundleUrl } = manifest;
|
||||
if (bundleUrl.includes(AssetBundle.DEFAULT_CDN_HOST)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const bundleUrlParts = bundleUrl.split('/');
|
||||
return bundleUrlParts.slice(0, bundleUrlParts.length - 2).join('/');
|
||||
} catch (e) {
|
||||
throw Error(
|
||||
`Expected bundleUrl to be of the format https://domain/bundles/bundle-hash-id, ${bundleUrl} does not follow this format.`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
import { ExpoConfig, Platform } from '@expo/config';
|
||||
import spawnAsyncQuiet, { SpawnOptions, SpawnResult } from '@expo/spawn-async';
|
||||
import axios from 'axios';
|
||||
import fs from 'fs-extra';
|
||||
import isObject from 'lodash/isObject';
|
||||
import path from 'path';
|
||||
import { Readable } from 'stream';
|
||||
|
||||
import XDLError from '../XDLError';
|
||||
import LoggerDetach, { Logger, pipeOutputToLogger } from './Logger';
|
||||
|
||||
function getManifestFileNameForSdkVersion(sdkVersion: string) {
|
||||
if (parseSdkMajorVersion(sdkVersion) < 39) {
|
||||
return 'shell-app-manifest.json';
|
||||
} else {
|
||||
return 'app.manifest';
|
||||
}
|
||||
}
|
||||
|
||||
function getBundleFileNameForSdkVersion(sdkVersion: string) {
|
||||
if (parseSdkMajorVersion(sdkVersion) < 39) {
|
||||
return 'shell-app.bundle';
|
||||
} else {
|
||||
return 'app.bundle';
|
||||
}
|
||||
}
|
||||
|
||||
function parseSdkMajorVersion(expSdkVersion: string) {
|
||||
// We assume that the unversioned SDK is the latest
|
||||
if (expSdkVersion === 'UNVERSIONED') {
|
||||
return Infinity;
|
||||
}
|
||||
|
||||
let sdkMajorVersion = 0;
|
||||
try {
|
||||
const versionComponents = expSdkVersion
|
||||
.split('.')
|
||||
.map((num) => parseInt(num, 10));
|
||||
sdkMajorVersion = versionComponents[0];
|
||||
} catch {
|
||||
sdkMajorVersion = 0;
|
||||
}
|
||||
return sdkMajorVersion;
|
||||
}
|
||||
|
||||
async function saveUrlToPathAsync(
|
||||
url: string,
|
||||
filePath: string,
|
||||
timeout = 20000,
|
||||
) {
|
||||
const response = await axios.get(url, { responseType: 'stream', timeout });
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const stream = fs.createWriteStream(filePath);
|
||||
stream.on('close', resolve);
|
||||
stream.on('error', reject);
|
||||
response.data.on('error', reject).pipe(stream);
|
||||
});
|
||||
}
|
||||
|
||||
async function saveImageToPathAsync(
|
||||
projectRoot: string,
|
||||
pathOrURL: string,
|
||||
outPath: string,
|
||||
) {
|
||||
const localPath = path.resolve(projectRoot, pathOrURL);
|
||||
|
||||
if (fs.existsSync(localPath)) {
|
||||
await fs.copy(localPath, outPath);
|
||||
} else {
|
||||
await saveUrlToPathAsync(pathOrURL, outPath, 0);
|
||||
}
|
||||
}
|
||||
|
||||
async function getManifestAsync(url: string, headers: any, options: any = {}) {
|
||||
const buildPhaseLogger =
|
||||
options.logger ||
|
||||
LoggerDetach.withFields({ buildPhase: 'reading manifest' });
|
||||
|
||||
let response;
|
||||
try {
|
||||
response = await _retryPromise(() =>
|
||||
axios.get(url.replace('exp://', 'http://'), { headers }),
|
||||
);
|
||||
} catch (err) {
|
||||
buildPhaseLogger.error(err);
|
||||
throw new Error('Failed to fetch manifest from www');
|
||||
}
|
||||
|
||||
buildPhaseLogger.info(
|
||||
'Using manifest:',
|
||||
JSON.stringify(response.data, null, 2),
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function _retryPromise<T>(
|
||||
fn: (...args: any[]) => T,
|
||||
retries = 5,
|
||||
): Promise<T> {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (err) {
|
||||
if (retries-- > 0) {
|
||||
return await _retryPromise(fn, retries);
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type AsyncSpawnOptions = SpawnOptions & {
|
||||
loggerFields?: any;
|
||||
pipeToLogger?: boolean | { stdout?: boolean; stderr?: boolean };
|
||||
stdoutOnly?: boolean;
|
||||
loggerLineTransformer?: (line: any) => any;
|
||||
};
|
||||
|
||||
async function spawnAsyncThrowError(
|
||||
command: string,
|
||||
args: string[],
|
||||
options: AsyncSpawnOptions = {
|
||||
stdio: 'inherit',
|
||||
cwd: process.cwd(),
|
||||
},
|
||||
): Promise<SpawnResult> {
|
||||
const { pipeToLogger } = options;
|
||||
if (pipeToLogger) {
|
||||
options.stdio = 'pipe';
|
||||
options.cwd = options.cwd || process.cwd();
|
||||
}
|
||||
const promise = spawnAsyncQuiet(command, args, options);
|
||||
if (pipeToLogger && promise.child) {
|
||||
const streams: { stdout?: Readable | null; stderr?: Readable | null } = {};
|
||||
if (pipeToLogger === true || pipeToLogger.stdout) {
|
||||
streams.stdout = promise.child.stdout;
|
||||
}
|
||||
if (pipeToLogger === true || pipeToLogger.stderr) {
|
||||
streams.stderr = promise.child.stderr;
|
||||
}
|
||||
pipeOutputToLogger(streams, options.loggerFields, options);
|
||||
}
|
||||
return promise;
|
||||
}
|
||||
|
||||
async function spawnAsync(
|
||||
command: string,
|
||||
args: string[],
|
||||
options: SpawnOptions,
|
||||
): Promise<SpawnResult | void> {
|
||||
try {
|
||||
return await spawnAsyncThrowError(command, args, options);
|
||||
} catch (e) {
|
||||
LoggerDetach.error(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
function createSpawner(buildPhase: string, logger?: Logger) {
|
||||
return (command: string, ...args: any[]) => {
|
||||
const lastArg = args[args.length - 1];
|
||||
const optionsFromArg = isObject(lastArg) ? args.pop() : {};
|
||||
|
||||
const options = { ...optionsFromArg, pipeToLogger: true };
|
||||
if (buildPhase) {
|
||||
options.loggerFields = options.loggerFields ? options.loggerFields : {};
|
||||
options.loggerFields = { ...options.loggerFields, buildPhase };
|
||||
}
|
||||
|
||||
if (logger) {
|
||||
logger.info('Executing command:', command, ...args);
|
||||
}
|
||||
return spawnAsyncThrowError(command, args, options);
|
||||
};
|
||||
}
|
||||
|
||||
async function transformFileContentsAsync(
|
||||
filename: string,
|
||||
transform: (input: string) => string | null,
|
||||
) {
|
||||
const fileString = await fs.readFile(filename, 'utf8');
|
||||
const newFileString = transform(fileString);
|
||||
if (newFileString !== null) {
|
||||
await fs.writeFile(filename, newFileString);
|
||||
}
|
||||
}
|
||||
|
||||
function manifestUsesSplashApi(manifest: ExpoConfig, platform: Platform) {
|
||||
if (platform === 'ios') {
|
||||
return manifest.splash || (manifest.ios && manifest.ios.splash);
|
||||
}
|
||||
if (platform === 'android') {
|
||||
return manifest.splash || (manifest.android && manifest.android.splash);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function rimrafDontThrow(directory: string) {
|
||||
fs.removeSync(directory);
|
||||
}
|
||||
|
||||
async function removeIfExists(file: string) {
|
||||
await fs.remove(file);
|
||||
}
|
||||
|
||||
function isDirectory(dir: string) {
|
||||
try {
|
||||
if (fs.statSync(dir).isDirectory()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
interface ILocaleMap {
|
||||
[lang: string]: any;
|
||||
}
|
||||
|
||||
async function getResolvedLocalesAsync(
|
||||
projectRoot: string,
|
||||
exp: ExpoConfig,
|
||||
): Promise<ILocaleMap> {
|
||||
const locales: ILocaleMap = {};
|
||||
if (exp.locales !== undefined) {
|
||||
for (const [lang, localePath] of Object.entries(exp.locales)) {
|
||||
const s = await fs.readFile(
|
||||
path.resolve(projectRoot, localePath as string),
|
||||
'utf8',
|
||||
);
|
||||
try {
|
||||
locales[lang] = JSON.parse(s);
|
||||
} catch (e) {
|
||||
throw new XDLError('INVALID_JSON', JSON.stringify(e));
|
||||
}
|
||||
}
|
||||
}
|
||||
return locales;
|
||||
}
|
||||
|
||||
async function regexFileAsync(
|
||||
regex: RegExp | string,
|
||||
replace: string,
|
||||
filename: string,
|
||||
): Promise<void> {
|
||||
const file = await fs.readFile(filename);
|
||||
const fileString = file.toString();
|
||||
await fs.writeFile(filename, fileString.replace(regex, replace));
|
||||
}
|
||||
|
||||
// Matches sed /d behavior
|
||||
async function deleteLinesInFileAsync(
|
||||
startRegex: RegExp | string,
|
||||
endRegex: RegExp | string,
|
||||
filename: string,
|
||||
): Promise<void> {
|
||||
const file = await fs.readFile(filename);
|
||||
const fileString = file.toString();
|
||||
const lines = fileString.split(/\r?\n/);
|
||||
const filteredLines = [];
|
||||
let inDeleteRange = false;
|
||||
for (const line of lines) {
|
||||
if (line.match(startRegex)) {
|
||||
inDeleteRange = true;
|
||||
}
|
||||
|
||||
if (!inDeleteRange) {
|
||||
filteredLines.push(line);
|
||||
}
|
||||
|
||||
if (inDeleteRange && line.match(endRegex)) {
|
||||
inDeleteRange = false;
|
||||
}
|
||||
}
|
||||
await fs.writeFile(filename, filteredLines.join('\n'));
|
||||
}
|
||||
|
||||
export {
|
||||
isDirectory,
|
||||
parseSdkMajorVersion,
|
||||
saveUrlToPathAsync,
|
||||
saveImageToPathAsync,
|
||||
getManifestAsync,
|
||||
rimrafDontThrow,
|
||||
removeIfExists,
|
||||
spawnAsyncThrowError,
|
||||
spawnAsync,
|
||||
transformFileContentsAsync,
|
||||
manifestUsesSplashApi,
|
||||
getResolvedLocalesAsync,
|
||||
regexFileAsync,
|
||||
deleteLinesInFileAsync,
|
||||
createSpawner,
|
||||
getManifestFileNameForSdkVersion,
|
||||
getBundleFileNameForSdkVersion,
|
||||
};
|
||||
@@ -0,0 +1,71 @@
|
||||
import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
|
||||
import { parseSdkMajorVersion, spawnAsyncThrowError } from './ExponentTools';
|
||||
import * as IosIcons from './IosIcons';
|
||||
import { AnyStandaloneContext, StandaloneContextService } from './StandaloneContext';
|
||||
|
||||
/**
|
||||
* Compile a .car file from the icons in a manifest.
|
||||
*/
|
||||
async function buildAssetArchiveAsync(
|
||||
context: AnyStandaloneContext,
|
||||
destinationCARPath: string,
|
||||
intermediatesDirectory: string,
|
||||
) {
|
||||
if (!(context instanceof StandaloneContextService)) {
|
||||
throw new Error('buildAssetArchive is only supported for service standalone contexts.');
|
||||
}
|
||||
const { data } = context;
|
||||
fs.mkdirpSync(intermediatesDirectory);
|
||||
|
||||
// copy expoSourceRoot/.../Images.xcassets into intermediates
|
||||
await spawnAsyncThrowError(
|
||||
'/bin/cp',
|
||||
[
|
||||
'-R',
|
||||
path.join(data.expoSourcePath, 'Exponent', 'Images.xcassets'),
|
||||
path.join(intermediatesDirectory, 'Images.xcassets'),
|
||||
],
|
||||
{
|
||||
stdio: 'inherit',
|
||||
},
|
||||
);
|
||||
|
||||
// make the new xcassets contain the project's icon
|
||||
await IosIcons.createAndWriteIconsToPathAsync(
|
||||
context,
|
||||
path.join(intermediatesDirectory, 'Images.xcassets', 'AppIcon.appiconset'),
|
||||
);
|
||||
|
||||
const sdkMajorVersion = parseSdkMajorVersion(data.manifest.sdkVersion);
|
||||
const deploymentTarget = sdkMajorVersion > 40 ? '11.0' : '10.0'; // SDK41 drops support for iOS 10.0
|
||||
|
||||
// compile asset archive
|
||||
// prettier-ignore
|
||||
const xcrunargs = [
|
||||
'actool',
|
||||
'--minimum-deployment-target', deploymentTarget,
|
||||
'--platform', 'iphoneos',
|
||||
'--app-icon', 'AppIcon',
|
||||
'--output-partial-info-plist', 'assetcatalog_generated_info.plist',
|
||||
'--compress-pngs',
|
||||
'--enable-on-demand-resources', 'YES',
|
||||
'--product-type', 'com.apple.product-type.application',
|
||||
'--target-device', 'iphone',
|
||||
'--target-device', 'ipad',
|
||||
'--compile', path.relative(intermediatesDirectory, destinationCARPath),
|
||||
'Images.xcassets',
|
||||
];
|
||||
/*
|
||||
* Note: if you want to debug issues with `actool`, try changing to stdio: 'inherit'.
|
||||
* In both success and failure cases, actool will write an enormous .plist to stdout
|
||||
* which may contain the key `com.apple.actool.errors`. Great work Apple
|
||||
*/
|
||||
await spawnAsyncThrowError('xcrun', xcrunargs, {
|
||||
stdio: ['ignore', 'ignore', 'inherit'], // only stderr
|
||||
cwd: intermediatesDirectory,
|
||||
});
|
||||
}
|
||||
|
||||
export { buildAssetArchiveAsync };
|
||||
@@ -0,0 +1,343 @@
|
||||
import plist from '@expo/plist';
|
||||
import crypto from 'crypto';
|
||||
import fs from 'fs-extra';
|
||||
import { sync as globSync } from 'glob';
|
||||
import omit from 'lodash/omit';
|
||||
import minimatch from 'minimatch';
|
||||
import path from 'path';
|
||||
|
||||
import { spawnAsyncThrowError } from './ExponentTools';
|
||||
import { findP12CertSerialNumber, getP12CertFingerprint } from './PKCS12Utils';
|
||||
|
||||
async function ensureCertificateValid({ certPath, certPassword, teamID }) {
|
||||
const certData = await fs.readFile(certPath);
|
||||
const fingerprint = getP12CertFingerprint(certData, certPassword);
|
||||
const identities = await _findIdentitiesByTeamID(teamID);
|
||||
const isValid = identities.indexOf(fingerprint) !== -1;
|
||||
if (!isValid) {
|
||||
throw new Error(`codesign ident not present in find-identity: ${fingerprint}\n${identities}`);
|
||||
}
|
||||
return fingerprint;
|
||||
}
|
||||
|
||||
async function _findIdentitiesByTeamID(teamID) {
|
||||
const { output } = await spawnAsyncThrowError(
|
||||
'security',
|
||||
['find-identity', '-v', '-s', `(${teamID})`],
|
||||
{
|
||||
stdio: 'pipe',
|
||||
}
|
||||
);
|
||||
return output.join('');
|
||||
}
|
||||
|
||||
function validateProvisioningProfile(plistData, { distCertFingerprint, bundleIdentifier }) {
|
||||
_ensureDeveloperCertificateIsValid(plistData, distCertFingerprint);
|
||||
_ensureBundleIdentifierIsValid(plistData, bundleIdentifier);
|
||||
}
|
||||
|
||||
function _ensureDeveloperCertificateIsValid(plistData, distCertFingerprint) {
|
||||
const devCertBase64 = plistData.DeveloperCertificates[0];
|
||||
const devCertFingerprint = _genDerCertFingerprint(devCertBase64);
|
||||
if (devCertFingerprint !== distCertFingerprint) {
|
||||
throw new Error(
|
||||
'validateProvisioningProfile: provisioning profile is not associated with uploaded distribution certificate'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function _genDerCertFingerprint(certBase64) {
|
||||
const certBuffer = Buffer.from(certBase64, 'base64');
|
||||
return crypto.createHash('sha1').update(certBuffer).digest('hex').toUpperCase();
|
||||
}
|
||||
|
||||
function _ensureBundleIdentifierIsValid(plistData, expectedBundleIdentifier) {
|
||||
const actualApplicationIdentifier = plistData.Entitlements['application-identifier'];
|
||||
const actualBundleIdentifier = /\.(.+)/.exec(actualApplicationIdentifier)[1];
|
||||
|
||||
if (!minimatch(expectedBundleIdentifier, actualBundleIdentifier)) {
|
||||
throw new Error(
|
||||
`validateProvisioningProfile: wrong bundleIdentifier found in provisioning profile; expected: ${expectedBundleIdentifier}, found (in provisioning profile): ${actualBundleIdentifier}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function writeExportOptionsPlistFile(plistPath, data) {
|
||||
const toWrite = createExportOptionsPlist(data);
|
||||
await fs.writeFile(plistPath, toWrite);
|
||||
}
|
||||
|
||||
const createExportOptionsPlist = ({
|
||||
bundleIdentifier,
|
||||
provisioningProfileUUID,
|
||||
exportMethod,
|
||||
teamID,
|
||||
}) => {
|
||||
const disableBitcodeCompiling = `<key>uploadBitcode</key>
|
||||
<false/>
|
||||
<key>compileBitcode</key>
|
||||
<false/>
|
||||
<key>uploadSymbols</key>
|
||||
<false/>`;
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>method</key>
|
||||
<string>${exportMethod}</string>
|
||||
<key>teamID</key>
|
||||
<string>${teamID}</string>
|
||||
<key>provisioningProfiles</key>
|
||||
<dict>
|
||||
<key>${bundleIdentifier}</key>
|
||||
<string>${provisioningProfileUUID}</string>
|
||||
</dict>
|
||||
${exportMethod === 'ad-hoc' || exportMethod === 'enterprise' ? disableBitcodeCompiling : ''}
|
||||
</dict>
|
||||
</plist>`;
|
||||
};
|
||||
|
||||
async function buildIPA(
|
||||
{
|
||||
ipaPath,
|
||||
workspacePath,
|
||||
archivePath,
|
||||
codeSignIdentity,
|
||||
exportOptionsPlistPath,
|
||||
plistData,
|
||||
keychainPath,
|
||||
exportMethod,
|
||||
},
|
||||
credentials,
|
||||
client = false
|
||||
) {
|
||||
if (client) {
|
||||
await spawnAsyncThrowError(
|
||||
'xcodebuild',
|
||||
[
|
||||
'-exportArchive',
|
||||
'-archivePath',
|
||||
archivePath,
|
||||
'-exportOptionsPlist',
|
||||
exportOptionsPlistPath,
|
||||
'-exportPath',
|
||||
path.Dir(ipaPath),
|
||||
`OTHER_CODE_SIGN_FLAGS="--keychain ${keychainPath}"`,
|
||||
],
|
||||
{
|
||||
env: { ...process.env, CI: 1 },
|
||||
}
|
||||
);
|
||||
} else {
|
||||
await runFastlane(
|
||||
credentials,
|
||||
[
|
||||
'gym',
|
||||
'-n',
|
||||
path.basename(ipaPath),
|
||||
'--workspace',
|
||||
workspacePath,
|
||||
'--archive_path',
|
||||
archivePath,
|
||||
'--skip_build_archive',
|
||||
'true',
|
||||
'-i',
|
||||
codeSignIdentity,
|
||||
'--export_options',
|
||||
exportOptionsPlistPath,
|
||||
'--export_method',
|
||||
exportMethod,
|
||||
'--export_xcargs',
|
||||
`OTHER_CODE_SIGN_FLAGS="--keychain ${keychainPath}"`,
|
||||
'-o',
|
||||
path.dirname(ipaPath),
|
||||
'--verbose',
|
||||
],
|
||||
{ buildPhase: 'building and signing IPA' }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const resolveExportMethod = plistData => {
|
||||
if (plistData.ProvisionedDevices) {
|
||||
return 'ad-hoc';
|
||||
} else if (plistData.ProvisionsAllDevices === true) {
|
||||
return 'enterprise';
|
||||
} else {
|
||||
return 'app-store';
|
||||
}
|
||||
};
|
||||
|
||||
const entitlementTransferRules = [
|
||||
'com.apple.developer.associated-domains',
|
||||
'com.apple.developer.healthkit',
|
||||
'com.apple.developer.homekit',
|
||||
'com.apple.developer.icloud-container-identifiers',
|
||||
'com.apple.developer.icloud-services',
|
||||
'com.apple.developer.in-app-payments',
|
||||
'com.apple.developer.networking.vpn.api',
|
||||
'com.apple.developer.ubiquity-container-identifiers',
|
||||
'com.apple.developer.ubiquity-kvstore-identifier',
|
||||
'com.apple.external-accessory.wireless-configuration',
|
||||
'com.apple.security.application-groups',
|
||||
'inter-app-audio',
|
||||
'keychain-access-groups',
|
||||
];
|
||||
|
||||
const blacklistedEntitlementKeysWithoutICloud = [
|
||||
'com.apple.developer.icloud-container-environment',
|
||||
'com.apple.developer.icloud-container-identifiers',
|
||||
'com.apple.developer.icloud-services',
|
||||
'com.apple.developer.ubiquity-container-identifiers',
|
||||
'com.apple.developer.ubiquity-kvstore-identifier',
|
||||
];
|
||||
|
||||
const blacklistedEntitlementKeys = [
|
||||
'com.apple.developer.icloud-container-development-container-identifiers',
|
||||
'com.apple.developer.restricted-resource-mode',
|
||||
'inter-app-audio',
|
||||
'com.apple.developer.homekit',
|
||||
'com.apple.developer.healthkit',
|
||||
'com.apple.developer.in-app-payments',
|
||||
'com.apple.developer.maps',
|
||||
'com.apple.external-accessory.wireless-configuration',
|
||||
];
|
||||
|
||||
const icloudContainerEnvKey = 'com.apple.developer.icloud-container-environment';
|
||||
|
||||
async function createEntitlementsFile({
|
||||
generatedEntitlementsPath,
|
||||
plistData,
|
||||
archivePath,
|
||||
manifest,
|
||||
}) {
|
||||
const decodedProvisioningProfileEntitlements = plistData.Entitlements;
|
||||
|
||||
const entitlementsPaths = globSync('Products/Applications/*.app/*.entitlements', {
|
||||
absolute: true,
|
||||
cwd: archivePath,
|
||||
});
|
||||
if (entitlementsPaths.length === 0) {
|
||||
throw new Error("Didn't find any generated entitlements file in archive.");
|
||||
} else if (entitlementsPaths.length !== 1) {
|
||||
throw new Error('Found more than one entitlements file.');
|
||||
}
|
||||
const archiveEntitlementsPath = entitlementsPaths[0];
|
||||
const archiveEntitlementsRaw = await fs.readFile(archiveEntitlementsPath, 'utf8');
|
||||
let archiveEntitlementsData;
|
||||
try {
|
||||
archiveEntitlementsData = plist.parse(archiveEntitlementsRaw);
|
||||
} catch (error) {
|
||||
throw new Error(`Error when parsing plist: ${error.message}`);
|
||||
}
|
||||
|
||||
const entitlements = { ...decodedProvisioningProfileEntitlements };
|
||||
|
||||
entitlementTransferRules.forEach(rule => {
|
||||
if (rule in archiveEntitlementsData) {
|
||||
entitlements[rule] = archiveEntitlementsData[rule];
|
||||
}
|
||||
});
|
||||
|
||||
let generatedEntitlements = omit(entitlements, blacklistedEntitlementKeys);
|
||||
|
||||
if (!manifest.ios.usesIcloudStorage) {
|
||||
generatedEntitlements = omit(generatedEntitlements, blacklistedEntitlementKeysWithoutICloud);
|
||||
} else {
|
||||
const ubiquityKvKey = 'com.apple.developer.ubiquity-kvstore-identifier';
|
||||
if (generatedEntitlements[ubiquityKvKey]) {
|
||||
const teamId = generatedEntitlements[ubiquityKvKey].split('.')[0];
|
||||
generatedEntitlements[ubiquityKvKey] = `${teamId}.${manifest.ios.bundleIdentifier}`;
|
||||
}
|
||||
generatedEntitlements['com.apple.developer.icloud-services'] = ['CloudDocuments'];
|
||||
}
|
||||
if (!manifest.ios.associatedDomains) {
|
||||
generatedEntitlements = omit(generatedEntitlements, 'com.apple.developer.associated-domains');
|
||||
}
|
||||
if (!manifest.ios.usesAppleSignIn) {
|
||||
generatedEntitlements = omit(generatedEntitlements, 'com.apple.developer.applesignin');
|
||||
}
|
||||
if (generatedEntitlements[icloudContainerEnvKey]) {
|
||||
const envs = generatedEntitlements[icloudContainerEnvKey].filter(i => i === 'Production');
|
||||
generatedEntitlements[icloudContainerEnvKey] = envs;
|
||||
}
|
||||
|
||||
const generatedEntitlementsPlistData = plist.build(generatedEntitlements);
|
||||
await fs.writeFile(generatedEntitlementsPath, generatedEntitlementsPlistData, {
|
||||
mode: 0o755,
|
||||
});
|
||||
const { output } = await spawnAsyncThrowError(
|
||||
'/usr/libexec/PlistBuddy',
|
||||
['-x', '-c', 'Print', generatedEntitlementsPath],
|
||||
{
|
||||
stdio: 'pipe',
|
||||
}
|
||||
);
|
||||
const plistDataReformatted = output.join('');
|
||||
await fs.writeFile(generatedEntitlementsPath, plistDataReformatted, {
|
||||
mode: 0o755,
|
||||
});
|
||||
}
|
||||
|
||||
async function resignIPA(
|
||||
{
|
||||
codeSignIdentity,
|
||||
entitlementsPath,
|
||||
provisioningProfilePath,
|
||||
sourceIpaPath,
|
||||
destIpaPath,
|
||||
keychainPath,
|
||||
},
|
||||
credentials
|
||||
) {
|
||||
await spawnAsyncThrowError('cp', ['-rf', sourceIpaPath, destIpaPath]);
|
||||
await runFastlane(
|
||||
credentials,
|
||||
[
|
||||
'sigh',
|
||||
'resign',
|
||||
'--verbose',
|
||||
'--entitlements',
|
||||
entitlementsPath,
|
||||
'--signing_identity',
|
||||
codeSignIdentity,
|
||||
'--keychain_path',
|
||||
keychainPath,
|
||||
'--provisioning_profile',
|
||||
provisioningProfilePath,
|
||||
destIpaPath,
|
||||
],
|
||||
{ buildPhase: 'building and signing IPA' }
|
||||
);
|
||||
}
|
||||
|
||||
async function runFastlane({ teamID }, fastlaneArgs, loggerFields) {
|
||||
const fastlaneEnvVars = {
|
||||
FASTLANE_SKIP_UPDATE_CHECK: 1,
|
||||
FASTLANE_DISABLE_COLORS: 1,
|
||||
FASTLANE_TEAM_ID: teamID,
|
||||
CI: 1,
|
||||
LC_ALL: 'en_US.UTF-8',
|
||||
};
|
||||
|
||||
await spawnAsyncThrowError('fastlane', fastlaneArgs, {
|
||||
env: {
|
||||
...process.env,
|
||||
...fastlaneEnvVars,
|
||||
},
|
||||
pipeToLogger: true,
|
||||
dontShowStdout: false,
|
||||
loggerFields,
|
||||
});
|
||||
}
|
||||
|
||||
export {
|
||||
ensureCertificateValid,
|
||||
findP12CertSerialNumber,
|
||||
validateProvisioningProfile,
|
||||
writeExportOptionsPlistFile,
|
||||
buildIPA,
|
||||
resolveExportMethod,
|
||||
createEntitlementsFile,
|
||||
resignIPA,
|
||||
};
|
||||
@@ -0,0 +1,140 @@
|
||||
import plist from '@expo/plist';
|
||||
import fs from 'fs-extra';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import { spawnAsyncThrowError } from './ExponentTools';
|
||||
import * as IosCodeSigning from './IosCodeSigning';
|
||||
import _logger from './Logger';
|
||||
|
||||
const logger = _logger.withFields({ buildPhase: 'building and signing IPA' });
|
||||
|
||||
export default function createIPABuilder(buildParams) {
|
||||
const { appUUID, keychainPath, bundleIdentifier, teamID, manifest, workspacePath } = buildParams;
|
||||
const appDir = path.join('/private/tmp/turtle', appUUID);
|
||||
const buildDir = path.join(appDir, 'build');
|
||||
const provisionDir = path.join(appDir, 'provisioning');
|
||||
const outputPath = path.join(appDir, 'archive.xcarchive');
|
||||
const uploadPath = path.join(buildDir, 'archive.ipa');
|
||||
|
||||
async function build() {
|
||||
const { provisioningProfilePath, clientBuild } = buildParams;
|
||||
|
||||
await copyProvisioningProfileToHomedir(provisioningProfilePath, appUUID);
|
||||
logger.info('provisioning profile copied to home directory');
|
||||
|
||||
try {
|
||||
const plistData = await readCMSMessage(provisioningProfilePath);
|
||||
logger.info('done retrieving provisioning profile data');
|
||||
|
||||
logger.info('checking if teamID is present in keychain and that certificate is valid...');
|
||||
const codeSignIdentity = await IosCodeSigning.ensureCertificateValid(buildParams);
|
||||
logger.info('ensured certificate is valid');
|
||||
|
||||
logger.info('validating provisioning profile...');
|
||||
IosCodeSigning.validateProvisioningProfile(plistData, {
|
||||
distCertFingerprint: codeSignIdentity,
|
||||
teamID,
|
||||
bundleIdentifier,
|
||||
});
|
||||
logger.info('provisioning profile is valid');
|
||||
|
||||
logger.info('writing export-options.plist file...');
|
||||
const exportMethod = IosCodeSigning.resolveExportMethod(plistData);
|
||||
const exportOptionsPlistPath = path.join(provisionDir, 'export-options.plist');
|
||||
const exportOptionsData = {
|
||||
bundleIdentifier,
|
||||
provisioningProfileUUID: plistData.UUID,
|
||||
exportMethod,
|
||||
teamID,
|
||||
};
|
||||
await IosCodeSigning.writeExportOptionsPlistFile(exportOptionsPlistPath, exportOptionsData);
|
||||
logger.info('created export-options.plist file');
|
||||
|
||||
logger.info('generating IPA...');
|
||||
const unsignedIpaPath = path.join(buildDir, `${appUUID}-unsigned.ipa`);
|
||||
const ipaBuilderArgs = {
|
||||
ipaPath: unsignedIpaPath,
|
||||
workspacePath,
|
||||
archivePath: outputPath,
|
||||
codeSignIdentity,
|
||||
exportOptionsPlistPath,
|
||||
plistData,
|
||||
keychainPath,
|
||||
exportMethod,
|
||||
};
|
||||
await IosCodeSigning.buildIPA(ipaBuilderArgs, buildParams, clientBuild);
|
||||
logger.info('generated IPA');
|
||||
|
||||
logger.info('creating entitlements file...');
|
||||
const generatedEntitlementsPath = path.join(appDir, 'generatedEntitlements.entitlements');
|
||||
await IosCodeSigning.createEntitlementsFile({
|
||||
generatedEntitlementsPath,
|
||||
plistData,
|
||||
archivePath: outputPath,
|
||||
manifest,
|
||||
});
|
||||
logger.info('created entitlements file');
|
||||
|
||||
logger.info('resigning IPA...');
|
||||
await IosCodeSigning.resignIPA(
|
||||
{
|
||||
codeSignIdentity,
|
||||
entitlementsPath: generatedEntitlementsPath,
|
||||
provisioningProfilePath,
|
||||
sourceIpaPath: unsignedIpaPath,
|
||||
destIpaPath: uploadPath,
|
||||
keychainPath,
|
||||
},
|
||||
buildParams
|
||||
);
|
||||
logger.info('resigned IPA');
|
||||
} finally {
|
||||
await removeProvisioningProfileFromHomedir(appUUID);
|
||||
logger.info('removed provisioning profile from the home directory');
|
||||
}
|
||||
}
|
||||
|
||||
async function cleanup() {
|
||||
try {
|
||||
await fs.remove(getProvisioningProfilePath(appUUID));
|
||||
} catch (err) {
|
||||
logger.error('failed to perform cleanup, error:', err);
|
||||
}
|
||||
}
|
||||
|
||||
async function copyProvisioningProfileToHomedir(provisioningProfilePath, appUUID) {
|
||||
await fs.mkdirp(getProvisioningProfileDirPath());
|
||||
const newProvisioningProfilePath = getProvisioningProfilePath(appUUID);
|
||||
await fs.copy(provisioningProfilePath, newProvisioningProfilePath);
|
||||
}
|
||||
|
||||
async function removeProvisioningProfileFromHomedir(appUUID) {
|
||||
const provisioningProfilePath = getProvisioningProfilePath(appUUID);
|
||||
await fs.remove(provisioningProfilePath);
|
||||
}
|
||||
|
||||
async function readCMSMessage(provisioningProfilePath) {
|
||||
const { output } = await spawnAsyncThrowError(
|
||||
'security',
|
||||
['cms', '-D', '-i', provisioningProfilePath],
|
||||
{
|
||||
stdio: 'pipe',
|
||||
}
|
||||
);
|
||||
const plistRaw = output.join('');
|
||||
try {
|
||||
return plist.parse(plistRaw);
|
||||
} catch (error) {
|
||||
throw new Error(`Error when parsing plist: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
const getProvisioningProfileDirPath = () =>
|
||||
path.join(os.homedir(), 'Library/MobileDevice/Provisioning Profiles');
|
||||
|
||||
const getProvisioningProfilePath = appUUID =>
|
||||
path.join(getProvisioningProfileDirPath(), `${appUUID}.mobileprovision`);
|
||||
|
||||
return { build, cleanup };
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import path from 'path';
|
||||
|
||||
import { getImageDimensionsAsync, resizeImageAsync } from '../ImageUtils';
|
||||
import {
|
||||
saveImageToPathAsync,
|
||||
saveUrlToPathAsync,
|
||||
spawnAsyncThrowError,
|
||||
} from './ExponentTools';
|
||||
import logger from './Logger';
|
||||
import {
|
||||
AnyStandaloneContext,
|
||||
StandaloneContextService,
|
||||
StandaloneContextUser,
|
||||
} from './StandaloneContext';
|
||||
|
||||
function _getAppleIconQualifier(
|
||||
iconSize: number,
|
||||
iconResolution: number,
|
||||
): string {
|
||||
let iconQualifier;
|
||||
if (iconResolution !== 1) {
|
||||
// e.g. "29x29@3x"
|
||||
iconQualifier = `${iconSize}x${iconSize}@${iconResolution}x`;
|
||||
} else {
|
||||
iconQualifier = `${iconSize}x${iconSize}`;
|
||||
}
|
||||
if (iconSize === 76 || iconSize === 83.5) {
|
||||
// ipad sizes require ~ipad at the end
|
||||
iconQualifier = `${iconQualifier}~ipad`;
|
||||
}
|
||||
return iconQualifier;
|
||||
}
|
||||
|
||||
async function _saveDefaultIconToPathAsync(
|
||||
context: AnyStandaloneContext,
|
||||
filePath: string,
|
||||
) {
|
||||
if (context instanceof StandaloneContextUser) {
|
||||
const { data } = context;
|
||||
if (data.exp.icon) {
|
||||
await saveImageToPathAsync(data.projectPath, data.exp.icon, filePath);
|
||||
} else {
|
||||
throw new Error('Cannot save icon because app.json has no exp.icon key.');
|
||||
}
|
||||
} else {
|
||||
const { data } = context;
|
||||
if (data.manifest.ios && data.manifest.ios.iconUrl) {
|
||||
await saveUrlToPathAsync(data.manifest.ios.iconUrl, filePath);
|
||||
} else if (data.manifest.iconUrl) {
|
||||
await saveUrlToPathAsync(data.manifest.iconUrl, filePath);
|
||||
} else {
|
||||
throw new Error(
|
||||
'Cannot save icon because manifest has no iconUrl or ios.iconUrl key.',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Based on keys in the given context.config,
|
||||
* ensure that the proper iOS icon images exist -- assuming Info.plist already
|
||||
* points at them under CFBundleIcons.CFBundlePrimaryIcon.CFBundleIconFiles.
|
||||
*
|
||||
* This only works on MacOS (as far as I know) because it uses the sips utility.
|
||||
*/
|
||||
async function createAndWriteIconsToPathAsync(
|
||||
context: AnyStandaloneContext,
|
||||
destinationIconPath: string,
|
||||
) {
|
||||
let defaultIconFilename: string | null = 'exp-icon.png';
|
||||
try {
|
||||
await _saveDefaultIconToPathAsync(
|
||||
context,
|
||||
path.join(destinationIconPath, defaultIconFilename),
|
||||
);
|
||||
} catch (e) {
|
||||
defaultIconFilename = null;
|
||||
logger.warn(e.message);
|
||||
}
|
||||
|
||||
const iconSizes = [1024, 20, 29, 40, 60, 76, 83.5];
|
||||
|
||||
await Promise.all(
|
||||
iconSizes.map(async (iconSize) => {
|
||||
let iconResolutions;
|
||||
if (iconSize === 76) {
|
||||
// iPad has 1x and 2x icons for this size only
|
||||
iconResolutions = [1, 2];
|
||||
} else if (iconSize === 1024) {
|
||||
// marketing icon is weird
|
||||
iconResolutions = [1];
|
||||
} else if (iconSize === 83.5) {
|
||||
iconResolutions = [2];
|
||||
} else {
|
||||
iconResolutions = [2, 3];
|
||||
}
|
||||
|
||||
// We need to wait for all of these to finish!
|
||||
await Promise.all(
|
||||
iconResolutions.map(async (iconResolution) => {
|
||||
const iconQualifier = _getAppleIconQualifier(
|
||||
iconSize,
|
||||
iconResolution,
|
||||
);
|
||||
const iconKey = `iconUrl${iconQualifier}`;
|
||||
let rawIconFilename;
|
||||
let usesDefault = false;
|
||||
if (context instanceof StandaloneContextService) {
|
||||
// TODO(nikki): Support local paths for these icons
|
||||
const manifest = context.data.manifest;
|
||||
if (manifest.ios && manifest.ios.hasOwnProperty(iconKey)) {
|
||||
// manifest specifies an image just for this size/resolution, use that
|
||||
rawIconFilename = `exp-icon${iconQualifier}.png`;
|
||||
await saveUrlToPathAsync(
|
||||
manifest.ios[iconKey],
|
||||
`${destinationIconPath}/${rawIconFilename}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!rawIconFilename) {
|
||||
// use default iconUrl
|
||||
usesDefault = true;
|
||||
if (defaultIconFilename) {
|
||||
rawIconFilename = defaultIconFilename;
|
||||
} else {
|
||||
logger.warn(
|
||||
`Project does not specify ios.${iconKey} nor a default iconUrl. Bundle will use the Expo logo.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const iconFilename = `AppIcon${iconQualifier}.png`;
|
||||
const iconSizePx = iconSize * iconResolution;
|
||||
await spawnAsyncThrowError(
|
||||
'/bin/cp',
|
||||
[rawIconFilename, iconFilename],
|
||||
{
|
||||
stdio: 'inherit',
|
||||
cwd: destinationIconPath,
|
||||
},
|
||||
);
|
||||
try {
|
||||
await resizeImageAsync(
|
||||
iconSizePx,
|
||||
iconFilename,
|
||||
destinationIconPath,
|
||||
);
|
||||
} catch (e) {
|
||||
throw new Error(`Failed to resize image: ${iconFilename}. (${e})`);
|
||||
}
|
||||
|
||||
// reject non-square icons (because Apple will if we don't)
|
||||
const dims = await getImageDimensionsAsync(
|
||||
destinationIconPath,
|
||||
iconFilename,
|
||||
);
|
||||
if (!dims) {
|
||||
throw new Error(`Unable to read the dimensions of ${iconFilename}`);
|
||||
} else if (dims.width !== dims.height) {
|
||||
throw new Error(
|
||||
`iOS icons must be square, the dimensions of ${iconFilename} are ${dims}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!usesDefault) {
|
||||
// non-default icon used, clean up the downloaded version
|
||||
await spawnAsyncThrowError('/bin/rm', [
|
||||
path.join(destinationIconPath, rawIconFilename),
|
||||
]);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
// clean up default icon
|
||||
if (defaultIconFilename) {
|
||||
await spawnAsyncThrowError('/bin/rm', [
|
||||
path.join(destinationIconPath, defaultIconFilename),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
export { createAndWriteIconsToPathAsync };
|
||||
@@ -0,0 +1,132 @@
|
||||
import fs from 'fs-extra';
|
||||
import difference from 'lodash/difference';
|
||||
import uuidv1 from 'uuid/v1';
|
||||
|
||||
import { createSpawner, spawnAsyncThrowError } from './ExponentTools';
|
||||
import _logger from './Logger';
|
||||
|
||||
export async function createKeychain(appUUID: string, saveResultToFile = true) {
|
||||
const BUILD_PHASE = 'creating keychain';
|
||||
const logger = _logger.withFields({ buildPhase: BUILD_PHASE });
|
||||
const spawn = createSpawner(BUILD_PHASE, logger);
|
||||
|
||||
const name = uuidv1();
|
||||
const password = uuidv1();
|
||||
const path = getKeychainPath(name);
|
||||
|
||||
logger.info('creating new keychain...');
|
||||
await runFastlane([
|
||||
'run',
|
||||
'create_keychain',
|
||||
`path:${path}`,
|
||||
`password:${password}`,
|
||||
'unlock:true',
|
||||
'timeout:360000',
|
||||
]);
|
||||
await spawn('security', 'show-keychain-info', path, { stdoutOnly: true });
|
||||
|
||||
logger.info('created new keychain');
|
||||
const keychainInfo = {
|
||||
name,
|
||||
path,
|
||||
password,
|
||||
};
|
||||
|
||||
if (saveResultToFile) {
|
||||
const keychainInfoPath = getKeychainInfoPath(appUUID);
|
||||
await fs.writeFile(keychainInfoPath, JSON.stringify(keychainInfo));
|
||||
logger.info('saved keychain info to %s', keychainInfoPath);
|
||||
}
|
||||
|
||||
return keychainInfo;
|
||||
}
|
||||
|
||||
export async function deleteKeychain({ path, appUUID }: { path: string; appUUID?: string }) {
|
||||
const BUILD_PHASE = 'deleting keychain';
|
||||
const logger = _logger.withFields({ buildPhase: BUILD_PHASE });
|
||||
|
||||
logger.info('deleting keychain...');
|
||||
await runFastlane(['run', 'delete_keychain', `keychain_path:${path}`]);
|
||||
|
||||
if (appUUID) {
|
||||
const keychainInfoPath = getKeychainInfoPath(appUUID);
|
||||
await fs.remove(keychainInfoPath);
|
||||
}
|
||||
}
|
||||
|
||||
export async function importIntoKeychain({
|
||||
keychainPath,
|
||||
certPath,
|
||||
certPassword,
|
||||
}: {
|
||||
keychainPath: string;
|
||||
certPath: string;
|
||||
certPassword?: string;
|
||||
}) {
|
||||
const BUILD_PHASE = 'importing certificate into keychain';
|
||||
const logger = _logger.withFields({ buildPhase: BUILD_PHASE });
|
||||
const spawn = createSpawner(BUILD_PHASE);
|
||||
|
||||
logger.info('importing certificate into keychain...');
|
||||
const args = ['import', certPath, '-A', '-k', keychainPath, '-f', 'pkcs12'];
|
||||
if (certPassword) {
|
||||
logger.info('certificate has password');
|
||||
args.push('-P', certPassword);
|
||||
} else {
|
||||
logger.info('certificate doesn\'t have password');
|
||||
}
|
||||
await spawn('security', ...args);
|
||||
logger.info('imported certificate into keychain');
|
||||
}
|
||||
|
||||
export async function cleanUpKeychains() {
|
||||
try {
|
||||
_logger.info('Cleaning up keychains...');
|
||||
const { stdout: keychainsListRaw } = await spawnAsyncThrowError(
|
||||
'security',
|
||||
['list-keychains'],
|
||||
{ stdio: 'pipe' },
|
||||
);
|
||||
const allKeychainsList = (keychainsListRaw.match(/"(.*)"/g) || []).map((i) =>
|
||||
i.slice(1, i.length - 1),
|
||||
);
|
||||
const turtleKeychainsList = keychainsListRaw.match(/\/private\/tmp\/xdl\/(.*).keychain/g);
|
||||
let shouldCleanSearchList = false;
|
||||
if (turtleKeychainsList) {
|
||||
for (const keychainPath of turtleKeychainsList) {
|
||||
try {
|
||||
await deleteKeychain({ path: keychainPath });
|
||||
} catch (err) {
|
||||
_logger.warn(`Failed to delete keychain: ${keychainPath}`, err);
|
||||
shouldCleanSearchList = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldCleanSearchList) {
|
||||
const newSearchList = difference(allKeychainsList, turtleKeychainsList);
|
||||
await spawnAsyncThrowError('security', ['list-keychains', '-s', ...newSearchList], {
|
||||
stdio: 'pipe',
|
||||
});
|
||||
}
|
||||
}
|
||||
_logger.info('Cleaned up keychains');
|
||||
} catch (err) {
|
||||
_logger.error(err);
|
||||
throw new Error('Failed to clean up keychains');
|
||||
}
|
||||
}
|
||||
|
||||
async function runFastlane(fastlaneArgs: string[]) {
|
||||
const fastlaneEnvVars = {
|
||||
FASTLANE_DISABLE_COLORS: '1',
|
||||
FASTLANE_SKIP_UPDATE_CHECK: '1',
|
||||
CI: '1',
|
||||
LC_ALL: 'en_US.UTF-8',
|
||||
};
|
||||
await spawnAsyncThrowError('fastlane', fastlaneArgs, {
|
||||
env: { ...process.env, ...fastlaneEnvVars },
|
||||
});
|
||||
}
|
||||
|
||||
const getKeychainPath = (name: string) => `/private/tmp/xdl/${name}.keychain`;
|
||||
const getKeychainInfoPath = (appUUID: string) => `/private/tmp/${appUUID}-keychain-info.json`;
|
||||
@@ -0,0 +1,251 @@
|
||||
import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
import rimraf from 'rimraf';
|
||||
import { DOMParser, XMLSerializer } from 'xmldom';
|
||||
|
||||
import {
|
||||
manifestUsesSplashApi,
|
||||
saveImageToPathAsync,
|
||||
saveUrlToPathAsync,
|
||||
spawnAsyncThrowError,
|
||||
transformFileContentsAsync,
|
||||
} from './ExponentTools';
|
||||
import * as IosWorkspace from './IosWorkspace';
|
||||
import _logger from './Logger';
|
||||
|
||||
const logger = _logger.withFields({ buildPhase: 'configuring NSBundle' });
|
||||
|
||||
const ASPECT_FILL = 'scaleAspectFill';
|
||||
const ASPECT_FIT = 'scaleAspectFit';
|
||||
|
||||
const backgroundImageViewID = 'Bsh-cT-K4l';
|
||||
const backgroundViewID = 'OfY-5Y-tS4';
|
||||
|
||||
function _backgroundColorFromHexString(hexColor) {
|
||||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hexColor);
|
||||
if (!result || result.length < 4) {
|
||||
// Default to white if we can't parse the color. We should have 3 matches.
|
||||
logger.warn('Unable to parse color: ', hexColor, ' result:', result);
|
||||
return { r: 1, g: 1, b: 1 };
|
||||
}
|
||||
|
||||
const r = parseInt(result[1], 16) / 255;
|
||||
const g = parseInt(result[2], 16) / 255;
|
||||
const b = parseInt(result[3], 16) / 255;
|
||||
return { r, g, b };
|
||||
}
|
||||
|
||||
function _setBackgroundColor(manifest, dom) {
|
||||
let backgroundColorString;
|
||||
if (manifest.ios && manifest.ios.splash && manifest.ios.splash.backgroundColor) {
|
||||
backgroundColorString = manifest.ios.splash.backgroundColor;
|
||||
} else if (manifest.splash && manifest.splash.backgroundColor) {
|
||||
backgroundColorString = manifest.splash.backgroundColor;
|
||||
}
|
||||
|
||||
// Default to white
|
||||
if (!backgroundColorString) {
|
||||
backgroundColorString = '#FFFFFF';
|
||||
}
|
||||
|
||||
const { r, g, b } = _backgroundColorFromHexString(backgroundColorString);
|
||||
const backgroundViewNode = dom.getElementById(backgroundViewID);
|
||||
const backgroundViewColorNodes = backgroundViewNode.getElementsByTagName('color');
|
||||
let backgroundColorNode;
|
||||
for (let i = 0; i < backgroundViewColorNodes.length; i++) {
|
||||
const node = backgroundViewColorNodes[i];
|
||||
if (node.parentNode.getAttribute('id') !== backgroundViewID) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (node.getAttribute('key') === 'backgroundColor') {
|
||||
backgroundColorNode = node;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (backgroundColorNode) {
|
||||
backgroundColorNode.setAttribute('red', r);
|
||||
backgroundColorNode.setAttribute('green', g);
|
||||
backgroundColorNode.setAttribute('blue', b);
|
||||
}
|
||||
}
|
||||
|
||||
async function _saveImageAssetsAsync(context) {
|
||||
let tabletImagePathOrUrl, phoneImagePathOrUrl;
|
||||
|
||||
if (context.type === 'user') {
|
||||
// copy images from local project
|
||||
const exp = context.data.exp;
|
||||
if (exp.ios && exp.ios.splash && exp.ios.splash.image) {
|
||||
phoneImagePathOrUrl = exp.ios.splash.image;
|
||||
|
||||
if (exp.ios.splash.tabletImage) {
|
||||
tabletImagePathOrUrl = exp.ios.splash.tabletImage;
|
||||
}
|
||||
} else if (exp.splash && exp.splash.image) {
|
||||
phoneImagePathOrUrl = exp.splash.image;
|
||||
}
|
||||
} else {
|
||||
// use uploaded assets from published project
|
||||
const manifest = context.data.manifest;
|
||||
if (manifest.ios && manifest.ios.splash && manifest.ios.splash.imageUrl) {
|
||||
phoneImagePathOrUrl = manifest.ios.splash.imageUrl;
|
||||
|
||||
if (manifest.ios.splash.tabletImageUrl) {
|
||||
tabletImagePathOrUrl = manifest.ios.splash.tabletImageUrl;
|
||||
}
|
||||
} else if (manifest.splash && manifest.splash.imageUrl) {
|
||||
phoneImagePathOrUrl = manifest.splash.imageUrl;
|
||||
}
|
||||
}
|
||||
|
||||
if (!phoneImagePathOrUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const outputs = [];
|
||||
if (!tabletImagePathOrUrl) {
|
||||
outputs.push({
|
||||
pathOrUrl: phoneImagePathOrUrl,
|
||||
filename: 'launch_background_image.png',
|
||||
});
|
||||
} else {
|
||||
outputs.push({
|
||||
pathOrUrl: phoneImagePathOrUrl,
|
||||
filename: 'launch_background_image~iphone.png',
|
||||
});
|
||||
outputs.push({
|
||||
pathOrUrl: tabletImagePathOrUrl,
|
||||
filename: 'launch_background_image.png',
|
||||
});
|
||||
}
|
||||
|
||||
const { supportingDirectory } = IosWorkspace.getPaths(context);
|
||||
const projectRoot = context.type === 'user' ? context.data.projectPath : supportingDirectory;
|
||||
outputs.forEach(async output => {
|
||||
const { pathOrUrl, filename } = output;
|
||||
const destinationPath = path.join(supportingDirectory, filename);
|
||||
await saveImageToPathAsync(projectRoot, pathOrUrl, destinationPath);
|
||||
});
|
||||
}
|
||||
|
||||
function _setBackgroundImageResizeMode(manifest, dom) {
|
||||
const backgroundViewMode = (() => {
|
||||
let mode;
|
||||
if (!manifest) {
|
||||
return ASPECT_FIT;
|
||||
}
|
||||
|
||||
if (manifest.ios && manifest.ios.splash && manifest.ios.splash.resizeMode) {
|
||||
mode = manifest.ios.splash.resizeMode;
|
||||
} else if (manifest.splash && manifest.splash.resizeMode) {
|
||||
mode = manifest.splash.resizeMode;
|
||||
}
|
||||
|
||||
return mode === 'cover' ? ASPECT_FILL : ASPECT_FIT;
|
||||
})();
|
||||
|
||||
const backgroundImageViewNode = dom.getElementById(backgroundImageViewID);
|
||||
if (backgroundImageViewNode) {
|
||||
backgroundImageViewNode.setAttribute('contentMode', backgroundViewMode);
|
||||
}
|
||||
}
|
||||
|
||||
async function _copyIntermediateLaunchScreenAsync(context, launchScreenPath) {
|
||||
let splashTemplateFilename;
|
||||
if (context.type === 'user') {
|
||||
const { supportingDirectory } = IosWorkspace.getPaths(context);
|
||||
splashTemplateFilename = path.join(supportingDirectory, 'LaunchScreen.xib');
|
||||
} else {
|
||||
// TODO: after shell apps use detached workspaces,
|
||||
// we can just do this with the workspace's copy instead of referencing expoSourcePath.
|
||||
const expoTemplatePath = path.join(
|
||||
context.data.expoSourcePath,
|
||||
'..',
|
||||
'exponent-view-template',
|
||||
'ios'
|
||||
);
|
||||
splashTemplateFilename = path.join(
|
||||
expoTemplatePath,
|
||||
'exponent-view-template',
|
||||
'Supporting',
|
||||
'LaunchScreen.xib'
|
||||
);
|
||||
}
|
||||
await spawnAsyncThrowError('/bin/cp', [splashTemplateFilename, launchScreenPath], {
|
||||
stdio: 'inherit',
|
||||
});
|
||||
}
|
||||
|
||||
async function configureLaunchAssetsAsync(context, intermediatesDirectory) {
|
||||
logger.info('Configuring iOS Launch Screen...');
|
||||
|
||||
fs.mkdirpSync(intermediatesDirectory);
|
||||
const { supportingDirectory } = IosWorkspace.getPaths(context);
|
||||
const config = context.config;
|
||||
|
||||
const splashIntermediateFilename = path.join(intermediatesDirectory, 'LaunchScreen.xib');
|
||||
|
||||
let xibFile = null;
|
||||
if (context.type === 'user') {
|
||||
xibFile =
|
||||
context.data.exp.ios && context.data.exp.ios.splash && context.data.exp.ios.splash.xib;
|
||||
} else {
|
||||
xibFile =
|
||||
context.data.manifest.ios &&
|
||||
context.data.manifest.ios.splash &&
|
||||
context.data.manifest.ios.splash.xibUrl;
|
||||
}
|
||||
if (xibFile) {
|
||||
if (context.type === 'user') {
|
||||
const sourcePath = path.resolve(context.data.projectPath, xibFile);
|
||||
await spawnAsyncThrowError('/bin/cp', [sourcePath, splashIntermediateFilename], {
|
||||
stdio: 'inherit',
|
||||
});
|
||||
} else {
|
||||
await saveUrlToPathAsync(xibFile, splashIntermediateFilename);
|
||||
}
|
||||
} else {
|
||||
await _copyIntermediateLaunchScreenAsync(context, splashIntermediateFilename);
|
||||
|
||||
if (manifestUsesSplashApi(config, 'ios')) {
|
||||
await transformFileContentsAsync(splashIntermediateFilename, fileString => {
|
||||
const parser = new DOMParser();
|
||||
const serializer = new XMLSerializer();
|
||||
const dom = parser.parseFromString(fileString);
|
||||
|
||||
_setBackgroundColor(config, dom);
|
||||
_setBackgroundImageResizeMode(config, dom);
|
||||
|
||||
return serializer.serializeToString(dom);
|
||||
});
|
||||
|
||||
await _saveImageAssetsAsync(context);
|
||||
}
|
||||
}
|
||||
|
||||
if (context.type === 'user') {
|
||||
await spawnAsyncThrowError(
|
||||
'/bin/cp',
|
||||
[splashIntermediateFilename, path.join(supportingDirectory, 'LaunchScreen.xib')],
|
||||
{
|
||||
stdio: 'inherit',
|
||||
}
|
||||
);
|
||||
} else {
|
||||
const splashOutputFilename = path.join(supportingDirectory, 'Base.lproj', 'LaunchScreen.nib');
|
||||
await spawnAsyncThrowError('ibtool', [
|
||||
'--compile',
|
||||
splashOutputFilename,
|
||||
splashIntermediateFilename,
|
||||
]);
|
||||
|
||||
const generatedUnnecessaryNib = path.join(supportingDirectory, 'LaunchScreen.nib');
|
||||
if (fs.existsSync(generatedUnnecessaryNib)) {
|
||||
rimraf.sync(generatedUnnecessaryNib);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { configureLaunchAssetsAsync };
|
||||
@@ -0,0 +1,27 @@
|
||||
import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
|
||||
import { getResolvedLocalesAsync } from './ExponentTools';
|
||||
|
||||
export async function writeLocalizationResourcesAsync({
|
||||
projectRoot,
|
||||
supportingDirectory,
|
||||
context,
|
||||
}) {
|
||||
let locales = {};
|
||||
if (context.type === 'user') {
|
||||
locales = await getResolvedLocalesAsync(projectRoot, context.config);
|
||||
} else if (context.type === 'service') {
|
||||
locales = context.config.locales !== undefined ? context.config.locales : {};
|
||||
}
|
||||
for (const [lang, localizationObj] of Object.entries(locales)) {
|
||||
const dir = path.join(supportingDirectory, `${lang}.lproj`);
|
||||
fs.mkdirpSync(dir);
|
||||
const strings = path.join(dir, 'InfoPlist.strings');
|
||||
const buffer = [];
|
||||
for (const [plistKey, localVersion] of Object.entries(localizationObj)) {
|
||||
buffer.push(`${plistKey} = "${localVersion}";`);
|
||||
}
|
||||
await fs.writeFile(strings, buffer.join('\n'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,760 @@
|
||||
import { ExpoConfig } from '@expo/config';
|
||||
import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
|
||||
import turtleLogger from 'turtle/logger';
|
||||
import * as AssetBundle from './AssetBundle';
|
||||
import {
|
||||
getBundleFileNameForSdkVersion,
|
||||
getManifestAsync,
|
||||
getManifestFileNameForSdkVersion,
|
||||
manifestUsesSplashApi,
|
||||
parseSdkMajorVersion,
|
||||
saveUrlToPathAsync,
|
||||
} from './ExponentTools';
|
||||
import * as IosAssetArchive from './IosAssetArchive';
|
||||
import * as IosIcons from './IosIcons';
|
||||
// @ts-ignore: No TS support
|
||||
import * as IosLaunchScreen from './IosLaunchScreen';
|
||||
// @ts-ignore: No TS support
|
||||
import * as IosLocalization from './IosLocalization';
|
||||
import * as IosPlist from './IosPlist';
|
||||
// @ts-ignore: No TS support
|
||||
import * as IosWorkspace from './IosWorkspace';
|
||||
import logger from './Logger';
|
||||
import {
|
||||
AnyStandaloneContext,
|
||||
isStandaloneContextDataService,
|
||||
StandaloneContextService,
|
||||
StandaloneContextUser,
|
||||
} from './StandaloneContext';
|
||||
|
||||
// TODO: move this somewhere else. this is duplicated in universe/exponent/template-files/keys,
|
||||
// but xdl doesn't have access to that.
|
||||
const DEFAULT_FABRIC_KEY = '81130e95ea13cd7ed9a4f455e96214902c721c99';
|
||||
const DEFAULT_GAD_APPLICATION_ID = 'ca-app-pub-3940256099942544~1458002511';
|
||||
const KERNEL_URL = 'https://expo.io/@exponent/home';
|
||||
|
||||
function _configureInfoPlistForLocalDevelopment(
|
||||
config: any,
|
||||
exp: ExpoConfig,
|
||||
): ExpoConfig {
|
||||
// add detached scheme
|
||||
if (exp.isDetached && exp.detach?.scheme) {
|
||||
if (!config.CFBundleURLTypes) {
|
||||
config.CFBundleURLTypes = [
|
||||
{
|
||||
CFBundleURLSchemes: [],
|
||||
},
|
||||
];
|
||||
}
|
||||
config.CFBundleURLTypes[0].CFBundleURLSchemes.push(exp.detach.scheme);
|
||||
}
|
||||
// for local dev, don't specify device family here
|
||||
if (config.UIDeviceFamily) {
|
||||
delete config.UIDeviceFamily;
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prints warnings or info about the configured environment for local development.
|
||||
*/
|
||||
function _logDeveloperInfoForLocalDevelopment(infoPlist: any[]): void {
|
||||
// warn about *UsageDescription changes
|
||||
const usageKeysConfigured = [];
|
||||
for (const key in infoPlist) {
|
||||
if (key in infoPlist && key.includes('UsageDescription')) {
|
||||
usageKeysConfigured.push(key);
|
||||
}
|
||||
}
|
||||
if (usageKeysConfigured.length) {
|
||||
logger.info(
|
||||
'We added some permissions keys to `Info.plist` in your detached iOS project:',
|
||||
);
|
||||
usageKeysConfigured.forEach((key) => {
|
||||
logger.info(` ${key}`);
|
||||
});
|
||||
logger.info(
|
||||
'You may want to revise them to include language appropriate to your project. ' +
|
||||
'You can also remove them if your app will never use the corresponding API. ' +
|
||||
'See the Apple docs for these keys.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function _cleanPropertyListBackupsAsync(
|
||||
context: AnyStandaloneContext,
|
||||
backupPath: string,
|
||||
): Promise<void> {
|
||||
if (context.build?.ios?.buildType !== 'client') {
|
||||
await IosPlist.cleanBackupAsync(backupPath, 'EXShell', false);
|
||||
// [dsokal] it's probably ok to clean up those two for client but no one knows if it's true for sure
|
||||
await IosPlist.cleanBackupAsync(backupPath, 'EXSDKVersions', false);
|
||||
await IosPlist.cleanBackupAsync(backupPath, 'EXBuildConstants', false);
|
||||
}
|
||||
await IosPlist.cleanBackupAsync(backupPath, 'Info', false);
|
||||
// TODO: support this in user contexts as well
|
||||
if (context instanceof StandaloneContextService) {
|
||||
const { projectName } = IosWorkspace.getPaths(context);
|
||||
await IosPlist.cleanBackupAsync(
|
||||
backupPath,
|
||||
`${projectName}.entitlements`,
|
||||
false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the manifest and JS bundle to the NSBundle.
|
||||
*/
|
||||
async function _preloadManifestAndBundleAsync(
|
||||
manifest: { bundleUrl: string; [key: string]: any },
|
||||
supportingDirectory: string,
|
||||
manifestFilename: string,
|
||||
bundleFilename: string,
|
||||
): Promise<void> {
|
||||
const bundleUrl = manifest.bundleUrl;
|
||||
await fs.writeFile(
|
||||
path.join(supportingDirectory, manifestFilename),
|
||||
JSON.stringify(manifest),
|
||||
);
|
||||
await saveUrlToPathAsync(
|
||||
bundleUrl,
|
||||
path.join(supportingDirectory, bundleFilename),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* This method only makes sense when operating on a context with sdk version < 26.
|
||||
*/
|
||||
async function _maybeLegacyPreloadKernelManifestAndBundleAsync(
|
||||
context: AnyStandaloneContext,
|
||||
manifestFilename: string,
|
||||
bundleFilename: string,
|
||||
): Promise<void> {
|
||||
const { supportingDirectory } = IosWorkspace.getPaths(context);
|
||||
const sdkVersionSupported = await IosWorkspace.getNewestSdkVersionSupportedAsync(
|
||||
context,
|
||||
);
|
||||
|
||||
if (parseSdkMajorVersion(sdkVersionSupported) < 26) {
|
||||
logger.info('Preloading Expo kernel JS...');
|
||||
const kernelManifest = await getManifestAsync(KERNEL_URL, {
|
||||
'Exponent-SDK-Version': sdkVersionSupported,
|
||||
'Exponent-Platform': 'ios',
|
||||
'Accept': 'application/expo+json,application/json',
|
||||
});
|
||||
return _preloadManifestAndBundleAsync(
|
||||
kernelManifest,
|
||||
supportingDirectory,
|
||||
manifestFilename,
|
||||
bundleFilename,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure a standalone entitlements file.
|
||||
*/
|
||||
async function _configureEntitlementsAsync(
|
||||
context: AnyStandaloneContext,
|
||||
): Promise<any> {
|
||||
if (context instanceof StandaloneContextUser) {
|
||||
// don't modify .entitlements, print info/instructions
|
||||
const { exp } = context.data;
|
||||
logger.info(
|
||||
'Your iOS ExpoKit project will not contain an .entitlements file by default. ' +
|
||||
'If you need specific Apple entitlements, enable them manually via Xcode or the Apple Developer website.',
|
||||
);
|
||||
const keysToFlag = [];
|
||||
if (exp.ios?.usesIcloudStorage) {
|
||||
keysToFlag.push('ios.usesIcloudStorage');
|
||||
}
|
||||
if (exp.ios?.associatedDomains) {
|
||||
keysToFlag.push('ios.associatedDomains');
|
||||
}
|
||||
if (keysToFlag.length) {
|
||||
logger.info(
|
||||
'We noticed the following keys in your project which may require entitlements:',
|
||||
);
|
||||
keysToFlag.forEach((key) => {
|
||||
logger.info(` ${key}`);
|
||||
});
|
||||
}
|
||||
return {};
|
||||
} else {
|
||||
// modify the .entitlements file
|
||||
const { projectName, supportingDirectory } = IosWorkspace.getPaths(context);
|
||||
const manifest =
|
||||
isStandaloneContextDataService(context.data) && context.data.manifest;
|
||||
const entitlementsFilename = `${projectName}.entitlements`;
|
||||
const { appleTeamId } = context.build.ios || ({} as any);
|
||||
if (!fs.existsSync(path.join(supportingDirectory, entitlementsFilename))) {
|
||||
await IosPlist.createBlankAsync(
|
||||
supportingDirectory,
|
||||
entitlementsFilename,
|
||||
);
|
||||
}
|
||||
const result = IosPlist.modifyAsync(
|
||||
supportingDirectory,
|
||||
entitlementsFilename,
|
||||
(entitlements) => {
|
||||
if (manifest.ios?.entitlements) {
|
||||
for (const key in manifest.ios.entitlements) {
|
||||
if (manifest.ios.entitlements.hasOwnProperty(key)) {
|
||||
entitlements[key] = manifest.ios.entitlements[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// push notif entitlement changes based on build configuration
|
||||
entitlements['aps-environment'] =
|
||||
context.build.configuration === 'Release'
|
||||
? 'production'
|
||||
: 'development';
|
||||
|
||||
// remove iCloud-specific entitlements if the developer isn't using iCloud Storage with DocumentPicker
|
||||
if (manifest.ios?.usesIcloudStorage && appleTeamId) {
|
||||
entitlements['com.apple.developer.icloud-container-identifiers'] = [
|
||||
'iCloud.' + manifest.ios.bundleIdentifier,
|
||||
];
|
||||
entitlements['com.apple.developer.ubiquity-container-identifiers'] = [
|
||||
'iCloud.' + manifest.ios.bundleIdentifier,
|
||||
];
|
||||
entitlements['com.apple.developer.ubiquity-kvstore-identifier'] =
|
||||
appleTeamId + '.' + manifest.ios.bundleIdentifier;
|
||||
entitlements['com.apple.developer.icloud-services'] = [
|
||||
'CloudDocuments',
|
||||
];
|
||||
} else {
|
||||
[
|
||||
'com.apple.developer.icloud-container-identifiers',
|
||||
'com.apple.developer.icloud-services',
|
||||
'com.apple.developer.ubiquity-container-identifiers',
|
||||
'com.apple.developer.ubiquity-kvstore-identifier',
|
||||
].forEach((key) => {
|
||||
if (entitlements.hasOwnProperty(key)) {
|
||||
delete entitlements[key];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (manifest.ios?.usesAppleSignIn) {
|
||||
entitlements['com.apple.developer.applesignin'] = ['Default'];
|
||||
} else if (
|
||||
entitlements.hasOwnProperty('com.apple.developer.applesignin')
|
||||
) {
|
||||
delete entitlements['com.apple.developer.applesignin'];
|
||||
}
|
||||
|
||||
if (manifest.ios?.accessesContactNotes) {
|
||||
entitlements['com.apple.developer.contacts.notes'] =
|
||||
manifest.ios.accessesContactNotes;
|
||||
} else if (
|
||||
entitlements.hasOwnProperty('com.apple.developer.contacts.notes')
|
||||
) {
|
||||
delete entitlements['com.apple.developer.contacts.notes'];
|
||||
}
|
||||
|
||||
// Add app associated domains remove exp-specific ones.
|
||||
if (manifest.ios?.associatedDomains) {
|
||||
entitlements['com.apple.developer.associated-domains'] =
|
||||
manifest.ios.associatedDomains;
|
||||
} else if (
|
||||
entitlements.hasOwnProperty('com.apple.developer.associated-domains')
|
||||
) {
|
||||
delete entitlements['com.apple.developer.associated-domains'];
|
||||
}
|
||||
|
||||
// for now, remove any merchant ID in shell apps
|
||||
// (TODO: better plan for payments)
|
||||
delete entitlements['com.apple.developer.in-app-payments'];
|
||||
|
||||
return entitlements;
|
||||
},
|
||||
);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the private config for a project.
|
||||
* For standalone apps, this is copied into a separate context field context.data.privateConfig
|
||||
* by the turtle builder. For a local project, this is available in app.json under ios.config.
|
||||
*/
|
||||
function _getPrivateConfig(
|
||||
context: AnyStandaloneContext,
|
||||
): { [key: string]: any } | undefined {
|
||||
if (context instanceof StandaloneContextService) {
|
||||
return context.data.privateConfig;
|
||||
} else {
|
||||
const exp = context.data.exp;
|
||||
if (exp && exp.ios) {
|
||||
return exp.ios.config;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function _isAppleUsageDescriptionKey(key: string): boolean {
|
||||
return key.includes('UsageDescription');
|
||||
}
|
||||
|
||||
function ensureStringArray(input: any): string[] {
|
||||
// Normalize schemes and filter invalid schemes.
|
||||
const stringArray = (Array.isArray(input) ? input : [input]).filter(
|
||||
(scheme) => typeof scheme === 'string' && !!scheme,
|
||||
);
|
||||
return stringArray;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure an iOS Info.plist for a standalone app.
|
||||
*/
|
||||
async function _configureInfoPlistAsync(
|
||||
context: AnyStandaloneContext,
|
||||
): Promise<void> {
|
||||
const { supportingDirectory } = IosWorkspace.getPaths(context);
|
||||
const config = context.config;
|
||||
const privateConfig = _getPrivateConfig(context);
|
||||
|
||||
const result = await IosPlist.modifyAsync(
|
||||
supportingDirectory,
|
||||
'Info',
|
||||
(infoPlist) => {
|
||||
// make sure this happens first:
|
||||
// apply any custom information from ios.infoPlist prior to all other exponent config
|
||||
const usageDescriptionKeysConfigured: { [key: string]: any } = {};
|
||||
let extraConfig;
|
||||
if (config.ios && config.ios.infoPlist) {
|
||||
extraConfig = config.ios.infoPlist;
|
||||
for (const key in extraConfig) {
|
||||
if (extraConfig.hasOwnProperty(key)) {
|
||||
infoPlist[key] = extraConfig[key];
|
||||
|
||||
// if the user provides *UsageDescription keys, don't override them later.
|
||||
if (_isAppleUsageDescriptionKey(key)) {
|
||||
usageDescriptionKeysConfigured[key] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// reset the status bar style to the default gray. this can be removed if we
|
||||
// ever change expo client to use `UIStatusBarStyleDefault` instead of
|
||||
// `UIStatusBarStyleLightContent`
|
||||
if (!extraConfig?.UIStatusBarStyle) {
|
||||
infoPlist.UIStatusBarStyle = 'UIStatusBarStyleDefault';
|
||||
}
|
||||
|
||||
// bundle id
|
||||
infoPlist.CFBundleIdentifier = config?.ios?.bundleIdentifier ?? null;
|
||||
if (!infoPlist.CFBundleIdentifier) {
|
||||
throw new Error(
|
||||
`Cannot configure an iOS app with no bundle identifier.`,
|
||||
);
|
||||
}
|
||||
|
||||
// app name
|
||||
infoPlist.CFBundleName = config.name;
|
||||
infoPlist.CFBundleDisplayName = context.build.isExpoClientBuild()
|
||||
? 'Expo (Custom)'
|
||||
: config.name;
|
||||
|
||||
// determine app linking schemes
|
||||
const multiPlatformSchemes = ensureStringArray(config.scheme);
|
||||
const iosSchemes = ensureStringArray(config.ios?.scheme);
|
||||
const linkingSchemes = [...multiPlatformSchemes, ...iosSchemes];
|
||||
if (config.facebookScheme && config.facebookScheme.startsWith('fb')) {
|
||||
linkingSchemes.push(config.facebookScheme);
|
||||
}
|
||||
|
||||
if (
|
||||
privateConfig &&
|
||||
privateConfig.googleSignIn &&
|
||||
privateConfig.googleSignIn.reservedClientId
|
||||
) {
|
||||
linkingSchemes.push(privateConfig.googleSignIn.reservedClientId);
|
||||
}
|
||||
|
||||
// remove exp scheme, add app scheme(s)
|
||||
infoPlist.CFBundleURLTypes = [
|
||||
{
|
||||
CFBundleURLSchemes: linkingSchemes,
|
||||
},
|
||||
{
|
||||
// Add the generic oauth redirect, it's important that it has the name
|
||||
// 'OAuthRedirect' so we can find it in app code.
|
||||
CFBundleURLName: 'OAuthRedirect',
|
||||
CFBundleURLSchemes: [infoPlist.CFBundleIdentifier],
|
||||
},
|
||||
];
|
||||
|
||||
// add or remove other facebook config
|
||||
if (config.facebookAppId) {
|
||||
infoPlist.FacebookAppID = config.facebookAppId;
|
||||
let queriesSchemes = infoPlist.LSApplicationQueriesSchemes || [];
|
||||
queriesSchemes = queriesSchemes.concat([
|
||||
'fbapi',
|
||||
'fb-messenger-api',
|
||||
'fbauth2',
|
||||
'fbshareextension',
|
||||
]);
|
||||
infoPlist.LSApplicationQueriesSchemes = queriesSchemes;
|
||||
} else {
|
||||
delete infoPlist.FacebookAppID;
|
||||
}
|
||||
if (config.facebookDisplayName) {
|
||||
infoPlist.FacebookDisplayName = config.facebookDisplayName;
|
||||
} else {
|
||||
delete infoPlist.FacebookDisplayName;
|
||||
}
|
||||
|
||||
if (parseSdkMajorVersion(config.sdkVersion) >= 36) {
|
||||
infoPlist.FacebookAutoInitEnabled =
|
||||
config.facebookAutoInitEnabled || false;
|
||||
infoPlist.FacebookAutoLogAppEventsEnabled =
|
||||
config.facebookAutoLogAppEventsEnabled || false;
|
||||
infoPlist.FacebookAdvertiserIDCollectionEnabled =
|
||||
config.facebookAdvertiserIDCollectionEnabled || false;
|
||||
}
|
||||
|
||||
// set ITSAppUsesNonExemptEncryption to let people skip manually
|
||||
// entering it in iTunes Connect
|
||||
if (
|
||||
privateConfig &&
|
||||
privateConfig.hasOwnProperty('usesNonExemptEncryption') &&
|
||||
privateConfig.usesNonExemptEncryption === false
|
||||
) {
|
||||
infoPlist.ITSAppUsesNonExemptEncryption = false;
|
||||
}
|
||||
|
||||
// google maps api key
|
||||
if (privateConfig && privateConfig.googleMapsApiKey) {
|
||||
infoPlist.GMSApiKey = privateConfig.googleMapsApiKey;
|
||||
}
|
||||
|
||||
// Google Mobile Ads App ID
|
||||
// The app crashes if the app ID isn't provided, so if the user
|
||||
// doesn't provide the ID, we leave the sample one.
|
||||
infoPlist.GADApplicationIdentifier =
|
||||
(privateConfig && privateConfig.googleMobileAdsAppId) ||
|
||||
DEFAULT_GAD_APPLICATION_ID;
|
||||
|
||||
// Auto-init of Google App Measurement
|
||||
// unless the user explicitly specifies they want to auto-init, we set delay to true
|
||||
infoPlist.GADDelayAppMeasurementInit = !(
|
||||
privateConfig && privateConfig.googleMobileAdsAutoInit
|
||||
);
|
||||
|
||||
// use version from manifest
|
||||
const version = config.version ? config.version : '0.0.0';
|
||||
const buildNumber =
|
||||
config.ios && config.ios.buildNumber ? config.ios.buildNumber : '1';
|
||||
infoPlist.CFBundleShortVersionString = version;
|
||||
infoPlist.CFBundleVersion = buildNumber;
|
||||
|
||||
infoPlist.Fabric = {
|
||||
APIKey:
|
||||
(privateConfig &&
|
||||
privateConfig.fabric &&
|
||||
privateConfig.fabric.apiKey) ||
|
||||
DEFAULT_FABRIC_KEY,
|
||||
Kits: [
|
||||
{
|
||||
KitInfo: {},
|
||||
KitName: 'Crashlytics',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
if (privateConfig && privateConfig.branch) {
|
||||
infoPlist.branch_key = {
|
||||
live: privateConfig.branch.apiKey,
|
||||
};
|
||||
}
|
||||
|
||||
const permissionsAppName = config.name ? config.name : 'this app';
|
||||
for (const key in infoPlist) {
|
||||
if (
|
||||
infoPlist.hasOwnProperty(key) &&
|
||||
_isAppleUsageDescriptionKey(key) &&
|
||||
!usageDescriptionKeysConfigured.hasOwnProperty(key)
|
||||
) {
|
||||
infoPlist[key] = infoPlist[key].replace(
|
||||
'Expo experiences',
|
||||
permissionsAppName,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 1 is iPhone, 2 is iPad
|
||||
infoPlist.UIDeviceFamily =
|
||||
config.ios && config.ios.supportsTablet ? [1, 2] : [1];
|
||||
|
||||
// allow iPad-only
|
||||
if (config.ios && config.ios.isTabletOnly) {
|
||||
infoPlist.UIDeviceFamily = [2];
|
||||
}
|
||||
|
||||
// Whether requires full screen on iPad
|
||||
infoPlist.UIRequiresFullScreen =
|
||||
config.ios && config.ios.requireFullScreen;
|
||||
if (infoPlist.UIRequiresFullScreen == null) {
|
||||
// NOTES: This is defaulted to `true` for now to match the behavior prior to SDK 34,
|
||||
// but will change to `false` in a future SDK version.
|
||||
infoPlist.UIRequiresFullScreen = true;
|
||||
}
|
||||
// Cast to make sure that it is a boolean.
|
||||
infoPlist.UIRequiresFullScreen = Boolean(infoPlist.UIRequiresFullScreen);
|
||||
|
||||
// Put `ios.userInterfaceStyle` into `UIUserInterfaceStyle` property of Info.plist
|
||||
const userInterfaceStyle = config.ios && config.ios.userInterfaceStyle;
|
||||
if (userInterfaceStyle) {
|
||||
// To convert our config value to the InfoPlist value, we can just capitalize it.
|
||||
infoPlist.UIUserInterfaceStyle = _mapUserInterfaceStyleForInfoPlist(
|
||||
userInterfaceStyle,
|
||||
);
|
||||
}
|
||||
|
||||
// context-specific plist changes
|
||||
if (context instanceof StandaloneContextUser) {
|
||||
infoPlist = _configureInfoPlistForLocalDevelopment(
|
||||
infoPlist,
|
||||
context.data.exp,
|
||||
);
|
||||
_logDeveloperInfoForLocalDevelopment(infoPlist);
|
||||
}
|
||||
|
||||
if (context instanceof StandaloneContextService && context.build.ios) {
|
||||
infoPlist.CFBundleExecutable = context.build.ios.bundleExecutable;
|
||||
}
|
||||
|
||||
return infoPlist;
|
||||
},
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure EXShell.plist for a standalone app.
|
||||
*/
|
||||
async function _configureShellPlistAsync(
|
||||
context: AnyStandaloneContext,
|
||||
): Promise<void> {
|
||||
const { supportingDirectory } = IosWorkspace.getPaths(context);
|
||||
const config = context.config;
|
||||
const buildPhaseLogger = logger.withFields({
|
||||
buildPhase: 'configuring NSBundle',
|
||||
});
|
||||
|
||||
await IosPlist.modifyAsync(supportingDirectory, 'EXShell', (shellPlist) => {
|
||||
// older SDK versions rely on `isShell` flag in xdl
|
||||
if (parseSdkMajorVersion(config.sdkVersion) < 28) {
|
||||
shellPlist.isShell = true;
|
||||
}
|
||||
shellPlist.manifestUrl = context.published.url;
|
||||
shellPlist.releaseChannel = context.published.releaseChannel;
|
||||
if (
|
||||
isStandaloneContextDataService(context.data) &&
|
||||
context.data.testEnvironment
|
||||
) {
|
||||
shellPlist.testEnvironment = context.data.testEnvironment;
|
||||
}
|
||||
if (config.ios && config.ios.permissions) {
|
||||
shellPlist.permissions = config.ios.permissions;
|
||||
}
|
||||
if (context instanceof StandaloneContextUser) {
|
||||
// disable manifest verification on detached apps until
|
||||
// the developer adds the correct entitlements to their bundle id.
|
||||
shellPlist.isManifestVerificationBypassed = true;
|
||||
}
|
||||
if (config.ios && config.ios.hasOwnProperty('isRemoteJSEnabled')) {
|
||||
// deprecated, overridden by updates.enabled if that exists
|
||||
shellPlist.areRemoteUpdatesEnabled = config.ios.isRemoteJSEnabled;
|
||||
}
|
||||
if (config.updates && config.updates.hasOwnProperty('enabled')) {
|
||||
// enable/disable code push if the developer provided specific behavior
|
||||
shellPlist.areRemoteUpdatesEnabled = config.updates.enabled;
|
||||
}
|
||||
if (config.updates?.hasOwnProperty('checkAutomatically')) {
|
||||
shellPlist.updatesCheckAutomatically =
|
||||
config.updates.checkAutomatically !== 'ON_ERROR_RECOVERY';
|
||||
}
|
||||
if (config.updates?.hasOwnProperty('fallbackToCacheTimeout')) {
|
||||
shellPlist.updatesFallbackToCacheTimeout =
|
||||
config.updates.fallbackToCacheTimeout;
|
||||
}
|
||||
if (
|
||||
!manifestUsesSplashApi(config, 'ios') &&
|
||||
parseSdkMajorVersion(config.sdkVersion) < 28
|
||||
) {
|
||||
// for people still using the old loading api, hide the native splash screen.
|
||||
// we can remove this code eventually.
|
||||
shellPlist.isSplashScreenDisabled = true;
|
||||
}
|
||||
|
||||
buildPhaseLogger.info('Using standalone config:', shellPlist);
|
||||
return shellPlist;
|
||||
});
|
||||
}
|
||||
|
||||
async function _configureConstantsPlistAsync(context: AnyStandaloneContext) {
|
||||
if (context instanceof StandaloneContextUser) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { supportingDirectory } = IosWorkspace.getPaths(context);
|
||||
await IosPlist.modifyAsync(
|
||||
supportingDirectory,
|
||||
'EXBuildConstants',
|
||||
(constantsConfig) => {
|
||||
constantsConfig.API_SERVER_ENDPOINT =
|
||||
process.env.ENVIRONMENT === 'staging'
|
||||
? 'https://staging.exp.host/--/api/v2/'
|
||||
: 'https://exp.host/--/api/v2/';
|
||||
return constantsConfig;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function _configureGoogleServicesPlistAsync(
|
||||
context: AnyStandaloneContext,
|
||||
): Promise<void> {
|
||||
if (context instanceof StandaloneContextUser) {
|
||||
return;
|
||||
}
|
||||
if (context.data.manifest.ios?.googleServicesFile) {
|
||||
const { supportingDirectory } = IosWorkspace.getPaths(context);
|
||||
await fs.writeFile(
|
||||
path.join(supportingDirectory, 'GoogleService-Info.plist'),
|
||||
context.data.manifest.ios?.googleServicesFile,
|
||||
'base64',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function configureAsync(
|
||||
context: AnyStandaloneContext,
|
||||
): Promise<void> {
|
||||
const buildPhaseLogger = logger.withFields({
|
||||
buildPhase: 'configuring NSBundle',
|
||||
});
|
||||
|
||||
const {
|
||||
intermediatesDirectory,
|
||||
iosProjectDirectory,
|
||||
projectRootDirectory,
|
||||
projectName,
|
||||
supportingDirectory,
|
||||
} = IosWorkspace.getPaths(context);
|
||||
|
||||
const isExpoClientBuild = context.build.isExpoClientBuild();
|
||||
|
||||
if (!isExpoClientBuild && !context.published.url) {
|
||||
throw new Error(`Can't configure a NSBundle without a published url.`);
|
||||
}
|
||||
|
||||
// ensure the intermediates directory is clean of any prior build's artifacts, in the event we
|
||||
// share directories across builds
|
||||
await fs.remove(intermediatesDirectory);
|
||||
|
||||
try {
|
||||
// common configuration for all contexts
|
||||
buildPhaseLogger.info(
|
||||
`Modifying NSBundle configuration at ${supportingDirectory}...`,
|
||||
);
|
||||
await _configureInfoPlistAsync(context);
|
||||
if (!isExpoClientBuild) {
|
||||
await _configureShellPlistAsync(context);
|
||||
}
|
||||
await _configureEntitlementsAsync(context);
|
||||
await _configureConstantsPlistAsync(context);
|
||||
await _configureGoogleServicesPlistAsync(context);
|
||||
if (!isExpoClientBuild) {
|
||||
await IosLaunchScreen.configureLaunchAssetsAsync(
|
||||
context,
|
||||
intermediatesDirectory,
|
||||
);
|
||||
await IosLocalization.writeLocalizationResourcesAsync({
|
||||
projectRoot: projectRootDirectory,
|
||||
supportingDirectory,
|
||||
context,
|
||||
});
|
||||
}
|
||||
|
||||
if (isExpoClientBuild) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (context instanceof StandaloneContextUser) {
|
||||
const iconPath = path.join(
|
||||
iosProjectDirectory,
|
||||
projectName,
|
||||
'Assets.xcassets',
|
||||
'AppIcon.appiconset',
|
||||
);
|
||||
await IosIcons.createAndWriteIconsToPathAsync(context, iconPath);
|
||||
} else if (context instanceof StandaloneContextService) {
|
||||
buildPhaseLogger.info('Bundling assets...');
|
||||
try {
|
||||
await AssetBundle.bundleAsync(
|
||||
context,
|
||||
context.data.manifest.bundledAssets,
|
||||
supportingDirectory,
|
||||
);
|
||||
} catch (e) {
|
||||
throw new Error(`Asset bundling failed: ${e}`);
|
||||
}
|
||||
buildPhaseLogger.info('Compiling resources...');
|
||||
await IosAssetArchive.buildAssetArchiveAsync(
|
||||
context,
|
||||
supportingDirectory,
|
||||
intermediatesDirectory,
|
||||
);
|
||||
await _preloadManifestAndBundleAsync(
|
||||
context.data.manifest,
|
||||
supportingDirectory,
|
||||
getManifestFileNameForSdkVersion(context.data.manifest.sdkVersion),
|
||||
getBundleFileNameForSdkVersion(context.data.manifest.sdkVersion),
|
||||
);
|
||||
}
|
||||
|
||||
await _maybeLegacyPreloadKernelManifestAndBundleAsync(
|
||||
context,
|
||||
'kernel-manifest.json',
|
||||
'kernel.ios.bundle',
|
||||
);
|
||||
} catch (err) {
|
||||
turtleLogger.error(err);
|
||||
throw err;
|
||||
} finally {
|
||||
buildPhaseLogger.info('Cleaning up iOS...');
|
||||
await Promise.all([
|
||||
_cleanPropertyListBackupsAsync(context, supportingDirectory),
|
||||
fs.remove(intermediatesDirectory),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
function _mapUserInterfaceStyleForInfoPlist(
|
||||
userInterfaceStyle: string,
|
||||
): string | undefined {
|
||||
switch (userInterfaceStyle) {
|
||||
case 'light':
|
||||
return 'Light';
|
||||
case 'dark':
|
||||
return 'Dark';
|
||||
case 'automatic':
|
||||
return 'Automatic';
|
||||
default:
|
||||
logger.warn(
|
||||
`User interface style "${userInterfaceStyle}" is not supported. ` +
|
||||
`Supported values: "light", "dark", "automatic".`,
|
||||
);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import plist from '@expo/plist';
|
||||
import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
|
||||
import { spawnAsyncThrowError } from './ExponentTools';
|
||||
import logger from './Logger';
|
||||
|
||||
function _getNormalizedPlistFilename(plistName: string) {
|
||||
let plistFilename;
|
||||
if (plistName.indexOf('.') !== -1) {
|
||||
plistFilename = plistName;
|
||||
} else {
|
||||
plistFilename = `${plistName}.plist`;
|
||||
}
|
||||
return plistFilename;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param plistName base filename of property list. if no extension, assumes .plist
|
||||
*/
|
||||
async function modifyAsync(plistPath: string, plistName: string, transform: (config: any) => any) {
|
||||
const plistFilename = _getNormalizedPlistFilename(plistName);
|
||||
const configPlistName = path.join(plistPath, plistFilename);
|
||||
const configFilename = path.join(plistPath, `${plistName}.json`);
|
||||
|
||||
// grab original plist as json object
|
||||
let config;
|
||||
if (process.platform === 'darwin') {
|
||||
await spawnAsyncThrowError('plutil', [
|
||||
'-convert',
|
||||
'json',
|
||||
configPlistName,
|
||||
'-o',
|
||||
configFilename,
|
||||
]);
|
||||
const configContents = await fs.readFile(configFilename, 'utf8');
|
||||
|
||||
try {
|
||||
config = JSON.parse(configContents);
|
||||
} catch (e) {
|
||||
logger.info(`Error parsing ${configFilename}`, e);
|
||||
logger.info('The erroneous file contents was:', configContents);
|
||||
config = {};
|
||||
}
|
||||
} else {
|
||||
config = plist.parse(fs.readFileSync(configPlistName, 'utf8'));
|
||||
}
|
||||
|
||||
// apply transformation
|
||||
config = await transform(config);
|
||||
|
||||
// back up old plist and swap in modified one
|
||||
fs.copyFileSync(configPlistName, `${configPlistName}.bak`);
|
||||
await fs.writeFile(configFilename, JSON.stringify(config));
|
||||
if (process.platform === 'darwin') {
|
||||
await spawnAsyncThrowError('plutil', [
|
||||
'-convert',
|
||||
'xml1',
|
||||
configFilename,
|
||||
'-o',
|
||||
configPlistName,
|
||||
]);
|
||||
} else {
|
||||
await fs.writeFile(configPlistName, plist.build(config));
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
async function createBlankAsync(plistPath: string, plistName: string) {
|
||||
// write empty json file
|
||||
const emptyConfig = {};
|
||||
const tmpConfigFile = path.join(plistPath, `${plistName}.json`);
|
||||
await fs.writeFile(tmpConfigFile, JSON.stringify(emptyConfig));
|
||||
|
||||
// convert to plist
|
||||
const plistFilename = _getNormalizedPlistFilename(plistName);
|
||||
const configPlistName = path.join(plistPath, plistFilename);
|
||||
if (process.platform === 'darwin') {
|
||||
await spawnAsyncThrowError('plutil', [
|
||||
'-convert',
|
||||
'xml1',
|
||||
tmpConfigFile,
|
||||
'-o',
|
||||
configPlistName,
|
||||
]);
|
||||
} else {
|
||||
await fs.writeFile(configPlistName, JSON.stringify(plist.build(emptyConfig)));
|
||||
}
|
||||
|
||||
// remove tmp json file
|
||||
fs.removeSync(tmpConfigFile);
|
||||
}
|
||||
|
||||
async function cleanBackupAsync(plistPath: string, plistName: string, restoreOriginal = true) {
|
||||
const plistFilename = _getNormalizedPlistFilename(plistName);
|
||||
const configPlistName = path.join(plistPath, plistFilename);
|
||||
const configFilename = path.join(plistPath, `${plistName}.json`);
|
||||
const backupPlistPath = `${configPlistName}.bak`;
|
||||
|
||||
if (restoreOriginal && (await fs.pathExists(backupPlistPath))) {
|
||||
await fs.copy(backupPlistPath, configPlistName);
|
||||
}
|
||||
|
||||
await fs.remove(backupPlistPath);
|
||||
await fs.remove(configFilename);
|
||||
}
|
||||
|
||||
export { modifyAsync, cleanBackupAsync, createBlankAsync };
|
||||
@@ -0,0 +1,586 @@
|
||||
import JsonFile from '@expo/json-file';
|
||||
import fs from 'fs-extra';
|
||||
import { sync as globSync } from 'glob';
|
||||
import indentString from 'indent-string';
|
||||
import path from 'path';
|
||||
|
||||
import { parseSdkMajorVersion } from './ExponentTools';
|
||||
|
||||
function _validatePodfileSubstitutions(substitutions) {
|
||||
const validKeys = [
|
||||
// a pod dependency on ExpoKit (can be local or remote)
|
||||
'EXPOKIT_DEPENDENCY',
|
||||
// local path to ExpoKit dependency
|
||||
'EXPOKIT_PATH',
|
||||
// tag to use for ExpoKit dependency
|
||||
'EXPOKIT_TAG',
|
||||
// the contents of dependencies.json enumerated as deps in podfile format
|
||||
'EXPONENT_CLIENT_DEPS',
|
||||
// postinstall for detached projects (defines EX_DETACHED among other things)
|
||||
'PODFILE_DETACHED_POSTINSTALL',
|
||||
// same as previous but also defines EX_DETACHED_SERVICE
|
||||
'PODFILE_DETACHED_SERVICE_POSTINSTALL',
|
||||
// ExponentIntegrationTests
|
||||
'PODFILE_TEST_TARGET',
|
||||
// unversioned react native pod dependency, probably at the path given in
|
||||
// REACT_NATIVE_PATH, with a bunch of subspecs.
|
||||
'PODFILE_UNVERSIONED_RN_DEPENDENCY',
|
||||
// postinstall hook for unversioned deps
|
||||
'PODFILE_UNVERSIONED_POSTINSTALL',
|
||||
// versioned rn dependencies (paths to versioned-react-native directories)
|
||||
// read from template files
|
||||
'PODFILE_VERSIONED_RN_DEPENDENCIES',
|
||||
// versioned rn postinstall hooks read from template files
|
||||
'PODFILE_VERSIONED_POSTINSTALLS',
|
||||
// list of generated Expo subspecs to include under a versioned react native dependency
|
||||
'REACT_NATIVE_EXPO_SUBSPECS',
|
||||
// path to use for the unversioned react native dependency
|
||||
'REACT_NATIVE_PATH',
|
||||
// name of the main build target, e.g. Exponent
|
||||
'TARGET_NAME',
|
||||
// path from Podfile to versioned-react-native
|
||||
'VERSIONED_REACT_NATIVE_PATH',
|
||||
// Expo universal modules dependencies
|
||||
'PODFILE_UNVERSIONED_EXPO_MODULES_DEPENDENCIES',
|
||||
// Universal modules configurations to be included in the Podfile
|
||||
'UNIVERSAL_MODULES',
|
||||
// Relative path from iOS project directory to folder where unimodules are installed.
|
||||
'UNIVERSAL_MODULES_PATH',
|
||||
];
|
||||
|
||||
for (const key in substitutions) {
|
||||
if (substitutions.hasOwnProperty(key)) {
|
||||
if (!validKeys.includes(key)) {
|
||||
throw new Error(`Unrecognized Podfile template key: ${key}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function _renderExpoKitDependency(options, sdkVersion) {
|
||||
const sdkMajorVersion = parseSdkMajorVersion(sdkVersion);
|
||||
let attributes;
|
||||
if (options.expoKitPath) {
|
||||
attributes = {
|
||||
path: path.join(options.expoKitPath, 'ios'),
|
||||
};
|
||||
} else if (options.expoKitTag) {
|
||||
attributes = {
|
||||
git: 'http://github.com/expo/expo.git',
|
||||
tag: options.expoKitTag,
|
||||
};
|
||||
} else {
|
||||
attributes = {
|
||||
git: 'http://github.com/expo/expo.git',
|
||||
branch: 'master',
|
||||
};
|
||||
}
|
||||
|
||||
// GL subspec is available as of SDK 26
|
||||
// but removed together with CPP subspec in SDK 29
|
||||
if (sdkMajorVersion < 26) {
|
||||
attributes.subspecs = ['Core', 'CPP'];
|
||||
} else if (sdkMajorVersion < 29 && !process.env.EXPO_UNIVERSE_DIR) {
|
||||
attributes.subspecs = ['Core', 'CPP', 'GL'];
|
||||
} else {
|
||||
attributes.subspecs = ['Core'];
|
||||
}
|
||||
attributes.inhibit_warnings = true;
|
||||
|
||||
const dependency = `pod 'ExpoKit',
|
||||
${indentString(_renderDependencyAttributes(attributes), 2)}`;
|
||||
|
||||
return indentString(dependency, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param sdkVersion if specified, indicates which sdkVersion this project uses
|
||||
* as 'UNVERSIONED', e.g. if we are detaching a sdk15 project, we render
|
||||
* an unversioned dependency pointing at RN#sdk-15.
|
||||
*/
|
||||
function _renderUnversionedReactNativeDependency(options, sdkVersion) {
|
||||
const sdkMajorVersion = parseSdkMajorVersion(sdkVersion);
|
||||
|
||||
if (sdkMajorVersion >= 39) {
|
||||
return indentString(`
|
||||
# Install React Native and its dependencies
|
||||
require_relative '../node_modules/react-native/scripts/react_native_pods'
|
||||
use_react_native!(production: true)`);
|
||||
}
|
||||
|
||||
if (sdkMajorVersion >= 36) {
|
||||
return indentString(
|
||||
`
|
||||
# Install React Native and its dependencies
|
||||
require_relative '../node_modules/react-native/scripts/autolink-ios.rb'
|
||||
use_react_native!`
|
||||
);
|
||||
}
|
||||
|
||||
const glogLibraryName = sdkMajorVersion < 26 ? 'GLog' : 'glog';
|
||||
return indentString(
|
||||
`
|
||||
${_renderUnversionedReactDependency(options)}
|
||||
${_renderUnversionedYogaDependency(options)}
|
||||
${_renderUnversionedThirdPartyDependency(
|
||||
'DoubleConversion',
|
||||
path.join('third-party-podspecs', 'DoubleConversion.podspec'),
|
||||
options
|
||||
)}
|
||||
${_renderUnversionedThirdPartyDependency(
|
||||
'Folly',
|
||||
path.join('third-party-podspecs', 'Folly.podspec'),
|
||||
options
|
||||
)}
|
||||
${_renderUnversionedThirdPartyDependency(
|
||||
glogLibraryName,
|
||||
path.join('third-party-podspecs', `${glogLibraryName}.podspec`),
|
||||
options
|
||||
)}
|
||||
`,
|
||||
2
|
||||
);
|
||||
}
|
||||
|
||||
function _renderUnversionedReactDependency(options, sdkVersion) {
|
||||
if (!options.reactNativePath) {
|
||||
throw new Error(`Unsupported options for RN dependency: ${options}`);
|
||||
}
|
||||
const attributes = {
|
||||
path: options.reactNativePath,
|
||||
inhibit_warnings: true,
|
||||
subspecs: [
|
||||
'Core',
|
||||
'ART',
|
||||
'RCTActionSheet',
|
||||
'RCTAnimation',
|
||||
'RCTCameraRoll',
|
||||
'RCTGeolocation',
|
||||
'RCTImage',
|
||||
'RCTNetwork',
|
||||
'RCTText',
|
||||
'RCTVibration',
|
||||
'RCTWebSocket',
|
||||
'DevSupport',
|
||||
'CxxBridge',
|
||||
],
|
||||
};
|
||||
return `pod 'React',
|
||||
${indentString(_renderDependencyAttributes(attributes), 2)}`;
|
||||
}
|
||||
|
||||
function _renderUnversionedYogaDependency(options) {
|
||||
let attributes;
|
||||
if (options.reactNativePath) {
|
||||
attributes = {
|
||||
path: path.join(options.reactNativePath, 'ReactCommon', 'yoga'),
|
||||
inhibit_warnings: true,
|
||||
};
|
||||
} else {
|
||||
throw new Error(`Unsupported options for Yoga dependency: ${options}`);
|
||||
}
|
||||
return `pod 'yoga',
|
||||
${indentString(_renderDependencyAttributes(attributes), 2)}`;
|
||||
}
|
||||
|
||||
function _renderUnversionedThirdPartyDependency(podName, podspecRelativePath, options) {
|
||||
let attributes;
|
||||
if (options.reactNativePath) {
|
||||
attributes = {
|
||||
podspec: path.join(options.reactNativePath, podspecRelativePath),
|
||||
inhibit_warnings: true,
|
||||
};
|
||||
} else {
|
||||
throw new Error(`Unsupported options for ${podName} dependency: ${options}`);
|
||||
}
|
||||
return `pod '${podName}',
|
||||
${indentString(_renderDependencyAttributes(attributes), 2)}`;
|
||||
}
|
||||
|
||||
function _renderDependencyAttributes(attributes) {
|
||||
const attributesStrings = [];
|
||||
for (const key of Object.keys(attributes)) {
|
||||
const value = JSON.stringify(attributes[key], null, 2);
|
||||
attributesStrings.push(`:${key} => ${value}`);
|
||||
}
|
||||
return attributesStrings.join(',\n');
|
||||
}
|
||||
|
||||
function createSdkFilterFn(sdkVersion) {
|
||||
if (sdkVersion && String(sdkVersion).toUpperCase() === 'UNVERSIONED') {
|
||||
return () => false;
|
||||
}
|
||||
if (sdkVersion === undefined || !sdkVersion.match(/^\d+\.\d+.\d+$/)) {
|
||||
return;
|
||||
}
|
||||
const sdkVersionWithUnderscores = sdkVersion.replace(/\./g, '_');
|
||||
return i => i.endsWith(`/ReactABI${sdkVersionWithUnderscores}.rb`);
|
||||
}
|
||||
|
||||
async function _renderVersionedReactNativeDependenciesAsync(
|
||||
templatesDirectory,
|
||||
versionedReactNativePath,
|
||||
expoSubspecs,
|
||||
shellAppSdkVersion
|
||||
) {
|
||||
const filterFn = createSdkFilterFn(shellAppSdkVersion);
|
||||
let result = await _concatTemplateFilesInDirectoryAsync(
|
||||
path.join(templatesDirectory, 'versioned-react-native', 'dependencies'),
|
||||
filterFn
|
||||
);
|
||||
expoSubspecs = expoSubspecs.map(subspec => `'${subspec}'`).join(', ');
|
||||
result = result.replace(/\$\{VERSIONED_REACT_NATIVE_PATH\}/g, versionedReactNativePath);
|
||||
result = result.replace(/\$\{REACT_NATIVE_EXPO_SUBSPECS\}/g, expoSubspecs);
|
||||
return result;
|
||||
}
|
||||
|
||||
async function _renderVersionedReactNativePostinstallsAsync(
|
||||
templatesDirectory,
|
||||
shellAppSdkVersion
|
||||
) {
|
||||
const filterFn = createSdkFilterFn(shellAppSdkVersion);
|
||||
return _concatTemplateFilesInDirectoryAsync(
|
||||
path.join(templatesDirectory, 'versioned-react-native', 'postinstalls'),
|
||||
filterFn
|
||||
);
|
||||
}
|
||||
|
||||
async function _concatTemplateFilesInDirectoryAsync(directory, filterFn) {
|
||||
const templateFilenames = globSync('*.rb', { absolute: true, cwd: directory }).sort();
|
||||
const filteredTemplateFilenames = filterFn
|
||||
? templateFilenames.filter(filterFn)
|
||||
: templateFilenames;
|
||||
const templateStrings = [];
|
||||
// perform this in series in order to get deterministic output
|
||||
for (let fileIdx = 0, nFiles = filteredTemplateFilenames.length; fileIdx < nFiles; fileIdx++) {
|
||||
const filename = filteredTemplateFilenames[fileIdx];
|
||||
const templateString = await fs.readFile(filename, 'utf8');
|
||||
if (templateString) {
|
||||
templateStrings.push(templateString);
|
||||
}
|
||||
}
|
||||
return templateStrings.join('\n');
|
||||
}
|
||||
|
||||
function _renderDetachedPostinstall(sdkVersion, isServiceContext) {
|
||||
const sdkMajorVersion = parseSdkMajorVersion(sdkVersion);
|
||||
const podNameExpression = sdkMajorVersion < 33 ? 'target.pod_name' : 'pod_name';
|
||||
const targetExpression = sdkMajorVersion < 33 ? 'target' : 'target_installation_result';
|
||||
|
||||
const podsRootSub = '${PODS_ROOT}';
|
||||
const maybeDetachedServiceDef = isServiceContext
|
||||
? `config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] << 'EX_DETACHED_SERVICE=1'`
|
||||
: '';
|
||||
|
||||
const maybeFrameworkSearchPathDef =
|
||||
sdkMajorVersion < 33
|
||||
? `
|
||||
# Needed for GoogleMaps 2.x
|
||||
config.build_settings['FRAMEWORK_SEARCH_PATHS'] ||= []
|
||||
config.build_settings['FRAMEWORK_SEARCH_PATHS'] << '${podsRootSub}/GoogleMaps/Base/Frameworks'
|
||||
config.build_settings['FRAMEWORK_SEARCH_PATHS'] << '${podsRootSub}/GoogleMaps/Maps/Frameworks'`
|
||||
: '';
|
||||
// In SDK39, in preparation for iOS 14 we've decided to remove IDFA code.
|
||||
// By adding this macro to shell apps we'll remove this code from Branch
|
||||
// on compilation level, see:
|
||||
// https://github.com/BranchMetrics/ios-branch-deep-linking-attribution/blob/ac991f9d0bc9bad640b25a0f1192679a8cfa083a/Branch-SDK/BNCSystemObserver.m#L49-L75
|
||||
const excludeIdfaCodeFromBranchSinceSDK39 =
|
||||
sdkMajorVersion >= 39
|
||||
? `
|
||||
if ${podNameExpression} == 'Branch'
|
||||
${targetExpression}.native_target.build_configurations.each do |config|
|
||||
config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] ||= ['$(inherited)']
|
||||
config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] << 'BRANCH_EXCLUDE_IDFA_CODE=1'
|
||||
end
|
||||
end`
|
||||
: '';
|
||||
return `
|
||||
if ${podNameExpression} == 'ExpoKit'
|
||||
${targetExpression}.native_target.build_configurations.each do |config|
|
||||
config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] ||= ['$(inherited)']
|
||||
config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] << 'EX_DETACHED=1'
|
||||
${maybeDetachedServiceDef}
|
||||
# Enable Google Maps support
|
||||
config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] << 'HAVE_GOOGLE_MAPS=1'
|
||||
config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] << 'HAVE_GOOGLE_MAPS_UTILS=1'
|
||||
${maybeFrameworkSearchPathDef}
|
||||
end
|
||||
end
|
||||
|
||||
${excludeIdfaCodeFromBranchSinceSDK39}
|
||||
`;
|
||||
}
|
||||
|
||||
function _renderUnversionedPostinstall(sdkVersion) {
|
||||
const podsToChangeDeployTarget = [
|
||||
'Amplitude-iOS',
|
||||
'Analytics',
|
||||
'AppAuth',
|
||||
'Branch',
|
||||
'CocoaLumberjack',
|
||||
'FBSDKCoreKit',
|
||||
'FBSDKLoginKit',
|
||||
'FBSDKShareKit',
|
||||
'GPUImage',
|
||||
'JKBigInteger2',
|
||||
];
|
||||
const podsToChangeRB = `[${podsToChangeDeployTarget.map(pod => `'${pod}'`).join(',')}]`;
|
||||
const sdkMajorVersion = parseSdkMajorVersion(sdkVersion);
|
||||
const podNameExpression = sdkMajorVersion < 33 ? 'target.pod_name' : 'pod_name';
|
||||
const targetExpression = sdkMajorVersion < 33 ? 'target' : 'target_installation_result';
|
||||
|
||||
// SDK41 drops support for iOS 10.0
|
||||
const deploymentTarget = sdkMajorVersion > 40 ? '11.0' : '10.0';
|
||||
|
||||
const podsToChangeDeployTargetIfStart =
|
||||
sdkMajorVersion <= 33 ? ` if ${podsToChangeRB}.include? ${podNameExpression}` : '';
|
||||
const podsToChangeDeployTargetIfEnd = sdkMajorVersion <= 33 ? ' end' : '';
|
||||
const gccPreprocessorDefinitionsCondition =
|
||||
sdkMajorVersion < 36
|
||||
? `${podNameExpression} == 'React'`
|
||||
: `${podNameExpression}.start_with?('React')`;
|
||||
|
||||
return `
|
||||
${podsToChangeDeployTargetIfStart}
|
||||
${targetExpression}.native_target.build_configurations.each do |config|
|
||||
config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '${deploymentTarget}'
|
||||
end
|
||||
${podsToChangeDeployTargetIfEnd}
|
||||
|
||||
# Can't specify this in the React podspec because we need to use those podspecs for detached
|
||||
# projects which don't reference ExponentCPP.
|
||||
if ${podNameExpression}.start_with?('React')
|
||||
${targetExpression}.native_target.build_configurations.each do |config|
|
||||
config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '${deploymentTarget}'
|
||||
config.build_settings['HEADER_SEARCH_PATHS'] ||= ['$(inherited)']
|
||||
end
|
||||
end
|
||||
|
||||
# Build React Native with RCT_DEV enabled and RCT_ENABLE_INSPECTOR and
|
||||
# RCT_ENABLE_PACKAGER_CONNECTION disabled
|
||||
next unless ${gccPreprocessorDefinitionsCondition}
|
||||
${targetExpression}.native_target.build_configurations.each do |config|
|
||||
config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] ||= ['$(inherited)']
|
||||
config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] << 'RCT_DEV=1'
|
||||
config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] << 'RCT_ENABLE_INSPECTOR=0'
|
||||
config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] << 'ENABLE_PACKAGER_CONNECTION=0'
|
||||
end
|
||||
`;
|
||||
}
|
||||
|
||||
function _renderTestTarget(reactNativePath) {
|
||||
return `
|
||||
target 'ExponentIntegrationTests' do
|
||||
inherit! :search_paths
|
||||
end
|
||||
|
||||
target 'Tests' do
|
||||
inherit! :search_paths
|
||||
end
|
||||
`;
|
||||
}
|
||||
|
||||
async function _renderPodDependenciesAsync(dependenciesConfigPath, options) {
|
||||
const dependencies = await new JsonFile(dependenciesConfigPath).readAsync();
|
||||
const type = options.isPodfile ? 'pod' : 'ss.dependency';
|
||||
const noWarningsFlag = options.isPodfile ? `, :inhibit_warnings => true` : '';
|
||||
const depsStrings = dependencies.map(dependency => {
|
||||
let builder = '';
|
||||
if (dependency.comments) {
|
||||
builder += dependency.comments.map(commentLine => ` # ${commentLine}`).join('\n');
|
||||
builder += '\n';
|
||||
}
|
||||
const otherPodfileFlags = options.isPodfile && dependency.otherPodfileFlags;
|
||||
builder += ` ${type} '${dependency.name}', '${dependency.version}'${noWarningsFlag}${
|
||||
otherPodfileFlags || ''
|
||||
}`;
|
||||
return builder;
|
||||
});
|
||||
return depsStrings.join('\n');
|
||||
}
|
||||
|
||||
async function renderExpoKitPodspecAsync(pathToTemplate, pathToOutput, moreSubstitutions) {
|
||||
const templatesDirectory = path.dirname(pathToTemplate);
|
||||
const templateString = await fs.readFile(pathToTemplate, 'utf8');
|
||||
const dependencies = await _renderPodDependenciesAsync(
|
||||
path.join(templatesDirectory, 'dependencies.json'),
|
||||
{ isPodfile: false }
|
||||
);
|
||||
let result = templateString.replace(/\$\{IOS_EXPOKIT_DEPS\}/g, indentString(dependencies, 2));
|
||||
if (moreSubstitutions && moreSubstitutions.IOS_EXPONENT_CLIENT_VERSION) {
|
||||
result = result.replace(
|
||||
/\$\{IOS_EXPONENT_CLIENT_VERSION\}/g,
|
||||
moreSubstitutions.IOS_EXPONENT_CLIENT_VERSION
|
||||
);
|
||||
}
|
||||
|
||||
await fs.writeFile(pathToOutput, result);
|
||||
}
|
||||
|
||||
function _renderUnversionedUniversalModulesDependencies(
|
||||
universalModules,
|
||||
universalModulesPath,
|
||||
sdkVersion
|
||||
) {
|
||||
const sdkMajorVersion = parseSdkMajorVersion(sdkVersion);
|
||||
|
||||
if (sdkMajorVersion >= 33) {
|
||||
const excludedUnimodules = [
|
||||
'expo-bluetooth',
|
||||
'expo-in-app-purchases',
|
||||
'expo-payments-stripe',
|
||||
'expo-module-template',
|
||||
'expo-image',
|
||||
];
|
||||
|
||||
if (sdkMajorVersion < 39) {
|
||||
excludedUnimodules.push('expo-splash-screen', 'expo-updates');
|
||||
}
|
||||
|
||||
const expoModulesThatAreNotUnimodules = [];
|
||||
if (sdkMajorVersion >= 39) {
|
||||
expoModulesThatAreNotUnimodules.push(
|
||||
`pod 'EXRandom', path: '${universalModulesPath}/expo-random/ios'`
|
||||
);
|
||||
}
|
||||
|
||||
return indentString(
|
||||
`
|
||||
# Install unimodules
|
||||
require_relative '../node_modules/react-native-unimodules/cocoapods.rb'
|
||||
use_unimodules!(
|
||||
modules_paths: ['${universalModulesPath}'],
|
||||
exclude: [
|
||||
${excludedUnimodules.map(module => `'${module}'`).join(',\n ')}
|
||||
],
|
||||
)
|
||||
|
||||
# Expo modules that are not unimodules
|
||||
${expoModulesThatAreNotUnimodules.join('\n')}
|
||||
`,
|
||||
2
|
||||
);
|
||||
} else {
|
||||
return indentString(
|
||||
universalModules
|
||||
.map(moduleInfo =>
|
||||
_renderUnversionedUniversalModuleDependency(
|
||||
moduleInfo.podName,
|
||||
moduleInfo.path,
|
||||
sdkVersion
|
||||
)
|
||||
)
|
||||
.join('\n'),
|
||||
2
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function _renderUnversionedUniversalModuleDependency(podName, path, sdkVersion) {
|
||||
const attributes = {
|
||||
path,
|
||||
};
|
||||
return `pod '${podName}',
|
||||
${indentString(_renderDependencyAttributes(attributes), 2)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param pathToTemplate path to template Podfile
|
||||
* @param pathToOutput path to render final Podfile
|
||||
* @param moreSubstitutions dictionary of additional substitution keys and values to replace
|
||||
* in the template, such as: TARGET_NAME, REACT_NATIVE_PATH
|
||||
*/
|
||||
async function renderPodfileAsync(
|
||||
pathToTemplate,
|
||||
pathToOutput,
|
||||
moreSubstitutions,
|
||||
shellAppSdkVersion,
|
||||
sdkVersion = 'UNVERSIONED'
|
||||
) {
|
||||
if (!moreSubstitutions) {
|
||||
moreSubstitutions = {};
|
||||
}
|
||||
const templatesDirectory = path.dirname(pathToTemplate);
|
||||
const templateString = await fs.readFile(pathToTemplate, 'utf8');
|
||||
|
||||
const reactNativePath = moreSubstitutions.REACT_NATIVE_PATH;
|
||||
let rnDependencyOptions;
|
||||
if (reactNativePath) {
|
||||
rnDependencyOptions = { reactNativePath };
|
||||
} else {
|
||||
rnDependencyOptions = {};
|
||||
}
|
||||
|
||||
const expoKitPath = moreSubstitutions.EXPOKIT_PATH;
|
||||
const expoKitTag = moreSubstitutions.EXPOKIT_TAG;
|
||||
let expoKitDependencyOptions = {};
|
||||
if (expoKitPath) {
|
||||
expoKitDependencyOptions = { expoKitPath };
|
||||
} else if (expoKitTag) {
|
||||
expoKitDependencyOptions = { expoKitTag };
|
||||
}
|
||||
|
||||
let versionedRnPath = moreSubstitutions.VERSIONED_REACT_NATIVE_PATH;
|
||||
if (!versionedRnPath) {
|
||||
versionedRnPath = './versioned-react-native';
|
||||
}
|
||||
let rnExpoSubspecs = moreSubstitutions.REACT_NATIVE_EXPO_SUBSPECS;
|
||||
if (!rnExpoSubspecs) {
|
||||
rnExpoSubspecs = ['Expo'];
|
||||
}
|
||||
|
||||
const versionedDependencies = await _renderVersionedReactNativeDependenciesAsync(
|
||||
templatesDirectory,
|
||||
versionedRnPath,
|
||||
rnExpoSubspecs,
|
||||
shellAppSdkVersion
|
||||
);
|
||||
const versionedPostinstalls = await _renderVersionedReactNativePostinstallsAsync(
|
||||
templatesDirectory,
|
||||
shellAppSdkVersion
|
||||
);
|
||||
const podDependencies = await _renderPodDependenciesAsync(
|
||||
path.join(templatesDirectory, 'dependencies.json'),
|
||||
{ isPodfile: true }
|
||||
);
|
||||
|
||||
let universalModules = moreSubstitutions.UNIVERSAL_MODULES;
|
||||
if (!universalModules) {
|
||||
universalModules = [];
|
||||
}
|
||||
|
||||
const substitutions = {
|
||||
EXPONENT_CLIENT_DEPS: podDependencies,
|
||||
EXPOKIT_DEPENDENCY: _renderExpoKitDependency(expoKitDependencyOptions, sdkVersion),
|
||||
PODFILE_UNVERSIONED_EXPO_MODULES_DEPENDENCIES: _renderUnversionedUniversalModulesDependencies(
|
||||
universalModules,
|
||||
moreSubstitutions.UNIVERSAL_MODULES_PATH,
|
||||
sdkVersion
|
||||
),
|
||||
PODFILE_UNVERSIONED_RN_DEPENDENCY: _renderUnversionedReactNativeDependency(
|
||||
rnDependencyOptions,
|
||||
sdkVersion
|
||||
),
|
||||
PODFILE_UNVERSIONED_POSTINSTALL: _renderUnversionedPostinstall(sdkVersion),
|
||||
PODFILE_DETACHED_POSTINSTALL: _renderDetachedPostinstall(sdkVersion, false),
|
||||
PODFILE_DETACHED_SERVICE_POSTINSTALL: _renderDetachedPostinstall(sdkVersion, true),
|
||||
PODFILE_VERSIONED_RN_DEPENDENCIES: versionedDependencies,
|
||||
PODFILE_VERSIONED_POSTINSTALLS: versionedPostinstalls,
|
||||
PODFILE_TEST_TARGET: shellAppSdkVersion ? '' : _renderTestTarget(reactNativePath),
|
||||
...moreSubstitutions,
|
||||
};
|
||||
_validatePodfileSubstitutions(substitutions);
|
||||
|
||||
let result = templateString;
|
||||
for (const key in substitutions) {
|
||||
if (substitutions.hasOwnProperty(key)) {
|
||||
const replacement = substitutions[key];
|
||||
result = result.replace(new RegExp(`\\$\\{${key}\\}`, 'g'), replacement);
|
||||
}
|
||||
}
|
||||
|
||||
await fs.writeFile(pathToOutput, result);
|
||||
}
|
||||
|
||||
export { renderExpoKitPodspecAsync, renderPodfileAsync };
|
||||
@@ -0,0 +1,89 @@
|
||||
import plist, { PlistObject } from '@expo/plist';
|
||||
import fs from 'fs';
|
||||
import { sync as globSync } from 'glob';
|
||||
import xcode from 'xcode';
|
||||
|
||||
const defaultBundleId = '$(PRODUCT_BUNDLE_IDENTIFIER)';
|
||||
|
||||
export function resetAllPlistBundleIdentifiers(projectRoot: string) {
|
||||
const infoPlistPaths = globSync('ios/*/Info.plist', { absolute: true, cwd: projectRoot });
|
||||
|
||||
for (const plistPath of infoPlistPaths) {
|
||||
resetPlistBundleIdentifier(plistPath);
|
||||
}
|
||||
}
|
||||
|
||||
export function resetPlistBundleIdentifier(plistPath: string) {
|
||||
// Read Plist as source
|
||||
const rawPlist = fs.readFileSync(plistPath, 'utf8');
|
||||
|
||||
const plistObject = plist.parse(rawPlist) as PlistObject;
|
||||
|
||||
if (plistObject.CFBundleIdentifier) {
|
||||
// Maybe bail out
|
||||
if (plistObject.CFBundleIdentifier === defaultBundleId) { return; }
|
||||
// attempt to match default Info.plist format
|
||||
const format = { pretty: true, indent: `\t` };
|
||||
|
||||
const xml = plist.build(
|
||||
{
|
||||
...plistObject,
|
||||
CFBundleIdentifier: defaultBundleId,
|
||||
},
|
||||
format,
|
||||
);
|
||||
|
||||
if (xml !== rawPlist) {
|
||||
fs.writeFileSync(plistPath, xml);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function filterComments([item]: any[]): boolean {
|
||||
return !item.endsWith(`_comment`);
|
||||
}
|
||||
function filterConfig(input: any[]): boolean {
|
||||
const {
|
||||
1: { isa },
|
||||
} = input;
|
||||
return isa === 'XCBuildConfiguration';
|
||||
}
|
||||
function filterHosts(input: any[]): boolean {
|
||||
const {
|
||||
1: { buildSettings },
|
||||
} = input;
|
||||
|
||||
return !buildSettings.TEST_HOST;
|
||||
}
|
||||
|
||||
export function updateBundleIdentifierForPbxproj(pbxprojPath: string, bundleIdentifier: string) {
|
||||
const project = xcode.project(pbxprojPath);
|
||||
project.parseSync();
|
||||
|
||||
Object.entries(project.pbxXCBuildConfigurationSection())
|
||||
.filter(filterComments)
|
||||
.filter(filterConfig)
|
||||
.filter(filterHosts)
|
||||
.forEach(({ 1: { buildSettings } }: any) => {
|
||||
if (buildSettings.PRODUCT_BUNDLE_IDENTIFIER === bundleIdentifier) {
|
||||
return;
|
||||
}
|
||||
|
||||
buildSettings.PRODUCT_BUNDLE_IDENTIFIER = `"${bundleIdentifier}"`;
|
||||
|
||||
const productName = bundleIdentifier.split('.').pop();
|
||||
if (!productName?.includes('$')) {
|
||||
buildSettings.PRODUCT_NAME = productName;
|
||||
}
|
||||
});
|
||||
fs.writeFileSync(pbxprojPath, project.writeSync());
|
||||
}
|
||||
|
||||
export function setBundleIdentifier(projectRoot: string, bundleIdentifier: string) {
|
||||
// Get all pbx projects
|
||||
const pbxprojPaths = globSync('ios/*/project.pbxproj', { absolute: true, cwd: projectRoot });
|
||||
|
||||
for (const pbxprojPath of pbxprojPaths) {
|
||||
updateBundleIdentifierForPbxproj(pbxprojPath, bundleIdentifier);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
import fs from 'fs-extra';
|
||||
import pascalCase from 'pascal-case';
|
||||
import path from 'path';
|
||||
import rimraf from 'rimraf';
|
||||
|
||||
import {
|
||||
getManifestAsync,
|
||||
parseSdkMajorVersion,
|
||||
spawnAsync,
|
||||
spawnAsyncThrowError,
|
||||
} from './ExponentTools';
|
||||
import * as IosNSBundle from './IosNSBundle';
|
||||
import * as IosWorkspace from './IosWorkspace';
|
||||
import logger from './Logger';
|
||||
import StandaloneBuildFlags from './StandaloneBuildFlags';
|
||||
import StandaloneContext from './StandaloneContext';
|
||||
|
||||
export const EXPOKIT_APP = 'ExpoKitApp';
|
||||
export const EXPONENT_APP = 'Exponent';
|
||||
|
||||
function _validateCLIArgs(args) {
|
||||
args.type = args.type || 'archive';
|
||||
args.configuration = args.configuration || 'Release';
|
||||
args.verbose = args.verbose || false;
|
||||
args.testEnvironment = args.testEnvironment || 'none';
|
||||
|
||||
switch (args.type) {
|
||||
case 'simulator': {
|
||||
if (args.configuration !== 'Debug' && args.configuration !== 'Release') {
|
||||
throw new Error(`Unsupported build configuration ${args.configuration}`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'archive': {
|
||||
if (args.configuration !== 'Release') {
|
||||
throw new Error('Release is the only supported configuration when archiving');
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'client':
|
||||
break;
|
||||
default: {
|
||||
throw new Error(`Unsupported build type ${args.type}`);
|
||||
}
|
||||
}
|
||||
|
||||
switch (args.action) {
|
||||
case 'configure': {
|
||||
if (args.type === 'client') {
|
||||
break;
|
||||
}
|
||||
|
||||
if (!args.url) {
|
||||
throw new Error('Must run with `--url MANIFEST_URL`');
|
||||
}
|
||||
if (!args.sdkVersion) {
|
||||
throw new Error('Must run with `--sdkVersion SDK_VERSION`');
|
||||
}
|
||||
if (!args.archivePath) {
|
||||
throw new Error(
|
||||
'Need to provide --archivePath <path to existing archive for configuration>'
|
||||
);
|
||||
}
|
||||
if (
|
||||
args.testEnvironment !== 'local' &&
|
||||
args.testEnvironment !== 'ci' &&
|
||||
args.testEnvironment !== 'none'
|
||||
) {
|
||||
throw new Error(`Unsupported test environment ${args.testEnvironment}`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'build': {
|
||||
break;
|
||||
}
|
||||
case 'create-workspace': {
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
throw new Error(`Unsupported build action ${args.action}`);
|
||||
}
|
||||
}
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the iOS workspace at the given path.
|
||||
* @return the path to the resulting build artifact
|
||||
*/
|
||||
async function _buildAsync(
|
||||
projectName,
|
||||
workspacePath,
|
||||
configuration,
|
||||
type,
|
||||
relativeBuildDestination,
|
||||
verbose,
|
||||
useModernBuildSystem = false
|
||||
) {
|
||||
const modernBuildSystemFragment = `-UseModernBuildSystem=${useModernBuildSystem ? 'YES' : 'NO'}`;
|
||||
const buildDest = `${relativeBuildDestination}-${type}`;
|
||||
let buildCmd = `set -o pipefail && xcodebuild -workspace ${projectName}.xcworkspace -scheme ${projectName} -configuration ${configuration} -derivedDataPath ${buildDest} ${modernBuildSystemFragment}`,
|
||||
pathToArtifact;
|
||||
if (type === 'simulator') {
|
||||
buildCmd += ` -sdk iphonesimulator CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO ARCHS="x86_64" ONLY_ACTIVE_ARCH=NO | xcpretty`;
|
||||
pathToArtifact = path.join(
|
||||
buildDest,
|
||||
'Build',
|
||||
'Products',
|
||||
`${configuration}-iphonesimulator`,
|
||||
`${projectName}.app`
|
||||
);
|
||||
} else if (type === 'archive') {
|
||||
buildCmd += ` -sdk iphoneos -destination generic/platform=iOS archive -archivePath ${buildDest}/${projectName}.xcarchive CODE_SIGNING_ALLOWED=NO | xcpretty`;
|
||||
pathToArtifact = path.join(buildDest, `${projectName}.xcarchive`);
|
||||
} else {
|
||||
throw new Error(`Unsupported build type: ${type}`);
|
||||
}
|
||||
|
||||
logger.info(`Building iOS workspace at ${workspacePath} to ${buildDest}:\n`);
|
||||
logger.info(buildCmd);
|
||||
if (!verbose) {
|
||||
logger.info(
|
||||
'\nxcodebuild is running. Logging errors only. To see full output, use --verbose 1...'
|
||||
);
|
||||
}
|
||||
await spawnAsyncThrowError(buildCmd, null, {
|
||||
// only stderr
|
||||
stdio: verbose ? 'inherit' : ['ignore', 'ignore', 'inherit'],
|
||||
cwd: workspacePath,
|
||||
shell: true,
|
||||
});
|
||||
return path.resolve(workspacePath, pathToArtifact);
|
||||
}
|
||||
|
||||
async function _podInstallAsync(workspacePath, isRepoUpdateEnabled) {
|
||||
// ensure pods are clean
|
||||
const pathsToClean = [path.join(workspacePath, 'Pods'), path.join(workspacePath, 'Podfile.lock')];
|
||||
pathsToClean.forEach(path => {
|
||||
if (fs.existsSync(path)) {
|
||||
rimraf.sync(path);
|
||||
}
|
||||
});
|
||||
|
||||
// Disable cocoapod stats to speed up the install
|
||||
const COCOAPODS_DISABLE_STATS = process.env.COCOAPODS_DISABLE_STATS;
|
||||
process.env.COCOAPODS_DISABLE_STATS = true;
|
||||
|
||||
// install
|
||||
const cocoapodsArgs = ['install'];
|
||||
if (isRepoUpdateEnabled) {
|
||||
cocoapodsArgs.push('--repo-update');
|
||||
}
|
||||
logger.info('Installing iOS workspace dependencies...');
|
||||
logger.info(`pod ${cocoapodsArgs.join(' ')}`);
|
||||
try {
|
||||
await spawnAsyncThrowError('pod', cocoapodsArgs, {
|
||||
stdio: 'inherit',
|
||||
cwd: workspacePath,
|
||||
});
|
||||
} finally {
|
||||
// Revert the stats to the user preference
|
||||
process.env.COCOAPODS_DISABLE_STATS = COCOAPODS_DISABLE_STATS;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param workspacePath optionally provide a path for the unbuilt xcode workspace to create/use.
|
||||
* @param expoSourcePath path to expo client app sourcecode (/ios dir from expo/expo repo)
|
||||
* @param shellAppSdkVersion sdk version for shell app
|
||||
*/
|
||||
async function _createStandaloneContextAsync(args) {
|
||||
// right now we only ever build a single detached workspace for service contexts.
|
||||
// TODO: support multiple different pod configurations, assemble a cache of those builds.
|
||||
const expoSourcePath = args.expoSourcePath || '../ios';
|
||||
let workspaceSourcePath;
|
||||
if (args.workspacePath) {
|
||||
workspaceSourcePath = args.workspacePath;
|
||||
} else {
|
||||
workspaceSourcePath = path.join(expoSourcePath, '..', 'shellAppWorkspaces', 'default', 'ios');
|
||||
}
|
||||
const { privateConfigFile, privateConfigData } = args;
|
||||
|
||||
let privateConfig;
|
||||
if (privateConfigData) {
|
||||
privateConfig = privateConfigData;
|
||||
} else if (privateConfigFile) {
|
||||
const privateConfigContents = await fs.readFile(privateConfigFile, 'utf8');
|
||||
privateConfig = JSON.parse(privateConfigContents);
|
||||
}
|
||||
|
||||
let manifest;
|
||||
if (args.manifest) {
|
||||
manifest = args.manifest;
|
||||
logger
|
||||
.withFields({ buildPhase: 'reading manifest' })
|
||||
.info('Using manifest:', JSON.stringify(manifest));
|
||||
} else if (args.url && args.sdkVersion) {
|
||||
const { url, sdkVersion, releaseChannel } = args;
|
||||
manifest = await getManifestAsync(url, {
|
||||
'Exponent-SDK-Version': sdkVersion,
|
||||
'Exponent-Platform': 'ios',
|
||||
'Expo-Release-Channel': releaseChannel ? releaseChannel : 'default',
|
||||
Accept: 'application/expo+json,application/json',
|
||||
});
|
||||
}
|
||||
|
||||
let bundleExecutable = args.type === 'client' ? EXPONENT_APP : EXPOKIT_APP;
|
||||
if (manifest?.ios?.infoPlist?.CFBundleExecutable) {
|
||||
bundleExecutable = manifest.ios.infoPlist.CFBundleExecutable;
|
||||
} else if (privateConfig?.bundleIdentifier) {
|
||||
bundleExecutable = pascalCase(privateConfig.bundleIdentifier);
|
||||
}
|
||||
|
||||
const buildFlags = StandaloneBuildFlags.createIos(args.configuration, {
|
||||
workspaceSourcePath,
|
||||
appleTeamId: args.appleTeamId,
|
||||
buildType: args.type,
|
||||
bundleExecutable,
|
||||
});
|
||||
const context = StandaloneContext.createServiceContext(
|
||||
expoSourcePath,
|
||||
args.archivePath,
|
||||
manifest,
|
||||
privateConfig,
|
||||
args.testEnvironment,
|
||||
buildFlags,
|
||||
args.url,
|
||||
args.releaseChannel,
|
||||
args.shellAppSdkVersion
|
||||
);
|
||||
return context;
|
||||
}
|
||||
|
||||
/**
|
||||
* possible args:
|
||||
* @param url manifest url for shell experience
|
||||
* @param sdkVersion sdk to use when requesting the manifest
|
||||
* @param releaseChannel channel to pull manifests from, default is 'default'
|
||||
* @param archivePath path to existing NSBundle to configure
|
||||
* @param privateConfigFile path to a private config file containing, e.g., private api keys
|
||||
* @param appleTeamId Apple Developer's account Team ID
|
||||
* @param output specify the output path of the configured archive (ie) /tmp/my-app-archive-build.xcarchive or /tmp/my-app-ios-build.tar.gz
|
||||
* @param type type of artifact to configure (simulator or archive)
|
||||
* @param expoSourcePath path to expo client app sourcecode (/ios dir from expo/expo repo)
|
||||
*/
|
||||
async function configureAndCopyArchiveAsync(args) {
|
||||
args = _validateCLIArgs(args);
|
||||
const { output, type } = args;
|
||||
const context = await _createStandaloneContextAsync(args);
|
||||
await IosNSBundle.configureAsync(context);
|
||||
if (output) {
|
||||
const workspaceName = type === 'client' ? EXPONENT_APP : EXPOKIT_APP;
|
||||
if (context.build.ios.bundleExecutable !== workspaceName) {
|
||||
await spawnAsync('/bin/mv', [workspaceName, context.build.ios.bundleExecutable], {
|
||||
pipeToLogger: true,
|
||||
cwd: context.data.archivePath,
|
||||
loggerFields: { buildPhase: 'renaming bundle executable' },
|
||||
});
|
||||
}
|
||||
if (type === 'simulator') {
|
||||
const archiveName = context.config.slug.replace(/[^0-9a-z_-]/gi, '_');
|
||||
const appReleasePath = path.resolve(context.data.archivePath, '..');
|
||||
await spawnAsync(
|
||||
`mv ${workspaceName}.app ${archiveName}.app && tar -czvf ${output} ${archiveName}.app`,
|
||||
null,
|
||||
{
|
||||
stdoutOnly: true,
|
||||
pipeToLogger: true,
|
||||
loggerFields: { buildPhase: 'creating an archive for simulator' },
|
||||
cwd: appReleasePath,
|
||||
shell: true,
|
||||
}
|
||||
);
|
||||
} else if (type === 'archive' || type === 'client') {
|
||||
await spawnAsync('/bin/mv', [`${workspaceName}.xcarchive`, output], {
|
||||
pipeToLogger: true,
|
||||
cwd: path.join(context.data.archivePath, '../../../..'),
|
||||
loggerFields: { buildPhase: 'renaming archive' },
|
||||
});
|
||||
}
|
||||
}
|
||||
return context.data.manifest;
|
||||
}
|
||||
|
||||
/**
|
||||
* possible args:
|
||||
* @param skipRepoUpdate if true, omit `--repo-update` cocoapods flag.
|
||||
*/
|
||||
async function _createTurtleWorkspaceAsync(context, args) {
|
||||
const { skipRepoUpdate } = args;
|
||||
if (fs.existsSync(context.build.ios.workspaceSourcePath)) {
|
||||
logger.info(`Removing existing workspace at ${context.build.ios.workspaceSourcePath}...`);
|
||||
try {
|
||||
rimraf.sync(context.build.ios.workspaceSourcePath);
|
||||
} catch {}
|
||||
}
|
||||
await IosWorkspace.createDetachedAsync(context);
|
||||
await _podInstallAsync(context.build.ios.workspaceSourcePath, !skipRepoUpdate);
|
||||
}
|
||||
|
||||
/**
|
||||
* External-facing version can be used to create a turtle workspace without building it.
|
||||
* Probably only useful for local testing.
|
||||
*
|
||||
* @param workspacePath (optional) provide some other path to create the workspace besides the default
|
||||
* @param url (optional, with sdkVersion) url to an expo manifest, if you want the workspace to be configured automatically
|
||||
* @param sdkVersion (optional, with url) sdkVersion to an expo manifest, if you want the workspace to be configured automatically
|
||||
*/
|
||||
async function createTurtleWorkspaceAsync(args) {
|
||||
args = _validateCLIArgs(args);
|
||||
if (!args.workspacePath) {
|
||||
logger.info(
|
||||
'No workspace path was provided with --workspacePath, so the default will be used.'
|
||||
);
|
||||
}
|
||||
const context = await _createStandaloneContextAsync(args);
|
||||
await _createTurtleWorkspaceAsync(context, args);
|
||||
logger.info(
|
||||
`Created turtle workspace at ${context.build.ios.workspaceSourcePath}. You can open and run this in Xcode.`
|
||||
);
|
||||
if (context.config) {
|
||||
await IosNSBundle.configureAsync(context);
|
||||
logger.info(
|
||||
`The turtle workspace was configured for the url ${args.url}. To run this app with a Debug scheme, make sure to add a development url to 'EXBuildConstants.plist'.`
|
||||
);
|
||||
} else {
|
||||
logger.info(
|
||||
`You can specify --url <manifestUrl> --sdkVersion <version> to configure this workspace as a particular Expo app.\n\nBecause those arguments were omitted, the workspace has not been configured. It will compile but not run. The minimum configuration to get something running is to specify a manifest url in 'EXShell.plist' (for Release builds) or 'EXBuildConstants.plist' (for Debug builds).`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* possible args:
|
||||
* @param configuration StandaloneBuildConfiguration (Debug or Release)
|
||||
* @param verbose show all xcodebuild output (default false)
|
||||
* @param reuseWorkspace if true, when building, assume a detached workspace already exists rather than creating a new one.
|
||||
* @param type type of artifact to build (simulator or archive)
|
||||
* @param shellAppSdkVersion sdk version for shell app
|
||||
*/
|
||||
async function buildAndCopyArtifactAsync(args) {
|
||||
args = _validateCLIArgs(args);
|
||||
const context = await _createStandaloneContextAsync(args);
|
||||
const { verbose, type, reuseWorkspace } = args;
|
||||
const { projectName } = IosWorkspace.getPaths(context);
|
||||
|
||||
if (!reuseWorkspace) {
|
||||
await _createTurtleWorkspaceAsync(context, args);
|
||||
}
|
||||
const pathToArtifact = await _buildAsync(
|
||||
projectName,
|
||||
context.build.ios.workspaceSourcePath,
|
||||
context.build.configuration,
|
||||
type,
|
||||
path.relative(context.build.ios.workspaceSourcePath, './shellAppBase'),
|
||||
verbose,
|
||||
parseSdkMajorVersion(args.shellAppSdkVersion) > 33
|
||||
);
|
||||
const artifactDestPath = path.join('./shellAppBase-builds', type, context.build.configuration);
|
||||
logger.info(`\nFinished building, copying artifact to ${path.resolve(artifactDestPath)}...`);
|
||||
if (fs.existsSync(artifactDestPath)) {
|
||||
await spawnAsyncThrowError('/bin/rm', ['-rf', artifactDestPath]);
|
||||
}
|
||||
logger.info(`mkdir -p ${artifactDestPath}`);
|
||||
await spawnAsyncThrowError('/bin/mkdir', ['-p', artifactDestPath]);
|
||||
logger.info(`cp -R ${pathToArtifact} ${artifactDestPath}`);
|
||||
await spawnAsyncThrowError('/bin/cp', ['-R', pathToArtifact, artifactDestPath]);
|
||||
}
|
||||
|
||||
export { buildAndCopyArtifactAsync, configureAndCopyArchiveAsync, createTurtleWorkspaceAsync };
|
||||
@@ -0,0 +1,448 @@
|
||||
import { Api, Versions } from '@expo/xdl';
|
||||
import assert from 'assert';
|
||||
import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
import rimraf from 'rimraf';
|
||||
|
||||
import * as Modules from '../modules/Modules';
|
||||
import {
|
||||
isDirectory,
|
||||
parseSdkMajorVersion,
|
||||
rimrafDontThrow,
|
||||
spawnAsyncThrowError,
|
||||
transformFileContentsAsync,
|
||||
} from './ExponentTools';
|
||||
import installPackagesAsync from './installPackagesAsync';
|
||||
import * as IosPlist from './IosPlist';
|
||||
import { renderPodfileAsync } from './IosPodsTools.js';
|
||||
import logger from './Logger';
|
||||
import * as Utils from './Utils';
|
||||
|
||||
export { setBundleIdentifier } from './IosSetBundleIdentifier';
|
||||
|
||||
async function _getVersionedExpoKitConfigAsync(sdkVersion, skipServerValidation) {
|
||||
const versions = await Versions.versionsAsync();
|
||||
let sdkVersionConfig = versions.sdkVersions[sdkVersion];
|
||||
if (!sdkVersionConfig) {
|
||||
if (skipServerValidation) {
|
||||
sdkVersionConfig = {};
|
||||
} else {
|
||||
throw new Error(`Unsupported SDK version: ${sdkVersion}`);
|
||||
}
|
||||
}
|
||||
const { iosVersion, iosExpoViewUrl } = sdkVersionConfig;
|
||||
const iosClientVersion = iosVersion ? iosVersion : versions.iosVersion;
|
||||
return {
|
||||
iosClientVersion,
|
||||
iosExpoViewUrl,
|
||||
};
|
||||
}
|
||||
|
||||
async function _getOrCreateTemplateDirectoryAsync(context, iosExpoViewUrl) {
|
||||
if (context.type === 'service') {
|
||||
return path.join(context.data.expoSourcePath, '..');
|
||||
} else if (context.type === 'user') {
|
||||
let expoRootTemplateDirectory;
|
||||
if (process.env.EXPO_VIEW_DIR) {
|
||||
// Only for testing
|
||||
expoRootTemplateDirectory = process.env.EXPO_VIEW_DIR;
|
||||
} else {
|
||||
// HEY: if you need other paths into the extracted archive, be sure and include them
|
||||
// when the archive is generated in `ios/pipeline.js`
|
||||
expoRootTemplateDirectory = path.join(context.data.projectPath, 'temp-ios-directory');
|
||||
if (!isDirectory(expoRootTemplateDirectory)) {
|
||||
fs.mkdirpSync(expoRootTemplateDirectory);
|
||||
logger.info('Downloading iOS code...');
|
||||
assert(iosExpoViewUrl, `The URL for ExpoKit iOS must be set`);
|
||||
await Api.downloadAsync(iosExpoViewUrl, expoRootTemplateDirectory, {
|
||||
extract: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
return expoRootTemplateDirectory;
|
||||
}
|
||||
}
|
||||
|
||||
async function _renameAndMoveProjectFilesAsync(context, projectDirectory, projectName) {
|
||||
// remove .gitignore, as this actually pertains to internal expo template management
|
||||
try {
|
||||
const gitIgnorePath = path.join(projectDirectory, '.gitignore');
|
||||
if (fs.existsSync(gitIgnorePath)) {
|
||||
rimraf.sync(gitIgnorePath);
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
const filesToTransform = [
|
||||
path.join('exponent-view-template.xcodeproj', 'project.pbxproj'),
|
||||
path.join('exponent-view-template.xcworkspace', 'contents.xcworkspacedata'),
|
||||
path.join(
|
||||
'exponent-view-template.xcodeproj',
|
||||
'xcshareddata',
|
||||
'xcschemes',
|
||||
'exponent-view-template.xcscheme'
|
||||
),
|
||||
];
|
||||
|
||||
let bundleIdentifier;
|
||||
if (context.type === 'user') {
|
||||
const exp = context.data.exp;
|
||||
bundleIdentifier = exp.ios && exp.ios.bundleIdentifier ? exp.ios.bundleIdentifier : null;
|
||||
if (!bundleIdentifier) {
|
||||
throw new Error(`Cannot configure an ExpoKit workspace with no iOS bundle identifier.`);
|
||||
}
|
||||
} else if (context.type === 'service') {
|
||||
bundleIdentifier = 'host.exp.Exponent';
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
filesToTransform.map(fileName =>
|
||||
transformFileContentsAsync(path.join(projectDirectory, fileName), fileString => {
|
||||
return fileString
|
||||
.replace(/com.getexponent.exponent-view-template/g, bundleIdentifier)
|
||||
.replace(/exponent-view-template/g, projectName);
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
// order of this array matters
|
||||
const filesToMove = [
|
||||
'exponent-view-template',
|
||||
path.join(
|
||||
'exponent-view-template.xcodeproj',
|
||||
'xcshareddata',
|
||||
'xcschemes',
|
||||
'exponent-view-template.xcscheme'
|
||||
),
|
||||
'exponent-view-template.xcodeproj',
|
||||
'exponent-view-template.xcworkspace',
|
||||
];
|
||||
|
||||
filesToMove.forEach(async fileName => {
|
||||
const destFileName = path.join(
|
||||
path.dirname(fileName),
|
||||
`${projectName}${path.extname(fileName)}`
|
||||
);
|
||||
await spawnAsyncThrowError('/bin/mv', [
|
||||
path.join(projectDirectory, fileName),
|
||||
path.join(projectDirectory, destFileName),
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
async function _configureVersionsPlistAsync(configFilePath, standaloneSdkVersion) {
|
||||
await IosPlist.modifyAsync(configFilePath, 'EXSDKVersions', versionConfig => {
|
||||
versionConfig.sdkVersions = [standaloneSdkVersion];
|
||||
versionConfig.detachedNativeVersions = {
|
||||
shell: standaloneSdkVersion,
|
||||
kernel: standaloneSdkVersion,
|
||||
};
|
||||
return versionConfig;
|
||||
});
|
||||
}
|
||||
|
||||
async function _configureBuildConstantsPlistAsync(configFilePath, context) {
|
||||
await IosPlist.modifyAsync(configFilePath, 'EXBuildConstants', constantsConfig => {
|
||||
constantsConfig.STANDALONE_CONTEXT_TYPE = context.type;
|
||||
return constantsConfig;
|
||||
});
|
||||
}
|
||||
|
||||
async function _renderPodfileFromTemplateAsync(
|
||||
context,
|
||||
expoRootTemplateDirectory,
|
||||
sdkVersion,
|
||||
iosClientVersion
|
||||
) {
|
||||
const { iosProjectDirectory, projectName } = getPaths(context);
|
||||
let podfileTemplateFilename;
|
||||
const podfileSubstitutions = {
|
||||
TARGET_NAME: projectName,
|
||||
};
|
||||
let reactNativeDependencyPath;
|
||||
let modulesPath;
|
||||
|
||||
const detachableUniversalModules = Modules.getDetachableModules(
|
||||
'ios',
|
||||
context.data.shellAppSdkVersion || sdkVersion
|
||||
);
|
||||
if (context.type === 'user') {
|
||||
assert(iosClientVersion, `The iOS client version must be specified`);
|
||||
reactNativeDependencyPath = path.join(context.data.projectPath, 'node_modules', 'react-native');
|
||||
modulesPath = path.join(context.data.projectPath, 'node_modules');
|
||||
|
||||
podfileSubstitutions.EXPOKIT_TAG = `ios/${iosClientVersion}`;
|
||||
podfileTemplateFilename = 'ExpoKit-Podfile';
|
||||
} else if (context.type === 'service') {
|
||||
reactNativeDependencyPath = path.join(
|
||||
expoRootTemplateDirectory,
|
||||
'react-native-lab',
|
||||
'react-native'
|
||||
);
|
||||
modulesPath = path.join(expoRootTemplateDirectory, 'packages');
|
||||
|
||||
podfileSubstitutions.EXPOKIT_PATH = path.relative(
|
||||
iosProjectDirectory,
|
||||
expoRootTemplateDirectory
|
||||
);
|
||||
podfileSubstitutions.VERSIONED_REACT_NATIVE_PATH = path.relative(
|
||||
iosProjectDirectory,
|
||||
path.join(expoRootTemplateDirectory, 'ios', 'versioned-react-native')
|
||||
);
|
||||
podfileTemplateFilename = 'ExpoKit-Podfile-versioned';
|
||||
} else {
|
||||
throw new Error(`Unsupported context type: ${context.type}`);
|
||||
}
|
||||
podfileSubstitutions.REACT_NATIVE_PATH = path.relative(
|
||||
iosProjectDirectory,
|
||||
reactNativeDependencyPath
|
||||
);
|
||||
podfileSubstitutions.UNIVERSAL_MODULES_PATH = path.relative(iosProjectDirectory, modulesPath);
|
||||
podfileSubstitutions.UNIVERSAL_MODULES = detachableUniversalModules.map(module => ({
|
||||
...module,
|
||||
path: path.join(
|
||||
podfileSubstitutions.UNIVERSAL_MODULES_PATH,
|
||||
module.libName,
|
||||
module.subdirectory
|
||||
),
|
||||
}));
|
||||
|
||||
// env flags for testing
|
||||
if (process.env.EXPOKIT_TAG_IOS) {
|
||||
logger.info(`EXPOKIT_TAG_IOS: Using custom ExpoKit iOS tag...`);
|
||||
podfileSubstitutions.EXPOKIT_TAG = process.env.EXPOKIT_TAG_IOS;
|
||||
} else if (process.env.EXPO_VIEW_DIR) {
|
||||
logger.info('EXPO_VIEW_DIR: Using local ExpoKit source for iOS...');
|
||||
podfileSubstitutions.EXPOKIT_PATH = path.relative(
|
||||
iosProjectDirectory,
|
||||
process.env.EXPO_VIEW_DIR
|
||||
);
|
||||
// If EXPO_VIEW_DIR is defined overwrite UNIVERSAL_MODULES with paths pointing to EXPO_VIEW_DIR
|
||||
podfileSubstitutions.UNIVERSAL_MODULES = podfileSubstitutions.UNIVERSAL_MODULES.map(module => ({
|
||||
...module,
|
||||
path: path.relative(
|
||||
iosProjectDirectory,
|
||||
path.join(process.env.EXPO_VIEW_DIR, 'packages', module.libName, module.subdirectory)
|
||||
),
|
||||
}));
|
||||
}
|
||||
const templatePodfilePath = path.join(
|
||||
expoRootTemplateDirectory,
|
||||
'template-files',
|
||||
'ios',
|
||||
podfileTemplateFilename
|
||||
);
|
||||
await renderPodfileAsync(
|
||||
templatePodfilePath,
|
||||
path.join(iosProjectDirectory, 'Podfile'),
|
||||
podfileSubstitutions,
|
||||
context.data.shellAppSdkVersion,
|
||||
sdkVersion
|
||||
);
|
||||
}
|
||||
|
||||
async function createDetachedAsync(context) {
|
||||
const { iosProjectDirectory, projectName, supportingDirectory, projectRootDirectory } = getPaths(
|
||||
context
|
||||
);
|
||||
logger.info(`Creating ExpoKit workspace at ${iosProjectDirectory}...`);
|
||||
|
||||
const standaloneSdkVersion = await getNewestSdkVersionSupportedAsync(context);
|
||||
|
||||
let iosClientVersion;
|
||||
let iosExpoViewUrl;
|
||||
if (context.type === 'user') {
|
||||
({ iosClientVersion, iosExpoViewUrl } = await _getVersionedExpoKitConfigAsync(
|
||||
standaloneSdkVersion,
|
||||
process.env.EXPO_VIEW_DIR
|
||||
));
|
||||
}
|
||||
|
||||
const expoRootTemplateDirectory = await _getOrCreateTemplateDirectoryAsync(
|
||||
context,
|
||||
iosExpoViewUrl
|
||||
);
|
||||
|
||||
// copy template workspace
|
||||
logger.info('Moving iOS project files...');
|
||||
logger.info('Attempting to create project directory...');
|
||||
fs.mkdirpSync(iosProjectDirectory);
|
||||
logger.info('Created project directory! Copying files:');
|
||||
await Utils.ncpAsync(
|
||||
path.join(expoRootTemplateDirectory, 'exponent-view-template', 'ios'),
|
||||
iosProjectDirectory
|
||||
);
|
||||
|
||||
const projectPackageJsonPath = path.join(projectRootDirectory, 'package.json');
|
||||
|
||||
if (!(await fs.exists(projectPackageJsonPath))) {
|
||||
logger.info('Copying blank package.json...');
|
||||
await fs.copy(
|
||||
path.join(expoRootTemplateDirectory, 'exponent-view-template', 'package.json'),
|
||||
projectPackageJsonPath
|
||||
);
|
||||
}
|
||||
|
||||
logger.info('Installing required packages...');
|
||||
await _installRequiredPackagesAsync(projectRootDirectory, standaloneSdkVersion);
|
||||
|
||||
logger.info('Naming iOS project...');
|
||||
await _renameAndMoveProjectFilesAsync(context, iosProjectDirectory, projectName);
|
||||
|
||||
logger.info('Configuring iOS dependencies...');
|
||||
// this configuration must happen prior to build time because it affects which
|
||||
// native versions of RN we depend on.
|
||||
await _configureVersionsPlistAsync(supportingDirectory, standaloneSdkVersion);
|
||||
await _configureBuildConstantsPlistAsync(supportingDirectory, context);
|
||||
await _renderPodfileFromTemplateAsync(
|
||||
context,
|
||||
expoRootTemplateDirectory,
|
||||
standaloneSdkVersion,
|
||||
iosClientVersion
|
||||
);
|
||||
|
||||
if (!process.env.EXPO_VIEW_DIR) {
|
||||
if (context.type === 'user') {
|
||||
rimrafDontThrow(expoRootTemplateDirectory);
|
||||
}
|
||||
await IosPlist.cleanBackupAsync(supportingDirectory, 'EXSDKVersions', false);
|
||||
}
|
||||
}
|
||||
|
||||
async function _getPackagesToInstallWhenEjecting(sdkVersion) {
|
||||
const versions = await Versions.versionsAsync();
|
||||
return versions.sdkVersions[sdkVersion]
|
||||
? versions.sdkVersions[sdkVersion].packagesToInstallWhenEjecting
|
||||
: null;
|
||||
}
|
||||
|
||||
// @tsapeta: Temporarily copied from Detach._detachAsync. This needs to be invoked also when creating a shell app workspace
|
||||
// and not only when ejecting. These copies can be moved to one place if we decide to have just one flow for these two processes.
|
||||
async function _installRequiredPackagesAsync(projectRoot, sdkVersion) {
|
||||
const packagesToInstallWhenEjecting = await _getPackagesToInstallWhenEjecting(sdkVersion);
|
||||
const packagesToInstall = [];
|
||||
|
||||
if (packagesToInstallWhenEjecting && typeof packagesToInstallWhenEjecting === 'object') {
|
||||
Object.keys(packagesToInstallWhenEjecting).forEach(packageName => {
|
||||
packagesToInstall.push(`${packageName}@${packagesToInstallWhenEjecting[packageName]}`);
|
||||
});
|
||||
}
|
||||
if (packagesToInstall.length) {
|
||||
await installPackagesAsync(projectRoot, packagesToInstall);
|
||||
}
|
||||
}
|
||||
|
||||
function addDetachedConfigToExp(exp, context) {
|
||||
if (context.type !== 'user') {
|
||||
logger.warn(`Tried to modify exp for a non-user StandaloneContext, ignoring`);
|
||||
return exp;
|
||||
}
|
||||
const { supportingDirectory } = getPaths(context);
|
||||
exp.ios.publishBundlePath = path.relative(
|
||||
context.data.projectPath,
|
||||
path.join(supportingDirectory, 'shell-app.bundle')
|
||||
);
|
||||
exp.ios.publishManifestPath = path.relative(
|
||||
context.data.projectPath,
|
||||
path.join(supportingDirectory, 'shell-app-manifest.json')
|
||||
);
|
||||
return exp;
|
||||
}
|
||||
|
||||
/**
|
||||
* paths returned:
|
||||
* iosProjectDirectory - root directory of an (uncompiled) xcworkspace and obj-c source tree
|
||||
* projectName - xcworkspace project name normalized from context.config
|
||||
* supportingDirectory - location of Info.plist, xib files, etc. during configuration.
|
||||
* for an unbuilt app this is underneath iosProjectDirectory. for a compiled app it's just
|
||||
* a path to the flat xcarchive.
|
||||
* intermediatesDirectory - temporary spot to write whatever files are needed during the
|
||||
* detach/build process but can be discarded afterward.
|
||||
*/
|
||||
function getPaths(context) {
|
||||
let iosProjectDirectory;
|
||||
let projectName;
|
||||
let supportingDirectory;
|
||||
let projectRootDirectory;
|
||||
|
||||
if (context.build.isExpoClientBuild()) {
|
||||
projectName = 'Exponent';
|
||||
} else if (context.isAnonymous()) {
|
||||
projectName = 'ExpoKitApp';
|
||||
} else if (context.config && context.config.name) {
|
||||
const projectNameLabel = context.config.name;
|
||||
projectName = projectNameLabel.replace(/[^a-z0-9_-]/gi, '-').toLowerCase();
|
||||
} else {
|
||||
throw new Error('Cannot configure an Expo project with no name.');
|
||||
}
|
||||
if (context.type === 'user') {
|
||||
projectRootDirectory = context.data.projectPath;
|
||||
iosProjectDirectory = path.join(context.data.projectPath, 'ios');
|
||||
supportingDirectory = path.join(iosProjectDirectory, projectName, 'Supporting');
|
||||
} else if (context.type === 'service') {
|
||||
projectRootDirectory = path.dirname(context.build.ios.workspaceSourcePath);
|
||||
iosProjectDirectory = context.build.ios.workspaceSourcePath;
|
||||
if (context.data.archivePath) {
|
||||
// compiled archive has a flat NSBundle
|
||||
supportingDirectory = context.data.archivePath;
|
||||
} else {
|
||||
supportingDirectory = path.join(iosProjectDirectory, projectName, 'Supporting');
|
||||
}
|
||||
} else {
|
||||
throw new Error(`Unsupported StandaloneContext type: ${context.type}`);
|
||||
}
|
||||
// sandbox intermediates directory by workspace so that concurrently operating
|
||||
// contexts do not interfere with one another.
|
||||
const intermediatesDirectory = path.join(
|
||||
iosProjectDirectory,
|
||||
context.build.isExpoClientBuild() ? 'ExponentIntermediates' : 'ExpoKitIntermediates'
|
||||
);
|
||||
return {
|
||||
projectRootDirectory,
|
||||
intermediatesDirectory,
|
||||
iosProjectDirectory,
|
||||
projectName,
|
||||
supportingDirectory,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the newest sdk version supported given the standalone context.
|
||||
* Not all contexts support the newest sdk version.
|
||||
*/
|
||||
async function getNewestSdkVersionSupportedAsync(context) {
|
||||
if (context.type === 'user') {
|
||||
return context.data.exp.sdkVersion;
|
||||
} else if (context.type === 'service') {
|
||||
// when running in universe or on a turtle machine,
|
||||
// we care about what sdk version is actually present in this working copy.
|
||||
// this might not be the same thing deployed to our www Versions endpoint.
|
||||
let { supportingDirectory } = getPaths(context);
|
||||
if (!fs.existsSync(supportingDirectory)) {
|
||||
// if we run this method before creating the workspace, we may need to look at the template.
|
||||
supportingDirectory = path.join(
|
||||
context.data.expoSourcePath,
|
||||
'..',
|
||||
'exponent-view-template',
|
||||
'ios',
|
||||
'exponent-view-template',
|
||||
'Supporting'
|
||||
);
|
||||
}
|
||||
let allVersions, newestVersion;
|
||||
await IosPlist.modifyAsync(supportingDirectory, 'EXSDKVersions', versionConfig => {
|
||||
allVersions = versionConfig.sdkVersions;
|
||||
return versionConfig;
|
||||
});
|
||||
let highestMajorComponent = 0;
|
||||
allVersions.forEach(version => {
|
||||
const majorComponent = parseSdkMajorVersion(version);
|
||||
if (majorComponent > highestMajorComponent) {
|
||||
highestMajorComponent = majorComponent;
|
||||
newestVersion = version;
|
||||
}
|
||||
});
|
||||
return newestVersion;
|
||||
}
|
||||
}
|
||||
|
||||
export { addDetachedConfigToExp, createDetachedAsync, getPaths, getNewestSdkVersionSupportedAsync };
|
||||
@@ -0,0 +1,131 @@
|
||||
import bunyan from '@expo/bunyan';
|
||||
import isEmpty from 'lodash/isEmpty';
|
||||
import isPlainObject from 'lodash/isPlainObject';
|
||||
import { Readable } from 'stream';
|
||||
|
||||
export enum LogLevel {
|
||||
trace = 'trace',
|
||||
debug = 'debug',
|
||||
info = 'info',
|
||||
warn = 'warn',
|
||||
error = 'error',
|
||||
fatal = 'fatal',
|
||||
}
|
||||
|
||||
type BunyanGetter = () => bunyan;
|
||||
|
||||
export class Logger {
|
||||
public loggerObj: bunyan;
|
||||
public loggerGetter?: BunyanGetter;
|
||||
public extraFields: any;
|
||||
|
||||
constructor(bunyanGetter?: BunyanGetter, extraFields?: any) {
|
||||
this.loggerObj = bunyan.createLogger({ name: 'xdl-detach' });
|
||||
this.loggerGetter = bunyanGetter;
|
||||
this.extraFields = extraFields;
|
||||
}
|
||||
|
||||
public configure(loggerObj: bunyan) {
|
||||
this.loggerObj = loggerObj;
|
||||
}
|
||||
|
||||
public withFields(extraFields: any) {
|
||||
const getter = this.loggerGetter || (() => this.loggerObj);
|
||||
return new Logger(getter, { ...this.extraFields, ...extraFields });
|
||||
}
|
||||
|
||||
public trace(...all: any[]) {
|
||||
this.logLine(LogLevel.trace, ...all);
|
||||
}
|
||||
public debug(...all: any[]) {
|
||||
this.logLine(LogLevel.debug, ...all);
|
||||
}
|
||||
public info(...all: any[]) {
|
||||
this.logLine(LogLevel.info, ...all);
|
||||
}
|
||||
public warn(...all: any[]) {
|
||||
this.logLine(LogLevel.warn, ...all);
|
||||
}
|
||||
public error(...all: any[]) {
|
||||
this.logLine(LogLevel.error, ...all);
|
||||
}
|
||||
public fatal(...all: any[]) {
|
||||
this.logLine(LogLevel.fatal, ...all);
|
||||
}
|
||||
|
||||
public logLine(level: LogLevel, ...args: any[]) {
|
||||
const argsToLog = [...args];
|
||||
const extraFieldsFromArgsExist = isPlainObject(args[0]);
|
||||
const extraFieldsFromArgs = extraFieldsFromArgsExist ? args[0] : {};
|
||||
if (extraFieldsFromArgsExist) {
|
||||
argsToLog.shift();
|
||||
}
|
||||
const extraFields = { ...extraFieldsFromArgs, ...this.extraFields };
|
||||
if (!isEmpty(extraFields)) {
|
||||
argsToLog.unshift(extraFields);
|
||||
}
|
||||
|
||||
if (this.loggerGetter) {
|
||||
const loggerObj = this.loggerGetter();
|
||||
// @ts-ignore: type mismatch
|
||||
loggerObj[level](...argsToLog);
|
||||
} else {
|
||||
// @ts-ignore: type mismatch
|
||||
this.loggerObj[level](...argsToLog);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const LoggerDetach = new Logger();
|
||||
export default LoggerDetach;
|
||||
|
||||
export function pipeOutputToLogger(
|
||||
{ stdout, stderr }: { stdout?: Readable | null; stderr?: Readable | null } = {
|
||||
stdout: null,
|
||||
stderr: null,
|
||||
},
|
||||
extraFields = {},
|
||||
{
|
||||
stdoutOnly = false,
|
||||
loggerLineTransformer,
|
||||
}: { stdoutOnly?: boolean; loggerLineTransformer?: (line: any) => any } = {},
|
||||
) {
|
||||
if (stdout) {
|
||||
stdout.on('data', (chunk) =>
|
||||
logMultiline(
|
||||
chunk,
|
||||
{ ...extraFields, source: 'stdout' },
|
||||
loggerLineTransformer,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (stderr) {
|
||||
const source = stdoutOnly ? 'stdout' : 'stderr';
|
||||
stderr.on('data', (chunk) =>
|
||||
logMultiline(chunk, { ...extraFields, source }, loggerLineTransformer),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function logMultiline(
|
||||
data: any,
|
||||
extraFields: any,
|
||||
loggerLineTransformer?: (line: any) => any,
|
||||
) {
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
const lines = String(data).split('\n');
|
||||
lines.forEach((line) => {
|
||||
const lineToPrint = loggerLineTransformer
|
||||
? loggerLineTransformer(line)
|
||||
: line;
|
||||
if (lineToPrint) {
|
||||
const args = [lineToPrint];
|
||||
if (!isEmpty(extraFields)) {
|
||||
args.unshift(extraFields);
|
||||
}
|
||||
LoggerDetach.info(...args);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import forge from 'node-forge';
|
||||
|
||||
export function getP12CertFingerprint(
|
||||
p12Buffer: Buffer | string,
|
||||
passwordRaw: string | null,
|
||||
): string {
|
||||
const certData = _getCertData(p12Buffer, passwordRaw);
|
||||
const certAsn1 = forge.pki.certificateToAsn1(certData);
|
||||
const certDer = forge.asn1.toDer(certAsn1).getBytes();
|
||||
return forge.md.sha1.create().update(certDer).digest().toHex().toUpperCase();
|
||||
}
|
||||
|
||||
export function findP12CertSerialNumber(
|
||||
p12Buffer: Buffer | string,
|
||||
passwordRaw: string | null,
|
||||
): string | null {
|
||||
const { serialNumber } = getCertData(p12Buffer, passwordRaw);
|
||||
return serialNumber;
|
||||
}
|
||||
|
||||
function _processSerialNumber(maybeSerialNumber: string | null | undefined) {
|
||||
return maybeSerialNumber ? maybeSerialNumber.replace(/^0+/, '').toUpperCase() : null;
|
||||
}
|
||||
|
||||
export function getCertData(p12Buffer: Buffer | string, passwordRaw: string | null) {
|
||||
const certData = _getCertData(p12Buffer, passwordRaw);
|
||||
return {
|
||||
...certData,
|
||||
serialNumber: _processSerialNumber(certData.serialNumber),
|
||||
};
|
||||
}
|
||||
|
||||
function _getCertData(p12Buffer: Buffer | string, passwordRaw: string | null) {
|
||||
if (Buffer.isBuffer(p12Buffer)) {
|
||||
p12Buffer = p12Buffer.toString('base64');
|
||||
} else if (typeof p12Buffer !== 'string') {
|
||||
throw new Error('_getCertData only takes strings and buffers.');
|
||||
}
|
||||
|
||||
const password = String(passwordRaw || '');
|
||||
const p12Der = forge.util.decode64(p12Buffer);
|
||||
const p12Asn1 = forge.asn1.fromDer(p12Der);
|
||||
const p12 = forge.pkcs12.pkcs12FromAsn1(p12Asn1, password);
|
||||
const certBagType = forge.pki.oids.certBag;
|
||||
const certData = p12.getBags({ bagType: certBagType })?.[certBagType]?.[0]?.cert;
|
||||
if (!certData) {
|
||||
throw new Error('_getCertData: couldn\'t find cert bag');
|
||||
}
|
||||
return certData;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* StandaloneBuildFlags is owned by a StandaloneContext and carries information about
|
||||
* how to compile native code during the build step.
|
||||
*/
|
||||
|
||||
type StandaloneBuildConfiguration = 'Debug' | 'Release';
|
||||
interface IStandaloneBuildAndroidFlags {
|
||||
keystore: string;
|
||||
keystorePassword: string;
|
||||
keyAlias: string;
|
||||
keyPassword: string;
|
||||
outputFile: string | null;
|
||||
}
|
||||
interface IStandaloneBuildIosFlags {
|
||||
workspaceSourcePath: string;
|
||||
appleTeamId: string | null;
|
||||
buildType?: string;
|
||||
bundleExecutable?: string;
|
||||
}
|
||||
|
||||
class StandaloneBuildFlags {
|
||||
public static createEmpty = () => {
|
||||
return new StandaloneBuildFlags();
|
||||
}
|
||||
|
||||
public static createIos = (
|
||||
configuration: StandaloneBuildConfiguration,
|
||||
ios?: IStandaloneBuildIosFlags,
|
||||
): StandaloneBuildFlags => {
|
||||
const flags = new StandaloneBuildFlags();
|
||||
flags.configuration = configuration;
|
||||
flags.ios = ios;
|
||||
flags.isExpoClientBuild = () => ios?.buildType === 'client';
|
||||
return flags;
|
||||
}
|
||||
|
||||
public static createAndroid = (
|
||||
configuration: StandaloneBuildConfiguration,
|
||||
android?: IStandaloneBuildAndroidFlags,
|
||||
): StandaloneBuildFlags => {
|
||||
const flags = new StandaloneBuildFlags();
|
||||
flags.configuration = configuration;
|
||||
flags.android = android;
|
||||
flags.isExpoClientBuild = () => false;
|
||||
return flags;
|
||||
}
|
||||
public configuration: StandaloneBuildConfiguration = 'Debug';
|
||||
public android?: IStandaloneBuildAndroidFlags;
|
||||
public ios?: IStandaloneBuildIosFlags;
|
||||
public isExpoClientBuild: () => boolean = () => false;
|
||||
}
|
||||
|
||||
export default StandaloneBuildFlags;
|
||||
@@ -0,0 +1,125 @@
|
||||
import StandaloneBuildFlags from './StandaloneBuildFlags';
|
||||
import { StandaloneContextService } from './StandaloneContextService';
|
||||
import { StandaloneContextUser } from './StandaloneContextUser';
|
||||
|
||||
export type StandaloneContextDataType = 'user' | 'service';
|
||||
|
||||
export type StandaloneContextTestEnvironment = 'none' | 'local' | 'ci';
|
||||
|
||||
// currently unused
|
||||
export function isStandaloneContextDataUser(
|
||||
value: any,
|
||||
): value is IStandaloneContextDataUser {
|
||||
return value && typeof value.projectPath === 'string' && 'exp' in value;
|
||||
}
|
||||
|
||||
export function isStandaloneContextTestEnvironment(
|
||||
value: string,
|
||||
): value is StandaloneContextTestEnvironment {
|
||||
return ['none', 'local', 'ci'].includes(value);
|
||||
}
|
||||
|
||||
export function isStandaloneContextDataService(
|
||||
value: any,
|
||||
): value is IStandaloneContextDataService {
|
||||
return (
|
||||
value &&
|
||||
isStandaloneContextTestEnvironment(value.testEnvironment) &&
|
||||
typeof value.expoSourcePath === 'string' &&
|
||||
typeof value.shellAppSdkVersion === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A user context is used when we are configuring a standalone app locally on a user's machine,
|
||||
* such as during `exp detach`.
|
||||
*/
|
||||
export interface IStandaloneContextDataUser {
|
||||
projectPath: string;
|
||||
exp: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* A service context is used when we are generating a standalone app remotely on an Expo
|
||||
* service machine, such as during `exp build`.
|
||||
*/
|
||||
export interface IStandaloneContextDataService {
|
||||
expoSourcePath: string;
|
||||
archivePath: string | null;
|
||||
manifest: any;
|
||||
privateConfig: any;
|
||||
testEnvironment: StandaloneContextTestEnvironment;
|
||||
shellAppSdkVersion: string;
|
||||
}
|
||||
|
||||
export class StandaloneContext {
|
||||
public static createUserContext = (
|
||||
projectPath: string,
|
||||
exp: any,
|
||||
publishedUrl?: string,
|
||||
): StandaloneContextUser => {
|
||||
const context = new StandaloneContextUser(
|
||||
{
|
||||
projectPath,
|
||||
exp,
|
||||
},
|
||||
{
|
||||
url: publishedUrl,
|
||||
releaseChannel: 'default',
|
||||
},
|
||||
// we never expect to handle the build step for user contexts right now
|
||||
StandaloneBuildFlags.createEmpty(),
|
||||
);
|
||||
context.config = exp;
|
||||
return context;
|
||||
}
|
||||
|
||||
public static createServiceContext = (
|
||||
expoSourcePath: string,
|
||||
archivePath: string | null,
|
||||
manifest: any,
|
||||
privateConfig: any,
|
||||
testEnvironment: StandaloneContextTestEnvironment,
|
||||
build: StandaloneBuildFlags,
|
||||
publishedUrl: string,
|
||||
releaseChannel: string,
|
||||
shellAppSdkVersion: string,
|
||||
): StandaloneContextService => {
|
||||
const context = new StandaloneContextService(
|
||||
{
|
||||
expoSourcePath,
|
||||
archivePath,
|
||||
manifest,
|
||||
privateConfig,
|
||||
testEnvironment,
|
||||
shellAppSdkVersion,
|
||||
},
|
||||
{
|
||||
url: publishedUrl,
|
||||
releaseChannel: releaseChannel ? releaseChannel : 'default',
|
||||
},
|
||||
build,
|
||||
);
|
||||
context.config = manifest;
|
||||
|
||||
return context;
|
||||
}
|
||||
public data?: IStandaloneContextDataUser | IStandaloneContextDataService;
|
||||
public config: any; // same as underlying app.json or manifest
|
||||
|
||||
/**
|
||||
* On iOS we begin configuring standalone apps before we have any information about the
|
||||
* project's manifest. By default let's treat all contexts as non-anonymous and override
|
||||
* it in contexts that needs this to be different.
|
||||
*/
|
||||
public isAnonymous() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
export type AnyStandaloneContext =
|
||||
| StandaloneContextUser
|
||||
| StandaloneContextService;
|
||||
|
||||
export default StandaloneContext;
|
||||
|
||||
export { StandaloneContextService, StandaloneContextUser };
|
||||
@@ -0,0 +1,28 @@
|
||||
import StandaloneBuildFlags from './StandaloneBuildFlags';
|
||||
import {
|
||||
IStandaloneContextDataService,
|
||||
StandaloneContext,
|
||||
StandaloneContextDataType,
|
||||
} from './StandaloneContext';
|
||||
|
||||
export class StandaloneContextService extends StandaloneContext {
|
||||
public type: StandaloneContextDataType = 'service';
|
||||
constructor(
|
||||
public data: IStandaloneContextDataService,
|
||||
public published: {
|
||||
url: string;
|
||||
releaseChannel: string;
|
||||
},
|
||||
public build: StandaloneBuildFlags,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
/**
|
||||
* On iOS we begin configuring standalone apps before we have any information about the
|
||||
* project's manifest.
|
||||
*/
|
||||
public isAnonymous() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import StandaloneBuildFlags from './StandaloneBuildFlags';
|
||||
import {
|
||||
IStandaloneContextDataUser,
|
||||
StandaloneContext,
|
||||
StandaloneContextDataType,
|
||||
} from './StandaloneContext';
|
||||
|
||||
export class StandaloneContextUser extends StandaloneContext {
|
||||
public type: StandaloneContextDataType = 'user';
|
||||
constructor(
|
||||
public data: IStandaloneContextDataUser,
|
||||
public published: {
|
||||
url?: string;
|
||||
releaseChannel: 'default';
|
||||
},
|
||||
public build: StandaloneBuildFlags,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { ncp } from 'ncp';
|
||||
|
||||
export function ncpAsync(
|
||||
source: string,
|
||||
dest: string,
|
||||
options: any = {},
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
ncp(source, dest, options, (err: any) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import spawnAsync from '@expo/spawn-async';
|
||||
import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
import semver from 'semver';
|
||||
|
||||
import logger from './Logger';
|
||||
|
||||
export default async function installPackagesAsync(
|
||||
projectDir: string,
|
||||
packages: string[],
|
||||
options: any = {},
|
||||
): Promise<void> {
|
||||
let packageManager = 'npm';
|
||||
if (options.packageManager) {
|
||||
packageManager = options.packageManager;
|
||||
} else {
|
||||
const packageLockJsonExists: boolean = await fs.pathExists(
|
||||
path.join(projectDir, 'package-lock.json'),
|
||||
);
|
||||
const yarnExists = await yarnExistsAsync();
|
||||
packageManager = yarnExists && !packageLockJsonExists ? 'yarn' : 'npm';
|
||||
}
|
||||
|
||||
if (packageManager === 'yarn') {
|
||||
logger.info(`Installing dependencies using Yarn...`);
|
||||
await spawnAsync('yarnpkg', ['add', '--silent', ...packages], {
|
||||
cwd: projectDir,
|
||||
stdio: 'inherit',
|
||||
});
|
||||
} else {
|
||||
logger.info(`Installing dependencies using npm...`);
|
||||
if (!(await fs.pathExists(path.join(projectDir, 'node_modules')))) {
|
||||
await spawnAsync('npm', ['install', '--loglevel', 'error'], {
|
||||
cwd: projectDir,
|
||||
stdio: 'inherit',
|
||||
});
|
||||
}
|
||||
await spawnAsync('npm', ['install', '--save', '--loglevel', 'error', ...packages], {
|
||||
cwd: projectDir,
|
||||
stdio: 'inherit',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function yarnExistsAsync() {
|
||||
try {
|
||||
const version = (await spawnAsync('yarnpkg', ['--version'])).stdout.trim();
|
||||
return !!semver.valid(version);
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import * as ExponentTools from './detach/ExponentTools';
|
||||
import * as IosIcons from './detach/IosIcons';
|
||||
import * as IosKeychain from './detach/IosKeychain';
|
||||
import * as IosPlist from './detach/IosPlist';
|
||||
import LoggerDetach from './detach/Logger';
|
||||
import * as PKCS12Utils from './detach/PKCS12Utils';
|
||||
import * as FsCache from './FsCache';
|
||||
import * as ImageUtils from './ImageUtils';
|
||||
import * as Modules from './modules/Modules';
|
||||
import * as ModuleVersion from './ModuleVersion';
|
||||
import XDLError from './XDLError';
|
||||
|
||||
// tslint:disable-next-line
|
||||
const AndroidShellApp: any = require("./detach/AndroidShellApp.js");
|
||||
// tslint:disable-next-line
|
||||
const Detach: any = require("./detach/Detach.js");
|
||||
// tslint:disable-next-line
|
||||
const IosCodeSigning = require("./detach/IosCodeSigning.js");
|
||||
// tslint:disable-next-line
|
||||
const IosIPABuilder = require("./detach/IosIPABuilder.js").default;
|
||||
// tslint:disable-next-line
|
||||
const IosPodsTools = require("./detach/IosPodsTools.js");
|
||||
// tslint:disable-next-line
|
||||
const IosShellApp = require("./detach/IosShellApp.js");
|
||||
// tslint:disable-next-line
|
||||
const IosWorkspace = require("./detach/IosWorkspace");
|
||||
|
||||
export { AndroidShellApp };
|
||||
|
||||
export { Detach };
|
||||
|
||||
export { ExponentTools };
|
||||
export { FsCache };
|
||||
export { ImageUtils };
|
||||
|
||||
export { IosIcons };
|
||||
|
||||
export { IosIPABuilder };
|
||||
export { IosKeychain };
|
||||
|
||||
export { IosWorkspace };
|
||||
export { IosPlist };
|
||||
|
||||
export { IosPodsTools };
|
||||
|
||||
export { IosShellApp };
|
||||
|
||||
export { IosCodeSigning };
|
||||
|
||||
export { LoggerDetach };
|
||||
export { ModuleVersion };
|
||||
export { Modules };
|
||||
export { PKCS12Utils };
|
||||
export { XDLError };
|
||||
@@ -0,0 +1,82 @@
|
||||
import semver from 'semver';
|
||||
|
||||
import {
|
||||
expoSdkUniversalModulesConfigs,
|
||||
IModuleConfig,
|
||||
INativeConfig,
|
||||
vendoredNativeModules,
|
||||
} from './config';
|
||||
|
||||
type PlatformSpecificModuleConfig = IModuleConfig & INativeConfig;
|
||||
type Platform = 'ios' | 'android';
|
||||
|
||||
const HIGHEST_KNOWN_VERSION = '10000.0.0';
|
||||
|
||||
export function getAllNativeModules() {
|
||||
return [...expoSdkUniversalModulesConfigs, ...vendoredNativeModules].filter(
|
||||
(moduleConfig) => moduleConfig.isNativeModule,
|
||||
);
|
||||
}
|
||||
|
||||
export function getAllForPlatform(
|
||||
platform: Platform,
|
||||
sdkVersion: string,
|
||||
): PlatformSpecificModuleConfig[] {
|
||||
return expoSdkUniversalModulesConfigs
|
||||
.filter((moduleConfig: IModuleConfig) =>
|
||||
doesVersionSatisfy(sdkVersion, moduleConfig.sdkVersions),
|
||||
)
|
||||
.map((moduleConfig: IModuleConfig) => ({
|
||||
...moduleConfig,
|
||||
...moduleConfig.config[platform],
|
||||
}));
|
||||
}
|
||||
|
||||
export function getAllNativeForExpoClientOnPlatform(
|
||||
platform: Platform,
|
||||
sdkVersion: string,
|
||||
): PlatformSpecificModuleConfig[] {
|
||||
return getAllForPlatform(platform, sdkVersion).filter(
|
||||
(moduleConfig) =>
|
||||
moduleConfig.includeInExpoClient && moduleConfig.isNativeModule,
|
||||
);
|
||||
}
|
||||
|
||||
export function getVersionableModulesForPlatform(
|
||||
platform: Platform,
|
||||
sdkVersion: string,
|
||||
): PlatformSpecificModuleConfig[] {
|
||||
return getAllNativeForExpoClientOnPlatform(platform, sdkVersion).filter(
|
||||
(moduleConfig) => {
|
||||
return moduleConfig.versionable;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function getDetachableModules(
|
||||
platform: Platform,
|
||||
sdkVersion: string,
|
||||
): PlatformSpecificModuleConfig[] {
|
||||
return getAllForPlatform(platform, sdkVersion).filter(
|
||||
(moduleConfig) => moduleConfig.isNativeModule && moduleConfig.detachable,
|
||||
);
|
||||
}
|
||||
|
||||
export function getPublishableModules(sdkVersion: string): IModuleConfig[] {
|
||||
return expoSdkUniversalModulesConfigs.filter(
|
||||
(moduleConfig: IModuleConfig) =>
|
||||
!!moduleConfig.libName &&
|
||||
doesVersionSatisfy(sdkVersion, moduleConfig.sdkVersions),
|
||||
);
|
||||
}
|
||||
|
||||
export function doesVersionSatisfy(
|
||||
version: string,
|
||||
versionRequirement: string,
|
||||
): boolean {
|
||||
if (version === 'UNVERSIONED') {
|
||||
return semver.satisfies(HIGHEST_KNOWN_VERSION, versionRequirement);
|
||||
}
|
||||
|
||||
return semver.satisfies(version, versionRequirement);
|
||||
}
|
||||
@@ -0,0 +1,718 @@
|
||||
export interface IModuleConfig {
|
||||
podName?: string;
|
||||
libName: string;
|
||||
sdkVersions: string;
|
||||
isNativeModule: boolean;
|
||||
config: {
|
||||
android: INativeConfig;
|
||||
ios: INativeConfig;
|
||||
};
|
||||
}
|
||||
|
||||
export interface INativeConfig {
|
||||
subdirectory: string;
|
||||
versionable: boolean;
|
||||
detachable: boolean;
|
||||
includeInExpoClient: boolean;
|
||||
}
|
||||
|
||||
const defaultUniversalModuleConfig = {
|
||||
ios: {
|
||||
// subdirectory in which the module podspec is placed.
|
||||
subdirectory: 'ios',
|
||||
// whether when adding a new version of ABI
|
||||
// the module should be versioned and released
|
||||
// as eg. ABI28_0_0EXCamera
|
||||
versionable: true,
|
||||
// whether the module should be included in the newly created detached app.
|
||||
detachable: true,
|
||||
// whether the module should be included in Expo client
|
||||
includeInExpoClient: true,
|
||||
},
|
||||
android: {
|
||||
// subdirectory in which the Android project can be found.
|
||||
subdirectory: 'android',
|
||||
// whether when adding a new version of ABI
|
||||
// the module should be versioned and released
|
||||
// as eg. abi28_2_0.expo.modules.camera
|
||||
versionable: true,
|
||||
// whether the module should be included in the newly created detached app,
|
||||
// so when releasing a new version of expoview
|
||||
// as eg. host.exp.exponent:expoview:28.2.0 the module will
|
||||
// be assembled and uploaded to the local maven repo
|
||||
detachable: true,
|
||||
// whether the module should be included in Expo client
|
||||
includeInExpoClient: true,
|
||||
},
|
||||
};
|
||||
|
||||
const firebaseModuleConfig = {
|
||||
ios: {
|
||||
versionable: false,
|
||||
detachable: false,
|
||||
includeInExpoClient: false,
|
||||
},
|
||||
android: {
|
||||
versionable: false,
|
||||
detachable: false,
|
||||
includeInExpoClient: false,
|
||||
},
|
||||
};
|
||||
|
||||
const expoUniversalModules = [
|
||||
// native modules
|
||||
{
|
||||
podName: 'EXAdsAdMob',
|
||||
libName: 'expo-ads-admob',
|
||||
sdkVersions: '>=30.0.0',
|
||||
},
|
||||
{
|
||||
podName: 'EXAdsFacebook',
|
||||
libName: 'expo-ads-facebook',
|
||||
sdkVersions: '>=33.0.0',
|
||||
},
|
||||
{
|
||||
podName: 'EXAmplitude',
|
||||
libName: 'expo-analytics-amplitude',
|
||||
sdkVersions: '>=33.0.0',
|
||||
},
|
||||
{
|
||||
podName: 'EXSegment',
|
||||
libName: 'expo-analytics-segment',
|
||||
sdkVersions: '>=30.0.0',
|
||||
},
|
||||
{
|
||||
podName: 'EXAppAuth',
|
||||
libName: 'expo-app-auth',
|
||||
sdkVersions: '>=32.0.0',
|
||||
},
|
||||
{
|
||||
podName: 'EXApplication',
|
||||
libName: 'expo-application',
|
||||
sdkVersions: '>=35.0.0',
|
||||
},
|
||||
{
|
||||
podName: 'EXAppLoaderProvider',
|
||||
libName: 'expo-app-loader-provider',
|
||||
sdkVersions: '>=32.0.0',
|
||||
},
|
||||
{
|
||||
podName: 'EXAV',
|
||||
libName: 'expo-av',
|
||||
sdkVersions: '>=33.0.0',
|
||||
},
|
||||
{
|
||||
podName: 'EXBackgroundFetch',
|
||||
libName: 'expo-background-fetch',
|
||||
sdkVersions: '>=32.0.0',
|
||||
},
|
||||
{
|
||||
podName: 'EXBarCodeScanner',
|
||||
libName: 'expo-barcode-scanner',
|
||||
sdkVersions: '>=30.0.0',
|
||||
},
|
||||
{
|
||||
podName: 'EXBarCodeScannerInterface',
|
||||
libName: 'expo-barcode-scanner-interface',
|
||||
sdkVersions: '>=30.0.0 <33.0.0',
|
||||
},
|
||||
{
|
||||
podName: 'EXBattery',
|
||||
libName: 'expo-battery',
|
||||
sdkVersions: '>=35.0.0',
|
||||
},
|
||||
{
|
||||
podName: 'EXBluetooth',
|
||||
libName: 'expo-bluetooth',
|
||||
sdkVersions: '>=34.0.0',
|
||||
config: {
|
||||
ios: {
|
||||
includeInExpoClient: false,
|
||||
},
|
||||
android: {
|
||||
includeInExpoClient: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
podName: 'EXBlur',
|
||||
libName: 'expo-blur',
|
||||
sdkVersions: '>=33.0.0',
|
||||
config: {
|
||||
android: {
|
||||
versionable: false,
|
||||
detachable: false,
|
||||
includeInExpoClient: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
podName: 'EXBrightness',
|
||||
libName: 'expo-brightness',
|
||||
sdkVersions: '>=33.0.0',
|
||||
},
|
||||
{
|
||||
podName: 'EXCalendar',
|
||||
libName: 'expo-calendar',
|
||||
sdkVersions: '>=33.0.0',
|
||||
},
|
||||
{
|
||||
podName: 'EXCamera',
|
||||
libName: 'expo-camera',
|
||||
sdkVersions: '>=29.0.0',
|
||||
},
|
||||
{
|
||||
podName: 'EXCameraInterface',
|
||||
libName: 'expo-camera-interface',
|
||||
sdkVersions: '>=29.0.0 <33.0.0',
|
||||
},
|
||||
{
|
||||
podName: 'EXCellular',
|
||||
libName: 'expo-cellular',
|
||||
sdkVersions: '>=35.0.0',
|
||||
},
|
||||
{
|
||||
podName: 'EXConstants',
|
||||
libName: 'expo-constants',
|
||||
sdkVersions: '>=29.0.0',
|
||||
},
|
||||
{
|
||||
podName: 'EXConstantsInterface',
|
||||
libName: 'expo-constants-interface',
|
||||
sdkVersions: '>=29.0.0 <33.0.0',
|
||||
},
|
||||
{
|
||||
podName: 'EXContacts',
|
||||
libName: 'expo-contacts',
|
||||
sdkVersions: '>=30.0.0',
|
||||
},
|
||||
{
|
||||
podName: 'EXCore',
|
||||
libName: 'expo-core',
|
||||
sdkVersions: '>=29.0.0 <33.0.0',
|
||||
},
|
||||
{
|
||||
podName: 'EXCrypto',
|
||||
libName: 'expo-crypto',
|
||||
sdkVersions: '>=33.0.0',
|
||||
},
|
||||
{
|
||||
podName: 'EXDevice',
|
||||
libName: 'expo-device',
|
||||
sdkVersions: '>=35.0.0',
|
||||
},
|
||||
{
|
||||
podName: 'EXDocumentPicker',
|
||||
libName: 'expo-document-picker',
|
||||
sdkVersions: '>=33.0.0',
|
||||
},
|
||||
{
|
||||
podName: 'EXErrors',
|
||||
libName: 'expo-errors',
|
||||
sdkVersions: '>=32.0.0 <33.0.0',
|
||||
},
|
||||
{
|
||||
podName: 'EXFacebook',
|
||||
libName: 'expo-facebook',
|
||||
sdkVersions: '>=33.0.0',
|
||||
},
|
||||
{
|
||||
podName: 'EXFaceDetector',
|
||||
libName: 'expo-face-detector',
|
||||
detachable: false,
|
||||
sdkVersions: '>=29.0.0',
|
||||
},
|
||||
{
|
||||
podName: 'EXFaceDetectorInterface',
|
||||
libName: 'expo-face-detector-interface',
|
||||
sdkVersions: '>=29.0.0 <33.0.0',
|
||||
},
|
||||
{
|
||||
podName: 'EXFileSystem',
|
||||
libName: 'expo-file-system',
|
||||
sdkVersions: '>=29.0.0',
|
||||
},
|
||||
{
|
||||
podName: 'EXFileSystemInterface',
|
||||
libName: 'expo-file-system-interface',
|
||||
sdkVersions: '>=29.0.0 <33.0.0',
|
||||
},
|
||||
{
|
||||
podName: 'EXFirebaseAnalytics',
|
||||
libName: 'expo-firebase-analytics',
|
||||
sdkVersions: '>=31.0.0',
|
||||
config: firebaseModuleConfig,
|
||||
},
|
||||
{
|
||||
podName: 'EXFirebaseApp',
|
||||
libName: 'expo-firebase-app',
|
||||
sdkVersions: '>=31.0.0',
|
||||
config: firebaseModuleConfig,
|
||||
},
|
||||
{
|
||||
podName: 'EXFirebaseAuth',
|
||||
libName: 'expo-firebase-auth',
|
||||
sdkVersions: '>=31.0.0',
|
||||
config: firebaseModuleConfig,
|
||||
},
|
||||
{
|
||||
podName: 'EXFirebaseCrashlytics',
|
||||
libName: 'expo-firebase-crashlytics',
|
||||
sdkVersions: '>=31.0.0',
|
||||
config: firebaseModuleConfig,
|
||||
},
|
||||
{
|
||||
podName: 'EXFirebaseDatabase',
|
||||
libName: 'expo-firebase-database',
|
||||
sdkVersions: '>=31.0.0',
|
||||
config: firebaseModuleConfig,
|
||||
},
|
||||
{
|
||||
podName: 'EXFirebaseFirestore',
|
||||
libName: 'expo-firebase-firestore',
|
||||
sdkVersions: '>=31.0.0',
|
||||
config: firebaseModuleConfig,
|
||||
},
|
||||
{
|
||||
podName: 'EXFirebaseFunctions',
|
||||
libName: 'expo-firebase-functions',
|
||||
sdkVersions: '>=31.0.0',
|
||||
config: firebaseModuleConfig,
|
||||
},
|
||||
{
|
||||
podName: 'EXFirebaseInstanceID',
|
||||
libName: 'expo-firebase-instance-id',
|
||||
sdkVersions: '>=31.0.0',
|
||||
config: firebaseModuleConfig,
|
||||
},
|
||||
{
|
||||
podName: 'EXFirebaseInvites',
|
||||
libName: 'expo-firebase-invites',
|
||||
sdkVersions: '>=31.0.0',
|
||||
config: firebaseModuleConfig,
|
||||
},
|
||||
{
|
||||
podName: 'EXFirebaseLinks',
|
||||
libName: 'expo-firebase-links',
|
||||
sdkVersions: '>=31.0.0',
|
||||
config: firebaseModuleConfig,
|
||||
},
|
||||
{
|
||||
podName: 'EXFirebaseMessaging',
|
||||
libName: 'expo-firebase-messaging',
|
||||
sdkVersions: '>=31.0.0',
|
||||
config: firebaseModuleConfig,
|
||||
},
|
||||
{
|
||||
podName: 'EXFirebaseNotifications',
|
||||
libName: 'expo-firebase-notifications',
|
||||
sdkVersions: '>=31.0.0',
|
||||
config: firebaseModuleConfig,
|
||||
},
|
||||
{
|
||||
podName: 'EXFirebasePerformance',
|
||||
libName: 'expo-firebase-performance',
|
||||
sdkVersions: '>=31.0.0',
|
||||
config: firebaseModuleConfig,
|
||||
},
|
||||
{
|
||||
podName: 'EXFirebaseRemoteConfig',
|
||||
libName: 'expo-firebase-remote-config',
|
||||
sdkVersions: '>=31.0.0',
|
||||
config: firebaseModuleConfig,
|
||||
},
|
||||
{
|
||||
podName: 'EXFirebaseStorage',
|
||||
libName: 'expo-firebase-storage',
|
||||
sdkVersions: '>=31.0.0',
|
||||
config: firebaseModuleConfig,
|
||||
},
|
||||
{
|
||||
podName: 'EXFont',
|
||||
libName: 'expo-font',
|
||||
sdkVersions: '>=30.0.0',
|
||||
},
|
||||
{
|
||||
podName: 'EXFontInterface',
|
||||
libName: 'expo-font-interface',
|
||||
sdkVersions: '>=30.0.0 <33.0.0',
|
||||
},
|
||||
{
|
||||
podName: 'EXGL',
|
||||
libName: 'expo-gl',
|
||||
sdkVersions: '>=29.0.0',
|
||||
},
|
||||
{
|
||||
podName: 'EXGL-CPP',
|
||||
libName: 'expo-gl-cpp',
|
||||
sdkVersions: '>=29.0.0',
|
||||
versionable: false,
|
||||
config: {
|
||||
ios: {
|
||||
subdirectory: 'cpp',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
podName: 'EXGoogleSignIn',
|
||||
libName: 'expo-google-sign-in',
|
||||
sdkVersions: '>=32.0.0',
|
||||
},
|
||||
{
|
||||
podName: 'EXHaptics',
|
||||
libName: 'expo-haptics',
|
||||
sdkVersions: '>=33.0.0',
|
||||
},
|
||||
{
|
||||
podName: 'EXImageLoaderInterface',
|
||||
libName: 'expo-image-loader-interface',
|
||||
sdkVersions: '>=30.0.0 <33.0.0',
|
||||
},
|
||||
{
|
||||
podName: 'EXImageManipulator',
|
||||
libName: 'expo-image-manipulator',
|
||||
sdkVersions: '>=33.0.0',
|
||||
},
|
||||
{
|
||||
podName: 'EXImagePicker',
|
||||
libName: 'expo-image-picker',
|
||||
sdkVersions: '>=33.0.0',
|
||||
},
|
||||
{
|
||||
podName: 'EXInAppPurchases',
|
||||
libName: 'expo-in-app-purchases',
|
||||
sdkVersions: '>=33.0.0',
|
||||
config: {
|
||||
ios: {
|
||||
includeInExpoClient: false,
|
||||
},
|
||||
android: {
|
||||
includeInExpoClient: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
libName: 'expo-intent-launcher',
|
||||
sdkVersions: '>=33.0.0',
|
||||
config: {
|
||||
ios: {
|
||||
versionable: false,
|
||||
detachable: false,
|
||||
includeInExpoClient: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
podName: 'EXKeepAwake',
|
||||
libName: 'expo-keep-awake',
|
||||
sdkVersions: '>=33.0.0',
|
||||
},
|
||||
{
|
||||
podName: 'EXLinearGradient',
|
||||
libName: 'expo-linear-gradient',
|
||||
sdkVersions: '>=33.0.0',
|
||||
},
|
||||
{
|
||||
podName: 'EXLocalAuthentication',
|
||||
libName: 'expo-local-authentication',
|
||||
sdkVersions: '>=30.0.0',
|
||||
},
|
||||
{
|
||||
podName: 'EXLocalization',
|
||||
libName: 'expo-localization',
|
||||
sdkVersions: '>=31.0.0',
|
||||
},
|
||||
{
|
||||
podName: 'EXLocation',
|
||||
libName: 'expo-location',
|
||||
sdkVersions: '>=30.0.0',
|
||||
},
|
||||
{
|
||||
podName: 'EXMailComposer',
|
||||
libName: 'expo-mail-composer',
|
||||
sdkVersions: '>=33.0.0',
|
||||
},
|
||||
{
|
||||
podName: 'EXMediaLibrary',
|
||||
libName: 'expo-media-library',
|
||||
sdkVersions: '>=30.0.0',
|
||||
},
|
||||
{
|
||||
podName: 'EXNetwork',
|
||||
libName: 'expo-network',
|
||||
sdkVersions: '>=35.0.0',
|
||||
},
|
||||
{
|
||||
podName: 'EXNotifications',
|
||||
libName: 'expo-notifications',
|
||||
sdkVersions: '>=38.0.0',
|
||||
},
|
||||
{
|
||||
podName: 'EXPaymentsStripe',
|
||||
libName: 'expo-payments-stripe',
|
||||
sdkVersions: '>=30.0.0',
|
||||
detachable: false,
|
||||
config: {
|
||||
ios: {
|
||||
includeInExpoClient: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
podName: 'EXPermissions',
|
||||
libName: 'expo-permissions',
|
||||
sdkVersions: '>=29.0.0',
|
||||
},
|
||||
{
|
||||
podName: 'EXPermissionsInterface',
|
||||
libName: 'expo-permissions-interface',
|
||||
sdkVersions: '>=29.0.0 <33.0.0',
|
||||
},
|
||||
{
|
||||
podName: 'EXPrint',
|
||||
libName: 'expo-print',
|
||||
sdkVersions: '>=30.0.0',
|
||||
},
|
||||
{
|
||||
podName: 'EXRandom',
|
||||
libName: 'expo-random',
|
||||
sdkVersions: '>=33.0.0',
|
||||
},
|
||||
{
|
||||
podName: 'EXReactNativeAdapter',
|
||||
libName: 'expo-react-native-adapter',
|
||||
sdkVersions: '>=29.0.0 <33.0.0',
|
||||
},
|
||||
{
|
||||
podName: 'EXSecureStore',
|
||||
libName: 'expo-secure-store',
|
||||
sdkVersions: '>=33.0.0',
|
||||
},
|
||||
{
|
||||
podName: 'EXSensors',
|
||||
libName: 'expo-sensors',
|
||||
sdkVersions: '>=29.0.0',
|
||||
},
|
||||
{
|
||||
podName: 'EXSensorsInterface',
|
||||
libName: 'expo-sensors-interface',
|
||||
sdkVersions: '>=29.0.0 <33.0.0',
|
||||
},
|
||||
{
|
||||
podName: 'EXSharing',
|
||||
libName: 'expo-sharing',
|
||||
sdkVersions: '>=33.0.0',
|
||||
},
|
||||
{
|
||||
podName: 'EXSMS',
|
||||
libName: 'expo-sms',
|
||||
sdkVersions: '>=29.0.0',
|
||||
},
|
||||
{
|
||||
podName: 'EXSpeech',
|
||||
libName: 'expo-speech',
|
||||
sdkVersions: '>=33.0.0',
|
||||
},
|
||||
{
|
||||
podName: 'EXSQLite',
|
||||
libName: 'expo-sqlite',
|
||||
sdkVersions: '>=33.0.0',
|
||||
},
|
||||
{
|
||||
podName: 'EXTaskManager',
|
||||
libName: 'expo-task-manager',
|
||||
sdkVersions: '>=32.0.0',
|
||||
},
|
||||
{
|
||||
podName: 'EXTaskManagerInterface',
|
||||
libName: 'expo-task-manager-interface',
|
||||
sdkVersions: '>=32.0.0 <33.0.0',
|
||||
config: {
|
||||
android: {
|
||||
versionable: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
podName: 'EXVideoThumbnails',
|
||||
libName: 'expo-video-thumbnails',
|
||||
sdkVersions: '>=33.0.0',
|
||||
},
|
||||
{
|
||||
podName: 'EXWebBrowser',
|
||||
libName: 'expo-web-browser',
|
||||
sdkVersions: '>=33.0.0',
|
||||
},
|
||||
|
||||
// JS-only modules
|
||||
{
|
||||
libName: 'expo-asset',
|
||||
sdkVersions: '>=29.0.0',
|
||||
isNativeModule: false,
|
||||
},
|
||||
{
|
||||
libName: 'expo-module-template',
|
||||
sdkVersions: '>=29.0.0',
|
||||
isNativeModule: false,
|
||||
},
|
||||
{
|
||||
podName: 'UMCore',
|
||||
libName: '@unimodules/core',
|
||||
sdkVersions: '>=33.0.0',
|
||||
},
|
||||
{
|
||||
podName: 'UMReactNativeAdapter',
|
||||
libName: '@unimodules/react-native-adapter',
|
||||
sdkVersions: '>=33.0.0',
|
||||
},
|
||||
{
|
||||
podName: 'UMBarCodeScannerInterface',
|
||||
libName: 'unimodules-barcode-scanner-interface',
|
||||
sdkVersions: '>=33.0.0',
|
||||
},
|
||||
{
|
||||
podName: 'UMCameraInterface',
|
||||
libName: 'unimodules-camera-interface',
|
||||
sdkVersions: '>=33.0.0',
|
||||
},
|
||||
{
|
||||
podName: 'UMConstantsInterface',
|
||||
libName: 'unimodules-constants-interface',
|
||||
sdkVersions: '>=33.0.0',
|
||||
},
|
||||
{
|
||||
podName: 'UMFaceDetectorInterface',
|
||||
libName: 'unimodules-face-detector-interface',
|
||||
sdkVersions: '>=33.0.0',
|
||||
},
|
||||
{
|
||||
podName: 'UMFileSystemInterface',
|
||||
libName: 'unimodules-file-system-interface',
|
||||
sdkVersions: '>=33.0.0',
|
||||
},
|
||||
{
|
||||
podName: 'UMFontInterface',
|
||||
libName: 'unimodules-font-interface',
|
||||
sdkVersions: '>=33.0.0',
|
||||
},
|
||||
{
|
||||
podName: 'UMImageLoaderInterface',
|
||||
libName: 'unimodules-image-loader-interface',
|
||||
sdkVersions: '>=33.0.0',
|
||||
},
|
||||
{
|
||||
podName: 'UMPermissionsInterface',
|
||||
libName: 'unimodules-permissions-interface',
|
||||
sdkVersions: '>=33.0.0',
|
||||
},
|
||||
{
|
||||
podName: 'UMSensorsInterface',
|
||||
libName: 'unimodules-sensors-interface',
|
||||
sdkVersions: '>=33.0.0',
|
||||
},
|
||||
{
|
||||
podName: 'UMTaskManagerInterface',
|
||||
libName: 'unimodules-task-manager-interface',
|
||||
sdkVersions: '>=33.0.0',
|
||||
config: {
|
||||
android: {
|
||||
versionable: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export const vendoredNativeModules = [
|
||||
{
|
||||
libName: '@expo/vector-icons',
|
||||
sdkVersions: '>=26.0.0',
|
||||
isNativeModule: true,
|
||||
},
|
||||
{
|
||||
libName: '@react-native-community/netinfo',
|
||||
sdkVersions: '>=33.0.0',
|
||||
isNativeModule: true,
|
||||
},
|
||||
{
|
||||
libName: 'lottie-react-native',
|
||||
sdkVersions: '>=26.0.0',
|
||||
isNativeModule: true,
|
||||
},
|
||||
{
|
||||
libName: 'react-native-branch',
|
||||
sdkVersions: '>=26.0.0',
|
||||
isNativeModule: true,
|
||||
},
|
||||
{
|
||||
libName: 'react-native-gesture-handler',
|
||||
sdkVersions: '>=26.0.0',
|
||||
isNativeModule: true,
|
||||
},
|
||||
{
|
||||
libName: 'react-native-maps',
|
||||
sdkVersions: '>=26.0.0',
|
||||
isNativeModule: true,
|
||||
},
|
||||
{
|
||||
libName: 'react-native-reanimated',
|
||||
sdkVersions: '>=28.0.0',
|
||||
isNativeModule: true,
|
||||
},
|
||||
{
|
||||
libName: 'react-native-screens',
|
||||
sdkVersions: '>=30.0.0',
|
||||
isNativeModule: true,
|
||||
},
|
||||
{
|
||||
libName: 'react-native-svg',
|
||||
sdkVersions: '>=26.0.0',
|
||||
isNativeModule: true,
|
||||
},
|
||||
{
|
||||
libName: 'react-native-view-shot',
|
||||
sdkVersions: '>=26.0.0',
|
||||
isNativeModule: true,
|
||||
},
|
||||
{
|
||||
libName: 'react-native-webview',
|
||||
sdkVersions: '>=33.0.0',
|
||||
isNativeModule: true,
|
||||
},
|
||||
];
|
||||
|
||||
function defaults(
|
||||
defaultConfig: INativeConfig,
|
||||
...customConfigs: Array<Partial<INativeConfig> | undefined>
|
||||
) {
|
||||
return Object.assign({}, defaultConfig, ...customConfigs);
|
||||
}
|
||||
|
||||
export const expoSdkUniversalModulesConfigs: IModuleConfig[] = expoUniversalModules.map(
|
||||
({
|
||||
config = {},
|
||||
podName,
|
||||
libName,
|
||||
sdkVersions,
|
||||
isNativeModule = true,
|
||||
...params
|
||||
}) => {
|
||||
return {
|
||||
podName,
|
||||
libName,
|
||||
sdkVersions,
|
||||
isNativeModule,
|
||||
config: {
|
||||
ios: defaults(
|
||||
defaultUniversalModuleConfig.ios,
|
||||
params,
|
||||
'ios' in config ? config.ios : undefined,
|
||||
),
|
||||
android: defaults(
|
||||
defaultUniversalModuleConfig.android,
|
||||
params,
|
||||
'android' in config ? config.android : undefined,
|
||||
),
|
||||
},
|
||||
};
|
||||
},
|
||||
);
|
||||
Reference in new issue
Block a user