├── .github ├── ISSUE_TEMPLATE │ ├── Bug_report_Android.md │ ├── Bug_report_iOS.md │ └── Feature_request.md ├── dependabot.yml └── workflows │ └── ci.yml ├── .gitignore ├── .npmrc ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── assets └── android │ └── skins │ ├── LICENSE │ ├── README.md │ ├── nexus_5x │ ├── land_back.webp │ ├── land_fore.webp │ ├── land_shadow.webp │ ├── layout │ ├── port_back.webp │ ├── port_fore.webp │ └── port_shadow.webp │ ├── pixel │ ├── land_back.webp │ ├── land_fore.webp │ ├── land_shadow.webp │ ├── layout │ ├── port_back.webp │ ├── port_fore.webp │ └── port_shadow.webp │ ├── pixel_2 │ ├── land_back.webp │ ├── land_fore.webp │ ├── land_shadow.webp │ ├── layout │ ├── port_back.webp │ ├── port_fore.webp │ └── port_shadow.webp │ └── pixel_3 │ ├── layout │ ├── port_back.webp │ └── round_corners.webp ├── bin └── native-run ├── jest.config.js ├── package.json ├── src ├── android │ ├── help.ts │ ├── index.ts │ ├── list.ts │ ├── run.ts │ ├── sdk-info.ts │ └── utils │ │ ├── __tests__ │ │ ├── adb.ts │ │ ├── avd.ts │ │ ├── emulator.ts │ │ └── fixtures │ │ │ └── avd │ │ │ ├── Nexus_5X_API_24.avd │ │ │ └── config.ini │ │ │ ├── Nexus_5X_API_24.ini │ │ │ ├── Pixel_2_API_28.avd │ │ │ └── config.ini │ │ │ ├── Pixel_2_API_28.ini │ │ │ ├── Pixel_2_XL_API_28.avd │ │ │ └── config.ini │ │ │ ├── Pixel_2_XL_API_28.ini │ │ │ ├── Pixel_API_25.avd │ │ │ └── config.ini │ │ │ ├── Pixel_API_25.ini │ │ │ ├── README.md │ │ │ ├── avdmanager_1.avd │ │ │ └── config.ini │ │ │ └── avdmanager_1.ini │ │ ├── adb.ts │ │ ├── apk.ts │ │ ├── avd.ts │ │ ├── binary-xml-parser.ts │ │ ├── emulator.ts │ │ ├── list.ts │ │ ├── run.ts │ │ └── sdk │ │ ├── __tests__ │ │ ├── api.ts │ │ └── index.ts │ │ ├── api.ts │ │ ├── index.ts │ │ └── xml.ts ├── constants.ts ├── errors.ts ├── help.ts ├── index.ts ├── ios │ ├── help.ts │ ├── index.ts │ ├── lib │ │ ├── client │ │ │ ├── afc.ts │ │ │ ├── client.ts │ │ │ ├── debugserver.ts │ │ │ ├── index.ts │ │ │ ├── installation_proxy.ts │ │ │ ├── lockdownd.ts │ │ │ ├── mobile_image_mounter.ts │ │ │ └── usbmuxd.ts │ │ ├── index.ts │ │ ├── lib-errors.ts │ │ ├── manager.ts │ │ └── protocol │ │ │ ├── afc.ts │ │ │ ├── gdb.ts │ │ │ ├── index.ts │ │ │ ├── lockdown.ts │ │ │ ├── protocol.ts │ │ │ └── usbmux.ts │ ├── list.ts │ ├── run.ts │ └── utils │ │ ├── app.ts │ │ ├── device.ts │ │ ├── simulator.ts │ │ └── xcode.ts ├── list.ts └── utils │ ├── __tests__ │ ├── fn.ts │ └── object.ts │ ├── cli.ts │ ├── fn.ts │ ├── fs.ts │ ├── ini.ts │ ├── json.ts │ ├── list.ts │ ├── log.ts │ ├── object.ts │ ├── process.ts │ └── unzip.ts ├── tsconfig.json └── types └── bplist-parser.d.ts /.github/ISSUE_TEMPLATE/Bug_report_Android.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report for Android 3 | about: Unexpected or buggy behavior when using "native-run android" 4 | labels: android 5 | 6 | --- 7 | 8 | **Version:** 9 | 10 | 11 | 12 | 13 | **Description:** 14 | 15 | 16 | 17 | 18 | **Command Output:** 19 | 20 | 21 | 22 | 23 | **SDK Info:** 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/Bug_report_iOS.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report for iOS 3 | about: Unexpected or buggy behavior when using "native-run ios" 4 | labels: ios 5 | 6 | --- 7 | 8 | **Version:** 9 | 10 | 11 | 12 | 13 | **Description:** 14 | 15 | 16 | 17 | 18 | **Command Output:** 19 | 20 | 21 | 22 | 23 | **Environment Info:** 24 | 25 | 26 | 27 | macOS version: 28 | 29 | Xcode version: 30 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/Feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | 5 | --- 6 | 7 | 8 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: npm 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - develop 7 | - stable 8 | pull_request: 9 | types: 10 | - 'synchronize' 11 | - 'opened' 12 | branches: 13 | - '**' 14 | 15 | jobs: 16 | build-and-test: 17 | name: Build and Test (Node ${{ matrix.node }}) 18 | runs-on: ubuntu-latest 19 | timeout-minutes: 30 20 | strategy: 21 | matrix: 22 | node: 23 | - 20.x 24 | - 18.x 25 | - 16.x 26 | steps: 27 | - uses: actions/setup-node@v3 28 | with: 29 | node-version: ${{ matrix.node }} 30 | - uses: actions/checkout@v3 31 | - name: Restore Dependency Cache 32 | uses: actions/cache@v3 33 | with: 34 | path: ~/.npm 35 | key: ${{ runner.OS }}-dependency-cache-${{ hashFiles('**/package.json') }} 36 | - run: npm install 37 | - run: npm run lint 38 | - run: npm run build 39 | - run: npm test 40 | deploy: 41 | name: Deploy 42 | runs-on: ubuntu-latest 43 | if: ${{ github.ref == 'refs/heads/stable' }} 44 | timeout-minutes: 30 45 | needs: 46 | - build-and-test 47 | steps: 48 | - run: echo "//registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }}" >> ~/.npmrc 49 | - uses: actions/setup-node@v3 50 | with: 51 | node-version: 16.x 52 | - uses: actions/checkout@v3 53 | with: 54 | fetch-depth: 0 55 | - name: Restore Dependency Cache 56 | uses: actions/cache@v3 57 | with: 58 | path: ~/.npm 59 | key: ${{ runner.OS }}-dependency-cache-${{ hashFiles('**/package.json') }} 60 | - run: npm install 61 | - run: npm run publish:ci 62 | env: 63 | GH_TOKEN: ${{ secrets.IONITRON_GITHUB_TOKEN }} 64 | GIT_AUTHOR_NAME: Ionitron 65 | GIT_AUTHOR_EMAIL: hi@ionicframework.com 66 | GIT_COMMITTER_NAME: Ionitron 67 | GIT_COMMITTER_EMAIL: hi@ionicframework.com 68 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.swp 2 | dist 3 | node_modules 4 | .DS_Store 5 | .vscode 6 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [2.0.1](https://github.com/ionic-team/native-run/compare/v2.0.0...v2.0.1) (2024-01-25) 2 | 3 | 4 | ### Bug Fixes 5 | 6 | * paths contain non-ascii characters cause error ([#381](https://github.com/ionic-team/native-run/issues/381)) ([77b0481](https://github.com/ionic-team/native-run/commit/77b048164faa453f2b4c8c4a2dee15a671240777)) 7 | 8 | # [2.0.0](https://github.com/ionic-team/native-run/compare/v1.7.4...v2.0.0) (2023-10-26) 9 | 10 | 11 | ### Bug Fixes 12 | 13 | * typo in android variable logs ([#318](https://github.com/ionic-team/native-run/issues/318)) ([3d9251f](https://github.com/ionic-team/native-run/commit/3d9251f2f7f569e16cdbda58bb5304ac9385096b)) 14 | 15 | 16 | * chore!: support only node 16+ (#354) ([eb20983](https://github.com/ionic-team/native-run/commit/eb2098358ecf2a79af2a212dae7c74140dbffb09)), closes [#354](https://github.com/ionic-team/native-run/issues/354) 17 | * feat(android)!: Remove AVD creation on list (#349) ([2a310ff](https://github.com/ionic-team/native-run/commit/2a310ff7283b07806a7edc2bcf1177b8e333e85c)), closes [#349](https://github.com/ionic-team/native-run/issues/349) 18 | 19 | 20 | ### BREAKING CHANGES 21 | 22 | * drop node 10-14 support 23 | * removal of AVD creation feature 24 | 25 | ## [1.7.4](https://github.com/ionic-team/native-run/compare/v1.7.3...v1.7.4) (2023-10-24) 26 | 27 | 28 | ### Bug Fixes 29 | 30 | * xcode major version extraction ([#351](https://github.com/ionic-team/native-run/issues/351)) ([24ffe66](https://github.com/ionic-team/native-run/commit/24ffe6654458d1aeb4944f9ceb9ddb402c39f651)) 31 | 32 | ## [1.7.3](https://github.com/ionic-team/native-run/compare/v1.7.2...v1.7.3) (2023-09-21) 33 | 34 | 35 | ### Bug Fixes 36 | 37 | * Pin @types/babel__traverse version to fix build ([#342](https://github.com/ionic-team/native-run/issues/342)) ([8492b24](https://github.com/ionic-team/native-run/commit/8492b2454466bbee2f843dd09ec6231d608a5ad5)) 38 | * use devicectl for iOS 17 if Xcode 15 is available ([#341](https://github.com/ionic-team/native-run/issues/341)) ([5c56d71](https://github.com/ionic-team/native-run/commit/5c56d712fdeb351b01d75edeb2bd2c9106e29f35)) 39 | 40 | ## [1.7.2](https://github.com/ionic-team/native-run/compare/v1.7.1...v1.7.2) (2023-03-02) 41 | 42 | 43 | ### Bug Fixes 44 | 45 | * NodeJS 18 compat ([#275](https://github.com/ionic-team/native-run/issues/275)) ([45050bb](https://github.com/ionic-team/native-run/commit/45050bbd416692e0911fe73a67d789b205e48ecc)) 46 | 47 | ## [1.7.1](https://github.com/ionic-team/native-run/compare/v1.7.0...v1.7.1) (2022-09-23) 48 | 49 | 50 | ### Bug Fixes 51 | 52 | * **android:** Use arm64 architecture on emulator creation from M1 ([#258](https://github.com/ionic-team/native-run/issues/258)) ([a6501c4](https://github.com/ionic-team/native-run/commit/a6501c419834f08bfd2be9ef00d0a6b1f4ee5f8a)) 53 | 54 | # [1.7.0](https://github.com/ionic-team/native-run/compare/v1.6.0...v1.7.0) (2022-09-01) 55 | 56 | 57 | ### Features 58 | 59 | * **android:** add API 33 support ([#245](https://github.com/ionic-team/native-run/issues/245)) ([8f1717d](https://github.com/ionic-team/native-run/commit/8f1717d1a54eaf9f45688c0c243f2085abd61421)) 60 | 61 | # [1.6.0](https://github.com/ionic-team/native-run/compare/v1.5.0...v1.6.0) (2022-05-16) 62 | 63 | 64 | ### Features 65 | 66 | * **android:** add API 32 support ([#232](https://github.com/ionic-team/native-run/issues/232)) ([bc48231](https://github.com/ionic-team/native-run/commit/bc482319c77c79c0da77b769865ab070c68efb67)) 67 | 68 | # [1.5.0](https://github.com/ionic-team/native-run/compare/v1.4.1...v1.5.0) (2021-10-11) 69 | 70 | 71 | ### Bug Fixes 72 | 73 | * Throw ERR_UNSUPPORTED_API_LEVEL ([#204](https://github.com/ionic-team/native-run/issues/204)) ([b879744](https://github.com/ionic-team/native-run/commit/b879744c81bbc6b73c6a1a97064dcc51818b2fa5)) 74 | 75 | 76 | ### Features 77 | 78 | * **android:** support API 31 ([#203](https://github.com/ionic-team/native-run/issues/203)) ([fb64ca5](https://github.com/ionic-team/native-run/commit/fb64ca5165cad0fe029ef81e9c8ba11ca36bc08c)) 79 | 80 | ## [1.4.1](https://github.com/ionic-team/native-run/compare/v1.4.0...v1.4.1) (2021-09-03) 81 | 82 | 83 | ### Bug Fixes 84 | 85 | * throw iOS errors that are not DeviceLocked ([#200](https://github.com/ionic-team/native-run/issues/200)) ([3ac6914](https://github.com/ionic-team/native-run/commit/3ac6914d7f9672fada40e80a2b0a9bd156e27db0)) 86 | 87 | # [1.4.0](https://github.com/ionic-team/native-run/compare/v1.3.1...v1.4.0) (2021-06-08) 88 | 89 | 90 | ### Features 91 | 92 | * **android:** support API 30 ([#186](https://github.com/ionic-team/native-run/issues/186)) ([e90aa32](https://github.com/ionic-team/native-run/commit/e90aa328666a353a015e47153b03f6896877890a)) 93 | 94 | ## [1.3.1](https://github.com/ionic-team/native-run/compare/v1.3.0...v1.3.1) (2021-06-04) 95 | 96 | 97 | ### Bug Fixes 98 | 99 | * don't print retry message for --json ([#184](https://github.com/ionic-team/native-run/issues/184)) ([4fb0563](https://github.com/ionic-team/native-run/commit/4fb0563d96435482066a270595b7f3393a0e6b42)) 100 | 101 | # [1.3.0](https://github.com/ionic-team/native-run/compare/v1.2.2...v1.3.0) (2020-12-07) 102 | 103 | 104 | ### Features 105 | 106 | * **iOS:** Add 5 second retry on DeviceLocked for iOS ([#167](https://github.com/ionic-team/native-run/issues/167)) ([f451e46](https://github.com/ionic-team/native-run/commit/f451e46a7f4d05c27baa641530d00f1301e2bfd5)) 107 | 108 | ## [1.2.2](https://github.com/ionic-team/native-run/compare/v1.2.1...v1.2.2) (2020-10-16) 109 | 110 | 111 | ### Bug Fixes 112 | 113 | * **android:** don't print ADB unresponsive error to stdout ([#163](https://github.com/ionic-team/native-run/issues/163)) ([2cd894b](https://github.com/ionic-team/native-run/commit/2cd894ba2341937f19825cb0865dd885acb01ace)) 114 | 115 | ## [1.2.1](https://github.com/ionic-team/native-run/compare/v1.2.0...v1.2.1) (2020-09-29) 116 | 117 | 118 | ### Bug Fixes 119 | 120 | * add missing signal-exit dependency ([#158](https://github.com/ionic-team/native-run/issues/158)) ([18743e0](https://github.com/ionic-team/native-run/commit/18743e0d48212f503393b47a21ced9905a24fcea)) 121 | 122 | # [1.2.0](https://github.com/ionic-team/native-run/compare/v1.1.0...v1.2.0) (2020-09-28) 123 | 124 | 125 | ### Bug Fixes 126 | 127 | * **iOS:** implement iOS 14 compatibility ([#157](https://github.com/ionic-team/native-run/issues/157)) ([6f242fd](https://github.com/ionic-team/native-run/commit/6f242fd9aa1dea2cd96db13f21b981b21953f3ea)) 128 | 129 | 130 | ### Features 131 | 132 | * **android:** gracefully handle when device is offline ([aa6688d](https://github.com/ionic-team/native-run/commit/aa6688d257127c5cf6b24279a6eb506cf5b8c258)) 133 | * **android:** gracefully handle when device is out of space ([9da9f59](https://github.com/ionic-team/native-run/commit/9da9f5968cebdc7887230f3085dfd7c2d5a4f3ec)) 134 | * **android:** handle INSTALL_FAILED_INSUFFICIENT_STORAGE adb error ([bcf2369](https://github.com/ionic-team/native-run/commit/bcf2369b51e6afcd3230eb68db965fe2a89300e1)) 135 | * **android:** kill unresponsive adb server after 5s and retry ([9e1bbc7](https://github.com/ionic-team/native-run/commit/9e1bbc7d636a266ed472e6b43553781bc7e90896)) 136 | * **list:** show model, then ID if no name ([d56415d](https://github.com/ionic-team/native-run/commit/d56415d00c68ce288d6575ebf4cb0386f6070801)) 137 | * columnize `--list` output ([5b7da72](https://github.com/ionic-team/native-run/commit/5b7da7235c23b01185d8317bf5e4cdad878a9845)) 138 | 139 | # [1.1.0](https://github.com/ionic-team/native-run/compare/v1.0.0...v1.1.0) (2020-09-10) 140 | 141 | 142 | ### Bug Fixes 143 | 144 | * **ios:** do not falsely link to Android Wiki for iOS errors ([18371f2](https://github.com/ionic-team/native-run/commit/18371f296fb8a3cb0ab070f2c5316f98e9351263)) 145 | 146 | 147 | ### Features 148 | 149 | * **android:** create AVD home if not found ([1cec3c2](https://github.com/ionic-team/native-run/commit/1cec3c258b26c876bf12f8d823ef270faa4a6a78)) 150 | 151 | # [1.0.0](https://github.com/ionic-team/native-run/compare/v0.3.0...v1.0.0) (2020-04-02) 152 | 153 | 154 | ### chore 155 | 156 | * require Node 10 ([430d23a](https://github.com/ionic-team/native-run/commit/430d23ac5dfb4f5c0ab059e923839a6bd7d523d4)) 157 | 158 | 159 | ### Features 160 | 161 | * **android:** handle adb error re: improper signing ([829585f](https://github.com/ionic-team/native-run/commit/829585f82cab311f5ceee84369ccdac2b327d744)) 162 | * **android:** show link to online help docs for errors ([0bc4487](https://github.com/ionic-team/native-run/commit/0bc448715af72ba7febee4f8f3e5b008cd489f16)) 163 | 164 | 165 | ### BREAKING CHANGES 166 | 167 | * A minimum of Node.js 10.3.0 is required. 168 | 169 | # [0.3.0](https://github.com/ionic-team/native-run/compare/v0.2.9...v0.3.0) (2019-12-04) 170 | 171 | 172 | ### Features 173 | 174 | * **android:** handle INSTALL_FAILED_OLDER_SDK adb error ([#92](https://github.com/ionic-team/native-run/issues/92)) ([6616f37](https://github.com/ionic-team/native-run/commit/6616f379a60797650709ba7a70f195558ddcdedd)) 175 | * **android:** support API 29 ([2282b3a](https://github.com/ionic-team/native-run/commit/2282b3acfa58da685b0dc1981cf602a781bd6a1a)) 176 | 177 | ## [0.2.9](https://github.com/ionic-team/native-run/compare/v0.2.8...v0.2.9) (2019-10-15) 178 | 179 | 180 | ### Bug Fixes 181 | 182 | * **ios:** added support for iOS 13 ([c27675f](https://github.com/ionic-team/native-run/commit/c27675f20ef40264837af5cf091e94bd1af2db91)) 183 | 184 | ## [0.2.8](https://github.com/ionic-team/native-run/compare/v0.2.7...v0.2.8) (2019-07-12) 185 | 186 | 187 | ### Bug Fixes 188 | 189 | * **list:** include errors in standard output ([9ceb343](https://github.com/ionic-team/native-run/commit/9ceb343)) 190 | 191 | ## [0.2.7](https://github.com/ionic-team/native-run/compare/v0.2.6...v0.2.7) (2019-06-25) 192 | 193 | 194 | ### Bug Fixes 195 | 196 | * **android:** more accurate device/emulator detection ([5ec454b](https://github.com/ionic-team/native-run/commit/5ec454b)) 197 | * **list:** handle errors with devices/virtual devices ([9c2375d](https://github.com/ionic-team/native-run/commit/9c2375d)) 198 | 199 | ## [0.2.6](https://github.com/ionic-team/native-run/compare/v0.2.5...v0.2.6) (2019-06-17) 200 | 201 | 202 | ### Bug Fixes 203 | 204 | * **ios:** support old simctl runtime output format ([aa73578](https://github.com/ionic-team/native-run/commit/aa73578)) 205 | 206 | ## [0.2.5](https://github.com/ionic-team/native-run/compare/v0.2.4...v0.2.5) (2019-06-10) 207 | 208 | 209 | ### Bug Fixes 210 | 211 | * **android:** fix path issue for windows ([9b87583](https://github.com/ionic-team/native-run/commit/9b87583)) 212 | 213 | ## [0.2.4](https://github.com/ionic-team/native-run/compare/v0.2.3...v0.2.4) (2019-06-07) 214 | 215 | 216 | ### Bug Fixes 217 | 218 | * **android:** log errors during sdk walk, don't throw ([ea2e0c5](https://github.com/ionic-team/native-run/commit/ea2e0c5)) 219 | 220 | ## [0.2.3](https://github.com/ionic-team/native-run/compare/v0.2.2...v0.2.3) (2019-06-05) 221 | 222 | 223 | ### Bug Fixes 224 | 225 | * **ios:** fix getSimulators for Xcode 10+ tooling ([605164a](https://github.com/ionic-team/native-run/commit/605164a)) 226 | * **ios:** improve getSimulators error messaging ([86205d6](https://github.com/ionic-team/native-run/commit/86205d6)) 227 | 228 | ## [0.2.2](https://github.com/ionic-team/native-run/compare/v0.2.1...v0.2.2) (2019-05-31) 229 | 230 | 231 | ### Bug Fixes 232 | 233 | * **android:** handle devices connected over tcp/ip ([4869f4a](https://github.com/ionic-team/native-run/commit/4869f4a)) 234 | 235 | ## [0.2.1](https://github.com/ionic-team/native-run/compare/v0.2.0...v0.2.1) (2019-05-30) 236 | 237 | 238 | ### Bug Fixes 239 | 240 | * **android:** handle \r\n for adb output on Windows during --list ([50bfa73](https://github.com/ionic-team/native-run/commit/50bfa73)) 241 | 242 | # [0.2.0](https://github.com/ionic-team/native-run/compare/v0.1.2...v0.2.0) (2019-05-30) 243 | 244 | 245 | ### Bug Fixes 246 | 247 | * **ios:** log iOS --list errors, but still print ([e516a83](https://github.com/ionic-team/native-run/commit/e516a83)) 248 | * **ios:** print more helpful error if app path doesn't exist ([49819b0](https://github.com/ionic-team/native-run/commit/49819b0)) 249 | 250 | 251 | ### Features 252 | 253 | * **android:** better error messaging ([0cfa51a](https://github.com/ionic-team/native-run/commit/0cfa51a)) 254 | * **android:** have --forward accept multiple values ([#26](https://github.com/ionic-team/native-run/issues/26)) ([7844ea4](https://github.com/ionic-team/native-run/commit/7844ea4)) 255 | 256 | ## [0.1.2](https://github.com/ionic-team/native-run/compare/v0.1.1...v0.1.2) (2019-05-29) 257 | 258 | 259 | ### Bug Fixes 260 | 261 | * **android:** catch api issues for --list ([9453f2c](https://github.com/ionic-team/native-run/commit/9453f2c)) 262 | 263 | ## [0.1.1](https://github.com/ionic-team/native-run/compare/v0.1.0...v0.1.1) (2019-05-29) 264 | 265 | 266 | ### Bug Fixes 267 | 268 | * **list:** add heading for each platform ([203d7b6](https://github.com/ionic-team/native-run/commit/203d7b6)) 269 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Pull requests are welcome! 4 | 5 | To begin, clone the repo, then install dependencies and setup the project. 6 | 7 | ``` 8 | npm install 9 | npm run setup 10 | ``` 11 | 12 | The source code is written in TypeScript. Spin up the compiler to watch for source changes: 13 | 14 | ``` 15 | npm run watch 16 | ``` 17 | 18 | ## Publishing 19 | 20 | CI automatically publishes the next version semantically from analyzing commits in `stable`. To maintain a shared history between `develop` and `stable`, the branches must be rebased with each other locally. 21 | 22 | * When it's time to cut a release from `develop`: 23 | 24 | ``` 25 | git checkout stable 26 | git merge --ff-only develop 27 | git push origin stable 28 | ``` 29 | 30 | * Await successful publish in CI. Ionitron will push the updated versions and tags to `stable`. 31 | * Sync `develop` with `stable`. 32 | 33 | ``` 34 | git pull origin stable 35 | git checkout develop 36 | git merge --ff-only stable 37 | git push origin develop 38 | ``` 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2018 Drifty Co 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 9 | of the Software, and to permit persons to whom the Software is furnished to do 10 | so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![github-actions](https://img.shields.io/github/actions/workflow/status/ionic-team/native-run/ci.yml?branch=develop&style=flat-square)](https://github.com/ionic-team/native-run/actions?query=workflow%3ACI) 2 | [![semantic-release](https://img.shields.io/badge/%20%20%F0%9F%93%A6%F0%9F%9A%80-semantic--release-e10079.svg?style=flat-square)](https://github.com/semantic-release/semantic-release) 3 | [![npm](https://img.shields.io/npm/v/native-run.svg?style=flat-square)](https://www.npmjs.com/package/native-run) 4 | 5 | # native-run 6 | 7 | `native-run` is a cross-platform command-line utility for running native app binaries (`.ipa` and `.apk` files) on iOS and Android devices. It can be used for both hardware and virtual devices. 8 | 9 | This tool is used by the Ionic CLI, but it can be used standalone as part of a development or testing pipeline for launching apps. It doesn't matter whether the `.apk` or `.ipa` is created with Cordova or native IDEs, `native-run` will be able to deploy it. 10 | 11 | ## Install 12 | 13 | `native-run` is written entirely in TypeScript/NodeJS, so there are no native dependencies. 14 | 15 | To install, run: 16 | 17 | ``` 18 | npm install -g native-run 19 | ``` 20 | 21 | :memo: Requires NodeJS 16+ 22 | 23 | ## Usage 24 | 25 | ``` 26 | native-run [options] 27 | ``` 28 | 29 | See the help documentation with the `--help` flag. 30 | 31 | ``` 32 | native-run --help 33 | native-run ios --help 34 | native-run android --help 35 | ``` 36 | 37 | ### Troubleshooting 38 | 39 | Much more information can be printed to the screen with the `--verbose` flag. 40 | -------------------------------------------------------------------------------- /assets/android/skins/LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (C) 2013 The Android Open Source Project Licensed under the Apache 2 | License, Version 2.0 (the "License"); you may not use this file except in 3 | compliance with the License. You may obtain a copy of the License at 4 | 5 | http://www.apache.org/licenses/LICENSE-2.0 6 | 7 | Unless required by applicable law or agreed to in writing, software distributed 8 | under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR 9 | CONDITIONS OF ANY KIND, either express or implied. See the License for the 10 | specific language governing permissions and limitations under the License. 11 | -------------------------------------------------------------------------------- /assets/android/skins/README.md: -------------------------------------------------------------------------------- 1 | # Android Skins 2 | 3 | These skins are copied from the Android Plugin for IntelliJ IDEA: 4 | https://github.com/JetBrains/android/tree/master/artwork/resources/device-art-resources 5 | -------------------------------------------------------------------------------- /assets/android/skins/nexus_5x/land_back.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ionic-team/native-run/e9f573f3b6295c3b3384967c47224e1244e3995b/assets/android/skins/nexus_5x/land_back.webp -------------------------------------------------------------------------------- /assets/android/skins/nexus_5x/land_fore.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ionic-team/native-run/e9f573f3b6295c3b3384967c47224e1244e3995b/assets/android/skins/nexus_5x/land_fore.webp -------------------------------------------------------------------------------- /assets/android/skins/nexus_5x/land_shadow.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ionic-team/native-run/e9f573f3b6295c3b3384967c47224e1244e3995b/assets/android/skins/nexus_5x/land_shadow.webp -------------------------------------------------------------------------------- /assets/android/skins/nexus_5x/layout: -------------------------------------------------------------------------------- 1 | parts { 2 | device { 3 | display { 4 | width 1080 5 | height 1920 6 | x 0 7 | y 0 8 | } 9 | } 10 | portrait { 11 | background { 12 | image port_back.webp 13 | } 14 | onion { 15 | image port_fore.webp 16 | } 17 | } 18 | landscape { 19 | background { 20 | image land_back.webp 21 | } 22 | onion { 23 | image land_fore.webp 24 | } 25 | } 26 | } 27 | layouts { 28 | portrait { 29 | width 1370 30 | height 2446 31 | event EV_SW:0:1 32 | part1 { 33 | name portrait 34 | x 0 35 | y 0 36 | } 37 | part2 { 38 | name device 39 | x 147 40 | y 233 41 | } 42 | } 43 | landscape { 44 | width 2497 45 | height 1234 46 | event EV_SW:0:0 47 | part1 { 48 | name landscape 49 | x 0 50 | y 0 51 | } 52 | part2 { 53 | name device 54 | x 278 55 | y 1143 56 | rotation 3 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /assets/android/skins/nexus_5x/port_back.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ionic-team/native-run/e9f573f3b6295c3b3384967c47224e1244e3995b/assets/android/skins/nexus_5x/port_back.webp -------------------------------------------------------------------------------- /assets/android/skins/nexus_5x/port_fore.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ionic-team/native-run/e9f573f3b6295c3b3384967c47224e1244e3995b/assets/android/skins/nexus_5x/port_fore.webp -------------------------------------------------------------------------------- /assets/android/skins/nexus_5x/port_shadow.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ionic-team/native-run/e9f573f3b6295c3b3384967c47224e1244e3995b/assets/android/skins/nexus_5x/port_shadow.webp -------------------------------------------------------------------------------- /assets/android/skins/pixel/land_back.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ionic-team/native-run/e9f573f3b6295c3b3384967c47224e1244e3995b/assets/android/skins/pixel/land_back.webp -------------------------------------------------------------------------------- /assets/android/skins/pixel/land_fore.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ionic-team/native-run/e9f573f3b6295c3b3384967c47224e1244e3995b/assets/android/skins/pixel/land_fore.webp -------------------------------------------------------------------------------- /assets/android/skins/pixel/land_shadow.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ionic-team/native-run/e9f573f3b6295c3b3384967c47224e1244e3995b/assets/android/skins/pixel/land_shadow.webp -------------------------------------------------------------------------------- /assets/android/skins/pixel/layout: -------------------------------------------------------------------------------- 1 | parts { 2 | device { 3 | display { 4 | width 1080 5 | height 1920 6 | x 0 7 | y 0 8 | } 9 | } 10 | portrait { 11 | background { 12 | image port_back.webp 13 | } 14 | onion { 15 | image port_fore.webp 16 | } 17 | } 18 | landscape { 19 | background { 20 | image land_back.webp 21 | } 22 | onion { 23 | image land_fore.webp 24 | } 25 | } 26 | } 27 | layouts { 28 | portrait { 29 | width 1370 30 | height 2534 31 | event EV_SW:0:1 32 | part1 { 33 | name portrait 34 | x 0 35 | y 0 36 | } 37 | part2 { 38 | name device 39 | x 139 40 | y 285 41 | } 42 | } 43 | landscape { 44 | width 2596 45 | height 1258 46 | event EV_SW:0:0 47 | part1 { 48 | name landscape 49 | x 0 50 | y 0 51 | } 52 | part2 { 53 | name device 54 | x 338 55 | y 1158 56 | rotation 3 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /assets/android/skins/pixel/port_back.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ionic-team/native-run/e9f573f3b6295c3b3384967c47224e1244e3995b/assets/android/skins/pixel/port_back.webp -------------------------------------------------------------------------------- /assets/android/skins/pixel/port_fore.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ionic-team/native-run/e9f573f3b6295c3b3384967c47224e1244e3995b/assets/android/skins/pixel/port_fore.webp -------------------------------------------------------------------------------- /assets/android/skins/pixel/port_shadow.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ionic-team/native-run/e9f573f3b6295c3b3384967c47224e1244e3995b/assets/android/skins/pixel/port_shadow.webp -------------------------------------------------------------------------------- /assets/android/skins/pixel_2/land_back.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ionic-team/native-run/e9f573f3b6295c3b3384967c47224e1244e3995b/assets/android/skins/pixel_2/land_back.webp -------------------------------------------------------------------------------- /assets/android/skins/pixel_2/land_fore.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ionic-team/native-run/e9f573f3b6295c3b3384967c47224e1244e3995b/assets/android/skins/pixel_2/land_fore.webp -------------------------------------------------------------------------------- /assets/android/skins/pixel_2/land_shadow.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ionic-team/native-run/e9f573f3b6295c3b3384967c47224e1244e3995b/assets/android/skins/pixel_2/land_shadow.webp -------------------------------------------------------------------------------- /assets/android/skins/pixel_2/layout: -------------------------------------------------------------------------------- 1 | parts { 2 | device { 3 | display { 4 | width 1080 5 | height 1920 6 | x 0 7 | y 0 8 | } 9 | } 10 | portrait { 11 | background { 12 | image port_back.webp 13 | } 14 | onion { 15 | image port_fore.webp 16 | } 17 | } 18 | landscape { 19 | background { 20 | image land_back.webp 21 | } 22 | onion { 23 | image land_fore.webp 24 | } 25 | } 26 | } 27 | layouts { 28 | portrait { 29 | width 1370 30 | height 2534 31 | event EV_SW:0:1 32 | part1 { 33 | name portrait 34 | x 0 35 | y 0 36 | } 37 | part2 { 38 | name device 39 | x 140 40 | y 280 41 | } 42 | } 43 | landscape { 44 | width 2596 45 | height 1258 46 | event EV_SW:0:0 47 | part1 { 48 | name landscape 49 | x 0 50 | y 0 51 | } 52 | part2 { 53 | name device 54 | x 338 55 | y 68 56 | rotation 3 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /assets/android/skins/pixel_2/port_back.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ionic-team/native-run/e9f573f3b6295c3b3384967c47224e1244e3995b/assets/android/skins/pixel_2/port_back.webp -------------------------------------------------------------------------------- /assets/android/skins/pixel_2/port_fore.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ionic-team/native-run/e9f573f3b6295c3b3384967c47224e1244e3995b/assets/android/skins/pixel_2/port_fore.webp -------------------------------------------------------------------------------- /assets/android/skins/pixel_2/port_shadow.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ionic-team/native-run/e9f573f3b6295c3b3384967c47224e1244e3995b/assets/android/skins/pixel_2/port_shadow.webp -------------------------------------------------------------------------------- /assets/android/skins/pixel_3/layout: -------------------------------------------------------------------------------- 1 | parts { 2 | device { 3 | display { 4 | width 1080 5 | height 2160 6 | x 0 7 | y 0 8 | } 9 | } 10 | portrait { 11 | background { 12 | image port_back.webp 13 | } 14 | foreground { 15 | mask round_corners.webp 16 | } 17 | onion { 18 | image port_fore.webp 19 | } 20 | } 21 | } 22 | layouts { 23 | portrait { 24 | width 1194 25 | height 2532 26 | event EV_SW:0:1 27 | part1 { 28 | name portrait 29 | x 0 30 | y 0 31 | } 32 | part2 { 33 | name device 34 | x 54 35 | y 196 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /assets/android/skins/pixel_3/port_back.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ionic-team/native-run/e9f573f3b6295c3b3384967c47224e1244e3995b/assets/android/skins/pixel_3/port_back.webp -------------------------------------------------------------------------------- /assets/android/skins/pixel_3/round_corners.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ionic-team/native-run/e9f573f3b6295c3b3384967c47224e1244e3995b/assets/android/skins/pixel_3/round_corners.webp -------------------------------------------------------------------------------- /bin/native-run: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | 'use strict'; 4 | 5 | process.title = 'native-run'; 6 | 7 | if (process.argv.includes('--verbose')) { 8 | process.env.DEBUG = '*'; 9 | } 10 | 11 | require('../').run(); 12 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | globals: { 4 | 'ts-jest': { 5 | diagnostics: { 6 | // warnOnly: true, 7 | }, 8 | tsconfig: { 9 | types: [ 10 | "node", 11 | "jest", 12 | ], 13 | }, 14 | }, 15 | }, 16 | }; 17 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "native-run", 3 | "version": "2.0.1", 4 | "description": "A CLI for running apps on iOS/Android devices and simulators/emulators", 5 | "bin": { 6 | "native-run": "bin/native-run" 7 | }, 8 | "scripts": { 9 | "clean": "rm -rf dist", 10 | "build": "npm run clean && tsc", 11 | "watch": "tsc -w", 12 | "test": "jest --maxWorkers=4", 13 | "lint": "npm run eslint && npm run prettier -- --check", 14 | "fmt": "npm run eslint -- --fix && npm run prettier -- --write", 15 | "prettier": "prettier \"**/*.ts\"", 16 | "eslint": "eslint . --ext .ts", 17 | "publish:ci": "semantic-release", 18 | "publish:testing": "npm version prerelease --preid=testing --no-git-tag-version && npm publish --tag=testing && git stash", 19 | "prepublishOnly": "npm run build" 20 | }, 21 | "main": "dist/index.js", 22 | "files": [ 23 | "assets", 24 | "bin", 25 | "dist" 26 | ], 27 | "engines": { 28 | "node": ">=16.0.0" 29 | }, 30 | "repository": { 31 | "type": "git", 32 | "url": "git+https://github.com/ionic-team/native-run.git" 33 | }, 34 | "dependencies": { 35 | "@ionic/utils-fs": "^3.1.7", 36 | "@ionic/utils-terminal": "^2.3.4", 37 | "bplist-parser": "^0.3.2", 38 | "debug": "^4.3.4", 39 | "elementtree": "^0.1.7", 40 | "ini": "^4.1.1", 41 | "plist": "^3.1.0", 42 | "split2": "^4.2.0", 43 | "through2": "^4.0.2", 44 | "tslib": "^2.6.2", 45 | "yauzl": "^2.10.0" 46 | }, 47 | "devDependencies": { 48 | "@ionic/eslint-config": "^0.4.0", 49 | "@ionic/prettier-config": "^4.0.0", 50 | "@semantic-release/changelog": "^6.0.3", 51 | "@semantic-release/git": "^10.0.1", 52 | "@types/debug": "^4.1.10", 53 | "@types/elementtree": "^0.1.3", 54 | "@types/ini": "^1.3.32", 55 | "@types/jest": "^26.0.13", 56 | "@types/node": "^16.0.0", 57 | "@types/plist": "^3.0.4", 58 | "@types/split2": "^4.2.2", 59 | "@types/through2": "^2.0.40", 60 | "@types/yauzl": "^2.10.2", 61 | "eslint": "^8.57.0", 62 | "jest": "^26.4.2", 63 | "prettier": "^3.0.3", 64 | "semantic-release": "^19.0.5", 65 | "ts-jest": "^26.3.0", 66 | "typescript": "~4.9.5" 67 | }, 68 | "prettier": "@ionic/prettier-config", 69 | "eslintConfig": { 70 | "extends": "@ionic/eslint-config/recommended", 71 | "rules": { 72 | "@typescript-eslint/explicit-module-boundary-types": [ 73 | "warn", 74 | { 75 | "allowArgumentsExplicitlyTypedAsAny": true 76 | } 77 | ] 78 | } 79 | }, 80 | "release": { 81 | "branches": "stable", 82 | "plugins": [ 83 | "@semantic-release/commit-analyzer", 84 | "@semantic-release/release-notes-generator", 85 | "@semantic-release/changelog", 86 | "@semantic-release/npm", 87 | "@semantic-release/github", 88 | "@semantic-release/git" 89 | ] 90 | }, 91 | "keywords": [ 92 | "android", 93 | "ios", 94 | "cli", 95 | "mobile", 96 | "app", 97 | "hybrid", 98 | "native" 99 | ], 100 | "author": "Ionic Team (https://ionicframework.com)", 101 | "license": "MIT", 102 | "bugs": { 103 | "url": "https://github.com/ionic-team/native-run/issues" 104 | }, 105 | "homepage": "https://github.com/ionic-team/native-run#readme" 106 | } 107 | -------------------------------------------------------------------------------- /src/android/help.ts: -------------------------------------------------------------------------------- 1 | const help = ` 2 | Usage: native-run android [options] 3 | 4 | Run an .apk on a device or emulator target 5 | 6 | Targets are selected as follows: 7 | 1) --target using device/emulator serial number or AVD ID 8 | 2) A connected device, unless --virtual is used 9 | 3) A running emulator 10 | 11 | If the above criteria are not met, an emulator is started from a default 12 | AVD, which is created if it does not exist. 13 | 14 | Use --list to list available targets. 15 | 16 | Options: 17 | 18 | --list .................. Print available targets, then quit 19 | --sdk-info .............. Print SDK information, then quit 20 | --json .................. Output JSON 21 | 22 | 23 | --app ............ Deploy specified .apk file 24 | --device ................ Use a device if available 25 | With --list prints connected devices 26 | --virtual ............... Prefer an emulator 27 | With --list prints available emulators 28 | --target ........... Use a specific target 29 | --connect ............... Tie process to app process 30 | --forward ... Forward a port from device to host 31 | `; 32 | 33 | export async function run(args: readonly string[]): Promise { 34 | process.stdout.write(`${help}\n`); 35 | } 36 | -------------------------------------------------------------------------------- /src/android/index.ts: -------------------------------------------------------------------------------- 1 | import type { Command } from '../'; 2 | 3 | export async function run(args: readonly string[]): Promise { 4 | let cmd: Command; 5 | 6 | if (args.includes('--help') || args.includes('-h')) { 7 | cmd = await import('./help'); 8 | return cmd.run(args); 9 | } 10 | 11 | if (args.includes('--list')) { 12 | cmd = await import('./list'); 13 | return cmd.run(args); 14 | } 15 | 16 | if (args.includes('--sdk-info')) { 17 | cmd = await import('./sdk-info'); 18 | return cmd.run(args); 19 | } 20 | 21 | cmd = await import('./run'); 22 | await cmd.run(args); 23 | } 24 | -------------------------------------------------------------------------------- /src/android/list.ts: -------------------------------------------------------------------------------- 1 | import type { Exception } from '../errors'; 2 | import type { Targets } from '../utils/list'; 3 | import { formatTargets } from '../utils/list'; 4 | 5 | import { getDeviceTargets, getVirtualTargets } from './utils/list'; 6 | import { getSDK } from './utils/sdk'; 7 | 8 | export async function run(args: readonly string[]): Promise { 9 | const targets = await list(args); 10 | process.stdout.write(`\n${formatTargets(args, targets)}\n`); 11 | } 12 | 13 | export async function list(args: readonly string[]): Promise { 14 | const sdk = await getSDK(); 15 | const errors: Exception[] = []; 16 | const [devices, virtualDevices] = await Promise.all([ 17 | (async () => { 18 | try { 19 | return await getDeviceTargets(sdk); 20 | } catch (e: any) { 21 | errors.push(e); 22 | return []; 23 | } 24 | })(), 25 | (async () => { 26 | try { 27 | return await getVirtualTargets(sdk); 28 | } catch (e: any) { 29 | errors.push(e); 30 | return []; 31 | } 32 | })(), 33 | ]); 34 | 35 | return { devices, virtualDevices, errors }; 36 | } 37 | -------------------------------------------------------------------------------- /src/android/run.ts: -------------------------------------------------------------------------------- 1 | import * as Debug from 'debug'; 2 | 3 | import { 4 | AVDException, 5 | AndroidRunException, 6 | CLIException, 7 | ERR_BAD_INPUT, 8 | ERR_NO_DEVICE, 9 | ERR_NO_TARGET, 10 | ERR_TARGET_NOT_FOUND, 11 | ERR_UNSUITABLE_API_INSTALLATION, 12 | } from '../errors'; 13 | import { getOptionValue, getOptionValues } from '../utils/cli'; 14 | import { log } from '../utils/log'; 15 | import { onBeforeExit } from '../utils/process'; 16 | 17 | import type { Device, Ports } from './utils/adb'; 18 | import { 19 | closeApp, 20 | forwardPorts, 21 | getDevices, 22 | startActivity, 23 | unforwardPorts, 24 | waitForBoot, 25 | waitForClose, 26 | } from './utils/adb'; 27 | import { getApkInfo } from './utils/apk'; 28 | import { getInstalledAVDs } from './utils/avd'; 29 | import { installApkToDevice, selectDeviceByTarget, selectHardwareDevice, selectVirtualDevice } from './utils/run'; 30 | import type { SDK } from './utils/sdk'; 31 | import { getSDK } from './utils/sdk'; 32 | 33 | const modulePrefix = 'native-run:android:run'; 34 | 35 | export async function run(args: readonly string[]): Promise { 36 | const sdk = await getSDK(); 37 | const apkPath = getOptionValue(args, '--app'); 38 | const forwardedPorts = getOptionValues(args, '--forward'); 39 | 40 | const ports: Ports[] = []; 41 | 42 | if (forwardedPorts && forwardedPorts.length > 0) { 43 | forwardedPorts.forEach((port: string) => { 44 | const [device, host] = port.split(':'); 45 | 46 | if (!device || !host) { 47 | throw new CLIException(`Invalid --forward value "${port}": expecting , e.g. 8080:8080`); 48 | } 49 | 50 | ports.push({ device, host }); 51 | }); 52 | } 53 | 54 | if (!apkPath) { 55 | throw new CLIException('--app is required', ERR_BAD_INPUT); 56 | } 57 | 58 | const device = await selectDevice(sdk, args); 59 | 60 | log(`Selected ${device.type === 'hardware' ? 'hardware device' : 'emulator'} ${device.serial}\n`); 61 | 62 | const { appId, activityName } = await getApkInfo(apkPath); 63 | await waitForBoot(sdk, device); 64 | 65 | if (ports) { 66 | await Promise.all( 67 | ports.map(async (port: Ports) => { 68 | await forwardPorts(sdk, device, port); 69 | log(`Forwarded device port ${port.device} to host port ${port.host}\n`); 70 | }), 71 | ); 72 | } 73 | 74 | await installApkToDevice(sdk, device, apkPath, appId); 75 | 76 | log(`Starting application activity ${appId}/${activityName}...\n`); 77 | await startActivity(sdk, device, appId, activityName); 78 | 79 | log(`Run Successful\n`); 80 | 81 | onBeforeExit(async () => { 82 | if (ports) { 83 | await Promise.all( 84 | ports.map(async (port: Ports) => { 85 | await unforwardPorts(sdk, device, port); 86 | }), 87 | ); 88 | } 89 | }); 90 | 91 | if (args.includes('--connect')) { 92 | onBeforeExit(async () => { 93 | await closeApp(sdk, device, appId); 94 | }); 95 | 96 | log(`Waiting for app to close...\n`); 97 | await waitForClose(sdk, device, appId); 98 | } 99 | } 100 | 101 | export async function selectDevice(sdk: SDK, args: readonly string[]): Promise { 102 | const debug = Debug(`${modulePrefix}:${selectDevice.name}`); 103 | 104 | const devices = await getDevices(sdk); 105 | const avds = await getInstalledAVDs(sdk); 106 | 107 | const target = getOptionValue(args, '--target'); 108 | const preferEmulator = args.includes('--virtual'); 109 | 110 | if (target) { 111 | const targetDevice = await selectDeviceByTarget(sdk, devices, avds, target); 112 | 113 | if (targetDevice) { 114 | return targetDevice; 115 | } else { 116 | throw new AndroidRunException(`Target not found: ${target}`, ERR_TARGET_NOT_FOUND); 117 | } 118 | } 119 | 120 | if (!preferEmulator) { 121 | const selectedDevice = await selectHardwareDevice(devices); 122 | 123 | if (selectedDevice) { 124 | return selectedDevice; 125 | } else if (args.includes('--device')) { 126 | throw new AndroidRunException( 127 | `No hardware devices found. Not attempting emulator because --device was specified.`, 128 | ERR_NO_DEVICE, 129 | ); 130 | } else { 131 | log('No hardware devices found, attempting emulator...\n'); 132 | } 133 | } 134 | 135 | try { 136 | return await selectVirtualDevice(sdk, devices, avds); 137 | } catch (e) { 138 | if (!(e instanceof AVDException)) { 139 | throw e; 140 | } 141 | 142 | debug('Issue with AVDs: %s', e.message); 143 | 144 | if (e.code === ERR_UNSUITABLE_API_INSTALLATION) { 145 | throw new AndroidRunException( 146 | 'No targets devices/emulators available. Cannot create AVD because there is no suitable API installation. Use --sdk-info to reveal missing packages and other issues.', 147 | ERR_NO_TARGET, 148 | ); 149 | } 150 | } 151 | 152 | throw new AndroidRunException('No target devices/emulators available.', ERR_NO_TARGET); 153 | } 154 | -------------------------------------------------------------------------------- /src/android/sdk-info.ts: -------------------------------------------------------------------------------- 1 | import { stringify } from '../utils/json'; 2 | 3 | import type { SDKPackage } from './utils/sdk'; 4 | import { findAllSDKPackages, getSDK } from './utils/sdk'; 5 | import type { APILevel } from './utils/sdk/api'; 6 | import { getAPILevels } from './utils/sdk/api'; 7 | 8 | type Platform = Required; 9 | 10 | interface SDKInfo { 11 | root: string; 12 | avdHome?: string; 13 | platforms: Platform[]; 14 | tools: SDKPackage[]; 15 | } 16 | 17 | export async function run(args: readonly string[]): Promise { 18 | const sdk = await getSDK(); 19 | const packages = await findAllSDKPackages(sdk); 20 | const apis = await getAPILevels(packages); 21 | const platforms = apis.map((api) => { 22 | return { ...api }; 23 | }); 24 | 25 | const sdkinfo: SDKInfo = { 26 | root: sdk.root, 27 | avdHome: sdk.avdHome, 28 | platforms, 29 | tools: packages.filter((pkg) => typeof pkg.apiLevel === 'undefined'), 30 | }; 31 | 32 | if (args.includes('--json')) { 33 | process.stdout.write(stringify(sdkinfo)); 34 | return; 35 | } 36 | 37 | process.stdout.write(`${formatSDKInfo(sdkinfo)}\n\n`); 38 | } 39 | 40 | function formatSDKInfo(sdk: SDKInfo): string { 41 | return ` 42 | SDK Location: ${sdk.root} 43 | AVD Home${sdk.avdHome ? `: ${sdk.avdHome}` : ` (!): not found`} 44 | 45 | ${sdk.platforms.map((platform) => `${formatPlatform(platform)}\n\n`).join('\n')} 46 | Tools: 47 | 48 | ${sdk.tools.map((tool) => formatPackage(tool)).join('\n')} 49 | `.trim(); 50 | } 51 | 52 | function formatPlatform(platform: Platform): string { 53 | return ` 54 | API Level: ${platform.apiLevel} 55 | Packages: ${platform.packages.map((p) => formatPackage(p)).join('\n' + ' '.repeat(22))} 56 | `.trim(); 57 | } 58 | 59 | function formatPackage(p: { name: string; path: string; version?: string | RegExp }): string { 60 | return `${p.name} ${p.path} ${typeof p.version === 'string' ? p.version : ''}`; 61 | } 62 | -------------------------------------------------------------------------------- /src/android/utils/__tests__/adb.ts: -------------------------------------------------------------------------------- 1 | import * as os from 'os'; 2 | 3 | import type * as adb from '../adb'; 4 | 5 | describe('android/utils/adb', () => { 6 | describe('parseAdbDevices', () => { 7 | let adbUtils: typeof adb; 8 | 9 | beforeEach(async () => { 10 | jest.resetModules(); 11 | adbUtils = await import('../adb'); 12 | }); 13 | 14 | it('should parse emulator-5554 device', async () => { 15 | const output = ` 16 | List of devices attached 17 | emulator-5554 device product:sdk_gphone_x86 model:Android_SDK_built_for_x86 device:generic_x86 transport_id:88\n\n`; 18 | const devices = adbUtils.parseAdbDevices(output); 19 | 20 | expect(devices).toEqual([ 21 | { 22 | serial: 'emulator-5554', 23 | state: 'device', 24 | type: 'emulator', 25 | connection: null, 26 | manufacturer: '', 27 | model: 'Android_SDK_built_for_x86', 28 | product: 'sdk_gphone_x86', 29 | sdkVersion: '', 30 | properties: { 31 | product: 'sdk_gphone_x86', 32 | model: 'Android_SDK_built_for_x86', 33 | device: 'generic_x86', 34 | transport_id: '88', 35 | }, 36 | }, 37 | ]); 38 | }); 39 | 40 | it('should parse hardware device (LGUS996e5ef677)', async () => { 41 | const output = ` 42 | List of devices attached 43 | LGUS996e5ef677 device usb:341835776X product:elsa_nao_us model:LG_US996 device:elsa transport_id:85\n\n`; 44 | const devices = adbUtils.parseAdbDevices(output); 45 | 46 | expect(devices).toEqual([ 47 | { 48 | serial: 'LGUS996e5ef677', 49 | state: 'device', 50 | type: 'hardware', 51 | connection: 'usb', 52 | manufacturer: '', 53 | model: 'LG_US996', 54 | product: 'elsa_nao_us', 55 | sdkVersion: '', 56 | properties: { 57 | usb: '341835776X', 58 | product: 'elsa_nao_us', 59 | model: 'LG_US996', 60 | device: 'elsa', 61 | transport_id: '85', 62 | }, 63 | }, 64 | ]); 65 | }); 66 | 67 | it('should parse hardware device (0a388e93)', async () => { 68 | const output = ` 69 | List of devices attached 70 | 0a388e93 device usb:1-1 product:razor model:Nexus_7 device:flo\n\n`; 71 | const devices = adbUtils.parseAdbDevices(output); 72 | 73 | expect(devices).toEqual([ 74 | { 75 | serial: '0a388e93', 76 | state: 'device', 77 | type: 'hardware', 78 | connection: 'usb', 79 | manufacturer: '', 80 | model: 'Nexus_7', 81 | product: 'razor', 82 | sdkVersion: '', 83 | properties: { 84 | usb: '1-1', 85 | product: 'razor', 86 | model: 'Nexus_7', 87 | device: 'flo', 88 | }, 89 | }, 90 | ]); 91 | }); 92 | 93 | it('should parse hardware device over tcpip (192.168.0.3:5555)', async () => { 94 | const output = ` 95 | List of devices attached 96 | 192.168.0.3:5555 device product:mido model:Redmi_Note_4 device:mido transport_id:1\n\n`; 97 | const devices = adbUtils.parseAdbDevices(output); 98 | 99 | expect(devices).toEqual([ 100 | { 101 | serial: '192.168.0.3:5555', 102 | state: 'device', 103 | type: 'hardware', 104 | connection: 'tcpip', 105 | manufacturer: '', 106 | model: 'Redmi_Note_4', 107 | product: 'mido', 108 | sdkVersion: '', 109 | properties: { 110 | device: 'mido', 111 | product: 'mido', 112 | model: 'Redmi_Note_4', 113 | transport_id: '1', 114 | }, 115 | }, 116 | ]); 117 | }); 118 | 119 | it('should parse hardware device from line without usb (98897a474748594558)', async () => { 120 | const output = ` 121 | List of devices attached 122 | 98897a474748594558 device product:dreamqltesq model:SM_G950U device:dreamqltesq transport_id:2\n\n`; 123 | const devices = adbUtils.parseAdbDevices(output); 124 | 125 | expect(devices).toEqual([ 126 | { 127 | serial: '98897a474748594558', 128 | state: 'device', 129 | type: 'hardware', 130 | connection: null, 131 | manufacturer: '', 132 | model: 'SM_G950U', 133 | product: 'dreamqltesq', 134 | sdkVersion: '', 135 | properties: { 136 | device: 'dreamqltesq', 137 | product: 'dreamqltesq', 138 | model: 'SM_G950U', 139 | transport_id: '2', 140 | }, 141 | }, 142 | ]); 143 | }); 144 | 145 | describe('windows', () => { 146 | let adbUtils: typeof adb; 147 | 148 | beforeEach(async () => { 149 | jest.resetModules(); 150 | jest.mock('os', () => ({ ...os, EOL: '\r\n' })); 151 | 152 | adbUtils = await import('../adb'); 153 | }); 154 | 155 | it('should parse hardware device (MWS0216B24001482)', async () => { 156 | const output = `\r\nList of devices attached\r\nMWS0216B24001482 offline transport_id:3\r\n\r\n`; 157 | const devices = adbUtils.parseAdbDevices(output); 158 | 159 | expect(devices).toEqual([ 160 | { 161 | serial: 'MWS0216B24001482', 162 | state: 'offline', 163 | type: 'hardware', 164 | connection: null, 165 | manufacturer: '', 166 | model: '', 167 | product: '', 168 | sdkVersion: '', 169 | properties: { 170 | transport_id: '3', 171 | }, 172 | }, 173 | ]); 174 | }); 175 | }); 176 | }); 177 | }); 178 | -------------------------------------------------------------------------------- /src/android/utils/__tests__/avd.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | 3 | import * as iniUtils from '../../../utils/ini'; 4 | import * as avdUtils from '../avd'; 5 | 6 | describe('android/utils/avd', () => { 7 | describe('getAVDFromINI', () => { 8 | it('should properly parse Pixel_2_API_28', async () => { 9 | const inipath = path.resolve(__dirname, './fixtures/avd/Pixel_2_API_28.ini'); 10 | const ini: any = await iniUtils.readINI(inipath); 11 | ini.path = path.resolve(__dirname, './fixtures/avd/Pixel_2_API_28.avd'); // patch path 12 | 13 | const expected = { 14 | id: 'Pixel_2_API_28', 15 | path: ini.path, 16 | name: 'Pixel 2 API 28', 17 | sdkVersion: '28', 18 | screenDPI: 420, 19 | screenWidth: 1080, 20 | screenHeight: 1920, 21 | }; 22 | 23 | const avd = await avdUtils.getAVDFromINI(inipath, ini); 24 | expect(avd).toEqual(expected); 25 | }); 26 | 27 | it('should properly parse Pixel_2_XL_API_28', async () => { 28 | const inipath = path.resolve(__dirname, './fixtures/avd/Pixel_2_XL_API_28.ini'); 29 | const ini: any = await iniUtils.readINI(inipath); 30 | ini.path = path.resolve(__dirname, './fixtures/avd/Pixel_2_XL_API_28.avd'); // patch path 31 | 32 | const expected = { 33 | id: 'Pixel_2_XL_API_28', 34 | path: ini.path, 35 | name: 'Pixel 2 XL API 28', 36 | sdkVersion: '28', 37 | screenDPI: 560, 38 | screenWidth: 1440, 39 | screenHeight: 2880, 40 | }; 41 | 42 | const avd = await avdUtils.getAVDFromINI(inipath, ini); 43 | expect(avd).toEqual(expected); 44 | }); 45 | 46 | it('should properly parse Pixel_API_25', async () => { 47 | const inipath = path.resolve(__dirname, './fixtures/avd/Pixel_API_25.ini'); 48 | const ini: any = await iniUtils.readINI(inipath); 49 | ini.path = path.resolve(__dirname, './fixtures/avd/Pixel_API_25.avd'); // patch path 50 | 51 | const expected = { 52 | id: 'Pixel_API_25', 53 | path: ini.path, 54 | name: 'Pixel API 25', 55 | sdkVersion: '25', 56 | screenDPI: 480, 57 | screenWidth: 1080, 58 | screenHeight: 1920, 59 | }; 60 | 61 | const avd = await avdUtils.getAVDFromINI(inipath, ini); 62 | expect(avd).toEqual(expected); 63 | }); 64 | 65 | it('should properly parse Nexus_5X_API_24', async () => { 66 | const inipath = path.resolve(__dirname, './fixtures/avd/Nexus_5X_API_24.ini'); 67 | const ini: any = await iniUtils.readINI(inipath); 68 | ini.path = path.resolve(__dirname, './fixtures/avd/Nexus_5X_API_24.avd'); // patch path 69 | 70 | const expected = { 71 | id: 'Nexus_5X_API_24', 72 | path: ini.path, 73 | name: 'Nexus 5X API 24', 74 | sdkVersion: '24', 75 | screenDPI: 420, 76 | screenWidth: 1080, 77 | screenHeight: 1920, 78 | }; 79 | 80 | const avd = await avdUtils.getAVDFromINI(inipath, ini); 81 | expect(avd).toEqual(expected); 82 | }); 83 | 84 | it('should properly parse avdmanager_1', async () => { 85 | const inipath = path.resolve(__dirname, './fixtures/avd/avdmanager_1.ini'); 86 | const ini: any = await iniUtils.readINI(inipath); 87 | ini.path = path.resolve(__dirname, './fixtures/avd/avdmanager_1.avd'); // patch path 88 | 89 | const expected = { 90 | id: 'avdmanager_1', 91 | path: ini.path, 92 | name: 'avdmanager 1', 93 | sdkVersion: '28', 94 | screenDPI: null, 95 | screenWidth: null, 96 | screenHeight: null, 97 | }; 98 | 99 | const avd = await avdUtils.getAVDFromINI(inipath, ini); 100 | expect(avd).toEqual(expected); 101 | }); 102 | }); 103 | }); 104 | -------------------------------------------------------------------------------- /src/android/utils/__tests__/emulator.ts: -------------------------------------------------------------------------------- 1 | import { parseAndroidConsoleResponse } from '../emulator'; 2 | 3 | const authRequiredOutput = `Android Console: Authentication required 4 | Android Console: type 'auth ' to authenticate 5 | Android Console: you can find your in 6 | '/Users/ionic/.emulator_console_auth_token' 7 | OK 8 | `; 9 | 10 | describe('android/utils/emulator', () => { 11 | describe('parseAndroidConsoleResponse', () => { 12 | it('should not parse an event from whitespace', () => { 13 | for (const output of ['', '\n', ' \n']) { 14 | const event = parseAndroidConsoleResponse(output); 15 | expect(event).not.toBeDefined(); 16 | } 17 | }); 18 | 19 | it('should not parse response from output until OK', () => { 20 | const lines = authRequiredOutput.split('\n').slice(0, -2); 21 | 22 | for (let i = 0; i < lines.length; i++) { 23 | const output = lines.slice(0, i).join('\n'); 24 | const event = parseAndroidConsoleResponse(output); 25 | expect(event).not.toBeDefined(); 26 | } 27 | }); 28 | 29 | it('should parse response from output', () => { 30 | const expected = authRequiredOutput.split('\n').slice(0, -2).join('\n') + '\n'; 31 | const event = parseAndroidConsoleResponse(authRequiredOutput); 32 | expect(event).toBe(expected); 33 | }); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /src/android/utils/__tests__/fixtures/avd/Nexus_5X_API_24.avd/config.ini: -------------------------------------------------------------------------------- 1 | abi.type=x86 2 | avd.ini.displayname=Nexus 5X API 24 3 | avd.ini.encoding=UTF-8 4 | AvdId=Nexus_5X_API_24 5 | disk.dataPartition.size=2G 6 | fastboot.forceColdBoot=no 7 | hw.accelerometer=yes 8 | hw.arc=false 9 | hw.audioInput=yes 10 | hw.battery=yes 11 | hw.camera.back=virtualscene 12 | hw.camera.front=emulated 13 | hw.cpu.arch=x86 14 | hw.cpu.ncore=4 15 | hw.device.hash2=MD5:bc5032b2a871da511332401af3ac6bb0 16 | hw.device.manufacturer=Google 17 | hw.device.name=Nexus 5X 18 | hw.dPad=no 19 | hw.gps=yes 20 | hw.gpu.enabled=yes 21 | hw.gpu.mode=auto 22 | hw.initialOrientation=Portrait 23 | hw.keyboard=yes 24 | hw.lcd.density=420 25 | hw.lcd.height=1920 26 | hw.lcd.width=1080 27 | hw.mainKeys=no 28 | hw.ramSize=1536 29 | hw.sdCard=yes 30 | hw.sensors.orientation=yes 31 | hw.sensors.proximity=yes 32 | hw.trackBall=no 33 | image.sysdir.1=system-images/android-24/google_apis/x86/ 34 | PlayStore.enabled=true 35 | runtime.network.latency=none 36 | runtime.network.speed=full 37 | sdcard.size=100M 38 | showDeviceFrame=yes 39 | skin.dynamic=yes 40 | skin.name=nexus_5x 41 | skin.path=/Users/ionic/Library/Android/sdk/skins/nexus_5x 42 | tag.display=Google Play 43 | tag.id=google_apis 44 | vm.heapSize=256 45 | -------------------------------------------------------------------------------- /src/android/utils/__tests__/fixtures/avd/Nexus_5X_API_24.ini: -------------------------------------------------------------------------------- 1 | avd.ini.encoding=UTF-8 2 | path=/Users/ionic/.android/avd/Nexus_5X_API_24.avd 3 | path.rel=avd/Nexus_5X_API_24.avd 4 | target=android-24 5 | -------------------------------------------------------------------------------- /src/android/utils/__tests__/fixtures/avd/Pixel_2_API_28.avd/config.ini: -------------------------------------------------------------------------------- 1 | AvdId=Pixel_2_API_28 2 | PlayStore.enabled=false 3 | abi.type=x86 4 | avd.ini.displayname=Pixel 2 API 28 5 | avd.ini.encoding=UTF-8 6 | disk.dataPartition.size=800M 7 | fastboot.forceColdBoot=no 8 | hw.accelerometer=yes 9 | hw.arc=false 10 | hw.audioInput=yes 11 | hw.battery=yes 12 | hw.camera.back=virtualscene 13 | hw.camera.front=emulated 14 | hw.cpu.arch=x86 15 | hw.cpu.ncore=4 16 | hw.dPad=no 17 | hw.device.hash2=MD5:bc5032b2a871da511332401af3ac6bb0 18 | hw.device.manufacturer=Google 19 | hw.device.name=pixel_2 20 | hw.gps=yes 21 | hw.gpu.enabled=yes 22 | hw.gpu.mode=auto 23 | hw.initialOrientation=Portrait 24 | hw.keyboard=yes 25 | hw.lcd.density=420 26 | hw.lcd.height=1920 27 | hw.lcd.width=1080 28 | hw.mainKeys=no 29 | hw.ramSize=1536 30 | hw.sdCard=yes 31 | hw.sensors.orientation=yes 32 | hw.sensors.proximity=yes 33 | hw.trackBall=no 34 | image.sysdir.1=system-images/android-28/google_apis/x86/ 35 | runtime.network.latency=none 36 | runtime.network.speed=full 37 | sdcard.size=100M 38 | showDeviceFrame=yes 39 | skin.dynamic=yes 40 | skin.name=pixel_2 41 | skin.path=/Users/ionic/Library/Android/sdk/skins/pixel_2 42 | tag.display=Google APIs 43 | tag.id=google_apis 44 | vm.heapSize=256 45 | -------------------------------------------------------------------------------- /src/android/utils/__tests__/fixtures/avd/Pixel_2_API_28.ini: -------------------------------------------------------------------------------- 1 | avd.ini.encoding=UTF-8 2 | path=/Users/ionic/.android/avd/Pixel_2_API_28.avd 3 | path.rel=avd/Pixel_2_API_28.avd 4 | target=android-28 5 | -------------------------------------------------------------------------------- /src/android/utils/__tests__/fixtures/avd/Pixel_2_XL_API_28.avd/config.ini: -------------------------------------------------------------------------------- 1 | AvdId=Pixel_2_XL_API_28 2 | PlayStore.enabled=false 3 | abi.type=x86 4 | avd.ini.displayname=Pixel 2 XL API 28 5 | avd.ini.encoding=UTF-8 6 | disk.dataPartition.size=800M 7 | fastboot.forceColdBoot=no 8 | hw.accelerometer=yes 9 | hw.arc=false 10 | hw.audioInput=yes 11 | hw.battery=yes 12 | hw.camera.back=virtualscene 13 | hw.camera.front=emulated 14 | hw.cpu.arch=x86 15 | hw.cpu.ncore=4 16 | hw.dPad=no 17 | hw.device.hash2=MD5:1a4fa6b2569f0b76bfb9824b6b6fc976 18 | hw.device.manufacturer=Google 19 | hw.device.name=pixel_2_xl 20 | hw.gps=yes 21 | hw.gpu.enabled=yes 22 | hw.gpu.mode=auto 23 | hw.initialOrientation=Portrait 24 | hw.keyboard=yes 25 | hw.lcd.density=560 26 | hw.lcd.height=2880 27 | hw.lcd.width=1440 28 | hw.mainKeys=no 29 | hw.ramSize=1536 30 | hw.sdCard=yes 31 | hw.sensors.orientation=yes 32 | hw.sensors.proximity=yes 33 | hw.trackBall=no 34 | image.sysdir.1=system-images/android-28/google_apis/x86/ 35 | runtime.network.latency=none 36 | runtime.network.speed=full 37 | sdcard.size=100M 38 | showDeviceFrame=yes 39 | skin.dynamic=yes 40 | skin.name=pixel_2_xl 41 | skin.path=/Users/ionic/Library/Android/sdk/skins/pixel_2_xl 42 | tag.display=Google APIs 43 | tag.id=google_apis 44 | vm.heapSize=256 45 | -------------------------------------------------------------------------------- /src/android/utils/__tests__/fixtures/avd/Pixel_2_XL_API_28.ini: -------------------------------------------------------------------------------- 1 | avd.ini.encoding=UTF-8 2 | path=/Users/ionic/.android/avd/Pixel_2_XL_API_28.avd 3 | path.rel=avd/Pixel_2_XL_API_28.avd 4 | target=android-28 5 | -------------------------------------------------------------------------------- /src/android/utils/__tests__/fixtures/avd/Pixel_API_25.avd/config.ini: -------------------------------------------------------------------------------- 1 | abi.type=x86 2 | avd.ini.displayname=Pixel API 25 3 | avd.ini.encoding=UTF-8 4 | AvdId=Pixel_API_25 5 | disk.dataPartition.size=800M 6 | fastboot.forceColdBoot=no 7 | hw.accelerometer=yes 8 | hw.arc=false 9 | hw.audioInput=yes 10 | hw.battery=yes 11 | hw.camera.back=virtualscene 12 | hw.camera.front=emulated 13 | hw.cpu.arch=x86 14 | hw.cpu.ncore=4 15 | hw.device.hash2=MD5:524882cfa9f421413193056700a29392 16 | hw.device.manufacturer=Google 17 | hw.device.name=pixel 18 | hw.dPad=no 19 | hw.gps=yes 20 | hw.gpu.enabled=yes 21 | hw.gpu.mode=auto 22 | hw.initialOrientation=Portrait 23 | hw.keyboard=yes 24 | hw.lcd.density=480 25 | hw.lcd.height=1920 26 | hw.lcd.width=1080 27 | hw.mainKeys=no 28 | hw.ramSize=1536 29 | hw.sdCard=yes 30 | hw.sensors.orientation=yes 31 | hw.sensors.proximity=yes 32 | hw.trackBall=no 33 | image.sysdir.1=system-images/android-25/google_apis/x86/ 34 | PlayStore.enabled=false 35 | runtime.network.latency=none 36 | runtime.network.speed=full 37 | sdcard.size=100M 38 | showDeviceFrame=yes 39 | skin.dynamic=yes 40 | skin.name=pixel 41 | skin.path=/Users/ionic/Library/Android/sdk/skins/pixel 42 | tag.display=Google APIs 43 | tag.id=google_apis 44 | vm.heapSize=256 45 | -------------------------------------------------------------------------------- /src/android/utils/__tests__/fixtures/avd/Pixel_API_25.ini: -------------------------------------------------------------------------------- 1 | avd.ini.encoding=UTF-8 2 | path=/Users/ionic/.android/avd/Pixel_API_25.avd 3 | path.rel=avd/Pixel_API_25.avd 4 | target=android-25 5 | -------------------------------------------------------------------------------- /src/android/utils/__tests__/fixtures/avd/README.md: -------------------------------------------------------------------------------- 1 | # AVD Definitions 2 | 3 | These test fixtures were generated by Android Studio, `avdmanager`, or `native-run` itself. 4 | 5 | AVD | Source | Hardware Profile | Information 6 | --------------------|-----------------------------------------|------------------|------------- 7 | `Pixel_2_API_28` | Android Studio 3.1.4 | Pixel 2 | 8 | `Pixel_2_XL_API_28` | Android Studio 3.1.4 | Pixel 2 XL | 9 | `Pixel_API_25` | `native-run` | Pixel | 10 | `Nexus_5X_API_24` | `native-run` | Nexus 5X | 11 | `avdmanager_1` | `avdmanager` (Android SDK Tools 26.1.1) | n/a |
  • no hardware profile used
  • `--package system-images;android-28;google_apis;x86`
12 | -------------------------------------------------------------------------------- /src/android/utils/__tests__/fixtures/avd/avdmanager_1.avd/config.ini: -------------------------------------------------------------------------------- 1 | PlayStore.enabled=false 2 | abi.type=x86 3 | avd.ini.encoding=UTF-8 4 | hw.cpu.arch=x86 5 | image.sysdir.1=system-images/android-28/google_apis/x86/ 6 | tag.display=Google APIs 7 | tag.id=google_apis 8 | -------------------------------------------------------------------------------- /src/android/utils/__tests__/fixtures/avd/avdmanager_1.ini: -------------------------------------------------------------------------------- 1 | avd.ini.encoding=UTF-8 2 | path=/Users/ionic/.android/avd/avdmanager_1.avd 3 | path.rel=avd/avdmanager_1.avd 4 | target=android-28 5 | -------------------------------------------------------------------------------- /src/android/utils/apk.ts: -------------------------------------------------------------------------------- 1 | import { unzip } from '../../utils/unzip'; 2 | 3 | import { BinaryXmlParser } from './binary-xml-parser'; 4 | 5 | export async function readAndroidManifest(apkPath: string): Promise { 6 | let error: Error | undefined; 7 | const chunks: Buffer[] = []; 8 | 9 | await unzip(apkPath, async (entry, zipfile, openReadStream) => { 10 | if (entry.fileName === 'AndroidManifest.xml') { 11 | const readStream = await openReadStream(entry); 12 | readStream.on('error', (err: Error) => (error = err)); 13 | readStream.on('data', (chunk: Buffer) => chunks.push(chunk)); 14 | readStream.on('end', () => zipfile.close()); 15 | } else { 16 | zipfile.readEntry(); 17 | } 18 | }); 19 | 20 | if (error) { 21 | throw error; 22 | } 23 | 24 | const buf = Buffer.concat(chunks); 25 | const manifestBuffer = Buffer.from(buf); 26 | 27 | return new BinaryXmlParser(manifestBuffer).parse(); 28 | } 29 | 30 | export async function getApkInfo(apkPath: string): Promise<{ appId: any; activityName: any }> { 31 | const doc = await readAndroidManifest(apkPath); 32 | const appId = doc.attributes.find((a: any) => a.name === 'package').value; 33 | const application = doc.childNodes.find((n: any) => n.nodeName === 'application'); 34 | const activity = application.childNodes.find((n: any) => n.nodeName === 'activity'); 35 | const activityName = activity.attributes.find((a: any) => a.name === 'name').value; 36 | 37 | return { appId, activityName }; 38 | } 39 | -------------------------------------------------------------------------------- /src/android/utils/avd.ts: -------------------------------------------------------------------------------- 1 | import { readdir } from '@ionic/utils-fs'; 2 | import * as Debug from 'debug'; 3 | import * as pathlib from 'path'; 4 | 5 | import { readINI } from '../../utils/ini'; 6 | 7 | import type { SDK } from './sdk'; 8 | 9 | const modulePrefix = 'native-run:android:utils:avd'; 10 | 11 | export interface AVD { 12 | readonly id: string; 13 | readonly path: string; 14 | readonly name: string; 15 | readonly sdkVersion: string; 16 | readonly screenDPI: number | null; 17 | readonly screenWidth: number | null; 18 | readonly screenHeight: number | null; 19 | } 20 | 21 | export interface AVDSchematic { 22 | readonly id: string; 23 | readonly ini: Required; 24 | readonly configini: Required; 25 | } 26 | 27 | export interface AVDINI { 28 | readonly 'avd.ini.encoding': string; 29 | readonly path: string; 30 | readonly 'path.rel': string; 31 | readonly target: string; 32 | } 33 | 34 | export interface AVDConfigINI { 35 | readonly AvdId?: string; 36 | readonly 'abi.type'?: string; 37 | readonly 'avd.ini.displayname'?: string; 38 | readonly 'avd.ini.encoding'?: string; 39 | readonly 'hw.accelerometer'?: string; 40 | readonly 'hw.audioInput'?: string; 41 | readonly 'hw.battery'?: string; 42 | readonly 'hw.camera.back'?: string; 43 | readonly 'hw.camera.front'?: string; 44 | readonly 'hw.cpu.arch'?: string; 45 | readonly 'hw.cpu.ncore'?: string; 46 | readonly 'hw.device.hash2'?: string; 47 | readonly 'hw.device.manufacturer'?: string; 48 | readonly 'hw.device.name'?: string; 49 | readonly 'hw.gps'?: string; 50 | readonly 'hw.gpu.enabled'?: string; 51 | readonly 'hw.gpu.mode'?: string; 52 | readonly 'hw.initialOrientation'?: string; 53 | readonly 'hw.keyboard'?: string; 54 | readonly 'hw.lcd.density'?: string; 55 | readonly 'hw.lcd.height'?: string; 56 | readonly 'hw.lcd.width'?: string; 57 | readonly 'hw.ramSize'?: string; 58 | readonly 'hw.sdCard'?: string; 59 | readonly 'hw.sensors.orientation'?: string; 60 | readonly 'hw.sensors.proximity'?: string; 61 | readonly 'image.sysdir.1'?: string; 62 | readonly 'sdcard.size'?: string; 63 | readonly showDeviceFrame?: string; 64 | readonly 'skin.dynamic'?: string; 65 | readonly 'skin.name'?: string; 66 | readonly 'skin.path'?: string; 67 | readonly 'tag.display'?: string; 68 | readonly 'tag.id'?: string; 69 | } 70 | 71 | export const isAVDINI = (o: any): o is AVDINI => 72 | o && 73 | typeof o['avd.ini.encoding'] === 'string' && 74 | typeof o['path'] === 'string' && 75 | typeof o['path.rel'] === 'string' && 76 | typeof o['target'] === 'string'; 77 | 78 | export const isAVDConfigINI = (o: any): o is AVDConfigINI => 79 | o && 80 | (typeof o['avd.ini.displayname'] === 'undefined' || typeof o['avd.ini.displayname'] === 'string') && 81 | (typeof o['hw.lcd.density'] === 'undefined' || typeof o['hw.lcd.density'] === 'string') && 82 | (typeof o['hw.lcd.height'] === 'undefined' || typeof o['hw.lcd.height'] === 'string') && 83 | (typeof o['hw.lcd.width'] === 'undefined' || typeof o['hw.lcd.width'] === 'string') && 84 | (typeof o['image.sysdir.1'] === 'undefined' || typeof o['image.sysdir.1'] === 'string'); 85 | 86 | export async function getAVDINIs(sdk: SDK): Promise<[string, AVDINI][]> { 87 | const debug = Debug(`${modulePrefix}:${getAVDINIs.name}`); 88 | 89 | const contents = await readdir(sdk.avdHome); 90 | 91 | const iniFilePaths = contents 92 | .filter((f) => pathlib.extname(f) === '.ini') 93 | .map((f) => pathlib.resolve(sdk.avdHome, f)); 94 | 95 | debug('Discovered AVD ini files: %O', iniFilePaths); 96 | 97 | const iniFiles = await Promise.all( 98 | iniFilePaths.map(async (f): Promise<[string, AVDINI | undefined]> => [f, await readINI(f, isAVDINI)]), 99 | ); 100 | 101 | const avdInis = iniFiles.filter((c): c is [string, AVDINI] => typeof c[1] !== 'undefined'); 102 | 103 | return avdInis; 104 | } 105 | 106 | export function getAVDFromConfigINI(inipath: string, ini: AVDINI, configini: AVDConfigINI): AVD { 107 | const inibasename = pathlib.basename(inipath); 108 | const id = inibasename.substring(0, inibasename.length - pathlib.extname(inibasename).length); 109 | const name = configini['avd.ini.displayname'] ? String(configini['avd.ini.displayname']) : id.replace(/_/g, ' '); 110 | const screenDPI = configini['hw.lcd.density'] ? Number(configini['hw.lcd.density']) : null; 111 | const screenWidth = configini['hw.lcd.width'] ? Number(configini['hw.lcd.width']) : null; 112 | const screenHeight = configini['hw.lcd.height'] ? Number(configini['hw.lcd.height']) : null; 113 | 114 | return { 115 | id, 116 | path: ini.path, 117 | name, 118 | sdkVersion: getSDKVersionFromTarget(ini.target), 119 | screenDPI, 120 | screenWidth, 121 | screenHeight, 122 | }; 123 | } 124 | 125 | export function getSDKVersionFromTarget(target: string): string { 126 | return target.replace(/^android-(\d+)/, '$1'); 127 | } 128 | 129 | export async function getAVDFromINI(inipath: string, ini: AVDINI): Promise { 130 | const configini = await readINI(pathlib.resolve(ini.path, 'config.ini'), isAVDConfigINI); 131 | 132 | if (configini) { 133 | return getAVDFromConfigINI(inipath, ini, configini); 134 | } 135 | } 136 | 137 | export async function getInstalledAVDs(sdk: SDK): Promise { 138 | const avdInis = await getAVDINIs(sdk); 139 | const possibleAvds = await Promise.all(avdInis.map(([inipath, ini]) => getAVDFromINI(inipath, ini))); 140 | const avds = possibleAvds.filter((avd): avd is AVD => typeof avd !== 'undefined'); 141 | 142 | return avds; 143 | } 144 | -------------------------------------------------------------------------------- /src/android/utils/emulator.ts: -------------------------------------------------------------------------------- 1 | import { readFile } from '@ionic/utils-fs'; 2 | import { spawn } from 'child_process'; 3 | import * as Debug from 'debug'; 4 | import * as net from 'net'; 5 | import * as os from 'os'; 6 | import * as path from 'path'; 7 | import * as split2 from 'split2'; 8 | import * as through2 from 'through2'; 9 | 10 | import { 11 | ERR_ALREADY_RUNNING, 12 | ERR_AVD_HOME_NOT_FOUND, 13 | ERR_NON_ZERO_EXIT, 14 | ERR_UNKNOWN_AVD, 15 | EmulatorException, 16 | } from '../../errors'; 17 | import { once } from '../../utils/fn'; 18 | 19 | import type { Device } from './adb'; 20 | import { getDevices, waitForDevice } from './adb'; 21 | import type { AVD } from './avd'; 22 | import type { SDK } from './sdk'; 23 | import { getSDKPackage, supplementProcessEnv } from './sdk'; 24 | 25 | const modulePrefix = 'native-run:android:utils:emulator'; 26 | 27 | /** 28 | * Resolves when emulator is ready and running with the specified AVD. 29 | */ 30 | export async function runEmulator(sdk: SDK, avd: AVD, port: number): Promise { 31 | try { 32 | await spawnEmulator(sdk, avd, port); 33 | } catch (e) { 34 | if (!(e instanceof EmulatorException) || e.code !== ERR_ALREADY_RUNNING) { 35 | throw e; 36 | } 37 | } 38 | 39 | const serial = `emulator-${port}`; 40 | const devices = await getDevices(sdk); 41 | const emulator = devices.find((device) => device.serial === serial); 42 | 43 | if (!emulator) { 44 | throw new EmulatorException(`Emulator not found: ${serial}`); 45 | } 46 | 47 | return emulator; 48 | } 49 | 50 | export async function spawnEmulator(sdk: SDK, avd: AVD, port: number): Promise { 51 | const debug = Debug(`${modulePrefix}:${spawnEmulator.name}`); 52 | const emulator = await getSDKPackage(path.join(sdk.root, 'emulator')); 53 | const emulatorBin = path.join(emulator.location, 'emulator'); 54 | const args = ['-avd', avd.id, '-port', port.toString(), '-verbose']; 55 | debug('Invoking emulator: %O %O', emulatorBin, args); 56 | const p = spawn(emulatorBin, args, { 57 | detached: true, 58 | stdio: ['ignore', 'pipe', 'pipe'], 59 | env: supplementProcessEnv(sdk), 60 | }); 61 | p.unref(); 62 | return new Promise((_resolve, _reject) => { 63 | const resolve: typeof _resolve = once(() => { 64 | _resolve(); 65 | cleanup(); 66 | }); 67 | const reject: typeof _reject = once((err) => { 68 | _reject(err); 69 | cleanup(); 70 | }); 71 | 72 | waitForDevice(sdk, `emulator-${port}`).then( 73 | () => resolve(), 74 | (err) => reject(err), 75 | ); 76 | 77 | const eventParser = through2((chunk: string, enc, cb) => { 78 | const line = chunk.toString(); 79 | 80 | debug('Android Emulator: %O', line); 81 | const event = parseEmulatorOutput(line); 82 | if (event === EmulatorEvent.AlreadyRunning) { 83 | reject(new EmulatorException(`Emulator already running with AVD [${avd.id}]`, ERR_ALREADY_RUNNING)); 84 | } else if (event === EmulatorEvent.UnknownAVD) { 85 | reject(new EmulatorException(`Unknown AVD name [${avd.id}]`, ERR_UNKNOWN_AVD)); 86 | } else if (event === EmulatorEvent.AVDHomeNotFound) { 87 | reject(new EmulatorException(`Emulator cannot find AVD home`, ERR_AVD_HOME_NOT_FOUND)); 88 | } 89 | 90 | cb(); 91 | }); 92 | 93 | const stdoutStream = p.stdout.pipe(split2()); 94 | const stderrStream = p.stderr.pipe(split2()); 95 | 96 | stdoutStream.pipe(eventParser); 97 | stderrStream.pipe(eventParser); 98 | 99 | const cleanup = () => { 100 | debug('Unhooking stdout/stderr streams from emulator process'); 101 | p.stdout.push(null); 102 | p.stderr.push(null); 103 | }; 104 | 105 | p.on('close', (code) => { 106 | debug('Emulator closed, exit code %d', code); 107 | 108 | if (code) { 109 | reject(new EmulatorException(`Non-zero exit code from Emulator: ${code}`, ERR_NON_ZERO_EXIT)); 110 | } 111 | }); 112 | 113 | p.on('error', (err) => { 114 | debug('Emulator error: %O', err); 115 | reject(err); 116 | }); 117 | }); 118 | } 119 | 120 | export enum EmulatorEvent { 121 | UnknownAVD, // AVD name was invalid 122 | AlreadyRunning, // already running with current AVD 123 | AVDHomeNotFound, // Cannot find AVD system path 124 | } 125 | 126 | export function parseEmulatorOutput(line: string): EmulatorEvent | undefined { 127 | const debug = Debug(`${modulePrefix}:${parseEmulatorOutput.name}`); 128 | let event: EmulatorEvent | undefined; 129 | 130 | if (line.includes('Unknown AVD name')) { 131 | event = EmulatorEvent.UnknownAVD; 132 | } else if (line.includes('another emulator instance running with the current AVD')) { 133 | event = EmulatorEvent.AlreadyRunning; 134 | } else if (line.includes('Cannot find AVD system path')) { 135 | event = EmulatorEvent.AVDHomeNotFound; 136 | } 137 | 138 | if (typeof event !== 'undefined') { 139 | debug('Parsed event from emulator output: %s', EmulatorEvent[event]); 140 | } 141 | 142 | return event; 143 | } 144 | 145 | export async function getAVDFromEmulator(emulator: Device, avds: readonly AVD[]): Promise { 146 | const debug = Debug(`${modulePrefix}:${getAVDFromEmulator.name}`); 147 | const emulatorPortRegex = /^emulator-(\d+)$/; 148 | const m = emulator.serial.match(emulatorPortRegex); 149 | 150 | if (!m) { 151 | throw new EmulatorException(`Emulator ${emulator.serial} does not match expected emulator serial format`); 152 | } 153 | 154 | const port = Number.parseInt(m[1], 10); 155 | const host = 'localhost'; 156 | const sock = net.createConnection({ host, port }); 157 | sock.setEncoding('utf8'); 158 | sock.setTimeout(5000); 159 | 160 | const readAuthFile = new Promise((resolve, reject) => { 161 | sock.on('connect', () => { 162 | debug('Connected to %s:%d', host, port); 163 | readFile(path.resolve(os.homedir(), '.emulator_console_auth_token'), { 164 | encoding: 'utf8', 165 | }).then( 166 | (contents) => resolve(contents.trim()), 167 | (err) => reject(err), 168 | ); 169 | }); 170 | }); 171 | 172 | enum Stage { 173 | Initial, 174 | Auth, 175 | AuthSuccess, 176 | Response, 177 | Complete, 178 | } 179 | 180 | return new Promise((resolve, reject) => { 181 | let stage = Stage.Initial; 182 | 183 | const timer = setTimeout(() => { 184 | if (stage !== Stage.Complete) { 185 | reject( 186 | new EmulatorException(`Took too long to get AVD name from Android Emulator Console, something went wrong.`), 187 | ); 188 | } 189 | }, 3000); 190 | 191 | const cleanup = once(() => { 192 | clearTimeout(timer); 193 | sock.end(); 194 | }); 195 | 196 | sock.on('timeout', () => { 197 | reject(new EmulatorException(`Socket timeout on ${host}:${port}`)); 198 | cleanup(); 199 | }); 200 | 201 | sock.pipe(split2()).pipe( 202 | through2((chunk: string, enc, cb) => { 203 | const line = chunk.toString(); 204 | 205 | debug('Android Console: %O', line); 206 | 207 | if (stage === Stage.Initial && line.includes('Authentication required')) { 208 | stage = Stage.Auth; 209 | } else if (stage === Stage.Auth && line.trim() === 'OK') { 210 | readAuthFile.then( 211 | (token) => sock.write(`auth ${token}\n`, 'utf8'), 212 | (err) => reject(err), 213 | ); 214 | stage = Stage.AuthSuccess; 215 | } else if (stage === Stage.AuthSuccess && line.trim() === 'OK') { 216 | sock.write('avd name\n', 'utf8'); 217 | stage = Stage.Response; 218 | } else if (stage === Stage.Response) { 219 | const avdId = line.trim(); 220 | const avd = avds.find((avd) => avd.id === avdId); 221 | 222 | if (avd) { 223 | resolve(avd); 224 | } else { 225 | reject(new EmulatorException(`Unknown AVD name [${avdId}]`, ERR_UNKNOWN_AVD)); 226 | } 227 | 228 | stage = Stage.Complete; 229 | cleanup(); 230 | } 231 | 232 | cb(); 233 | }), 234 | ); 235 | }); 236 | } 237 | 238 | export function parseAndroidConsoleResponse(output: string): string | undefined { 239 | const debug = Debug(`${modulePrefix}:${parseAndroidConsoleResponse.name}`); 240 | const m = /([\s\S]+)OK\r?\n/g.exec(output); 241 | 242 | if (m) { 243 | const [, response] = m; 244 | debug('Parsed response data from Android Console output: %O', response); 245 | return response; 246 | } 247 | } 248 | -------------------------------------------------------------------------------- /src/android/utils/list.ts: -------------------------------------------------------------------------------- 1 | import type { Target } from '../../utils/list'; 2 | 3 | import type { Device } from './adb'; 4 | import { getDevices } from './adb'; 5 | import type { AVD } from './avd'; 6 | import { getInstalledAVDs } from './avd'; 7 | import type { SDK } from './sdk'; 8 | 9 | export async function getDeviceTargets(sdk: SDK): Promise { 10 | return (await getDevices(sdk)).filter((device) => device.type === 'hardware').map(deviceToTarget); 11 | } 12 | 13 | export async function getVirtualTargets(sdk: SDK): Promise { 14 | const avds = await getInstalledAVDs(sdk); 15 | return avds.map(avdToTarget); 16 | } 17 | 18 | export function deviceToTarget(device: Device): Target { 19 | return { 20 | platform: 'android', 21 | model: `${device.manufacturer} ${device.model}`, 22 | sdkVersion: device.sdkVersion, 23 | id: device.serial, 24 | }; 25 | } 26 | 27 | export function avdToTarget(avd: AVD): Target { 28 | return { 29 | platform: 'android', 30 | name: avd.name, 31 | sdkVersion: avd.sdkVersion, 32 | id: avd.id, 33 | }; 34 | } 35 | -------------------------------------------------------------------------------- /src/android/utils/run.ts: -------------------------------------------------------------------------------- 1 | import * as Debug from 'debug'; 2 | 3 | import { 4 | ADBException, 5 | AndroidRunException, 6 | ERR_INCOMPATIBLE_UPDATE, 7 | ERR_NO_TARGET, 8 | ERR_VERSION_DOWNGRADE, 9 | } from '../../errors'; 10 | import { log } from '../../utils/log'; 11 | 12 | import type { Device } from './adb'; 13 | import { installApk, uninstallApp } from './adb'; 14 | import type { AVD } from './avd'; 15 | import { getAVDFromEmulator, runEmulator } from './emulator'; 16 | import type { SDK } from './sdk'; 17 | 18 | const modulePrefix = 'native-run:android:utils:run'; 19 | 20 | export async function selectDeviceByTarget( 21 | sdk: SDK, 22 | devices: readonly Device[], 23 | avds: readonly AVD[], 24 | target: string, 25 | ): Promise { 26 | const debug = Debug(`${modulePrefix}:${selectDeviceByTarget.name}`); 27 | 28 | debug('--target %s detected', target); 29 | debug('Checking if device can be found by serial: %s', target); 30 | const device = devices.find((d) => d.serial === target); 31 | 32 | if (device) { 33 | debug('Device found by serial: %s', device.serial); 34 | return device; 35 | } 36 | 37 | const emulatorDevices = devices.filter((d) => d.type === 'emulator'); 38 | 39 | const pairAVD = async (emulator: Device): Promise<[Device, AVD | undefined]> => { 40 | let avd: AVD | undefined; 41 | 42 | try { 43 | avd = await getAVDFromEmulator(emulator, avds); 44 | debug('Emulator %s is using AVD: %s', emulator.serial, avd.id); 45 | } catch (e) { 46 | debug('Error with emulator %s: %O', emulator.serial, e); 47 | } 48 | 49 | return [emulator, avd]; 50 | }; 51 | 52 | debug('Checking if any of %d running emulators are using AVD by ID: %s', emulatorDevices.length, target); 53 | const emulatorsAndAVDs = await Promise.all(emulatorDevices.map((emulator) => pairAVD(emulator))); 54 | const emulators = emulatorsAndAVDs.filter((t): t is [Device, AVD] => typeof t[1] !== 'undefined'); 55 | const emulator = emulators.find(([, avd]) => avd.id === target); 56 | 57 | if (emulator) { 58 | const [device, avd] = emulator; 59 | debug('Emulator %s found by AVD: %s', device.serial, avd.id); 60 | return device; 61 | } 62 | 63 | debug('Checking if AVD can be found by ID: %s', target); 64 | const avd = avds.find((avd) => avd.id === target); 65 | 66 | if (avd) { 67 | debug('AVD found by ID: %s', avd.id); 68 | const device = await runEmulator(sdk, avd, 5554); // TODO: 5554 will not always be available at this point 69 | debug('Emulator ready, running avd: %s on %s', avd.id, device.serial); 70 | 71 | return device; 72 | } 73 | } 74 | 75 | export async function selectHardwareDevice(devices: readonly Device[]): Promise { 76 | const hardwareDevices = devices.filter((d) => d.type === 'hardware'); 77 | 78 | // If a hardware device is found, we prefer launching to it instead of in an emulator. 79 | if (hardwareDevices.length > 0) { 80 | return hardwareDevices[0]; // TODO: can probably do better analysis on which to use? 81 | } 82 | } 83 | 84 | export async function selectVirtualDevice(sdk: SDK, devices: readonly Device[], avds: readonly AVD[]): Promise { 85 | const debug = Debug(`${modulePrefix}:${selectVirtualDevice.name}`); 86 | const emulators = devices.filter((d) => d.type === 'emulator'); 87 | 88 | // If an emulator is running, use it. 89 | if (emulators.length > 0) { 90 | const [emulator] = emulators; 91 | debug('Found running emulator: %s', emulator.serial); 92 | return emulator; 93 | } 94 | throw new AndroidRunException('No target devices/emulators available.', ERR_NO_TARGET); 95 | } 96 | 97 | export async function installApkToDevice(sdk: SDK, device: Device, apk: string, appId: string): Promise { 98 | log(`Installing ${apk}...\n`); 99 | 100 | try { 101 | await installApk(sdk, device, apk); 102 | } catch (e) { 103 | if (e instanceof ADBException) { 104 | if (e.code === ERR_INCOMPATIBLE_UPDATE || e.code === ERR_VERSION_DOWNGRADE) { 105 | log(`${e.message} Uninstalling and trying again...\n`); 106 | await uninstallApp(sdk, device, appId); 107 | await installApk(sdk, device, apk); 108 | return; 109 | } 110 | } 111 | 112 | throw e; 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /src/android/utils/sdk/__tests__/api.ts: -------------------------------------------------------------------------------- 1 | import type { APISchemaPackage } from '../api'; 2 | import { findPackageBySchema, findUnsatisfiedPackages } from '../api'; 3 | 4 | describe('android/utils/sdk/api', () => { 5 | const FooPackage = { 6 | path: 'foo', 7 | location: '/Users/me/Android/sdk/foo', 8 | name: 'Foo', 9 | version: '1', 10 | }; 11 | 12 | const BarPackage = { 13 | path: 'bar', 14 | location: '/Users/me/Android/sdk/bar', 15 | name: 'Bar', 16 | version: '1.0.0', 17 | }; 18 | 19 | const BarPackageInvalidVersion = { 20 | path: 'bar', 21 | location: '/Users/me/Android/sdk/bar', 22 | name: 'Bar', 23 | version: '2.0.0', 24 | }; 25 | 26 | const FooPackageSchema = { name: 'Foo', path: 'foo', version: '1' }; 27 | const BarPackageSchema = { 28 | name: 'Bar', 29 | path: 'bar', 30 | version: /^1\.\d+\.\d+$/, 31 | }; 32 | 33 | describe('findUnsatisfiedPackages', () => { 34 | const schemaPackages: APISchemaPackage[] = [FooPackageSchema, BarPackageSchema]; 35 | 36 | it('should return all package schemas for empty packages', () => { 37 | const result = findUnsatisfiedPackages([], schemaPackages); 38 | expect(result).toEqual(schemaPackages); 39 | }); 40 | 41 | it('should return unsatisfied packages for missing', () => { 42 | const api = [FooPackage]; 43 | const result = findUnsatisfiedPackages(api, schemaPackages); 44 | expect(result).toEqual([BarPackageSchema]); 45 | }); 46 | 47 | it('should return unsatisfied packages for invalid version', () => { 48 | const api = [FooPackage, BarPackageInvalidVersion]; 49 | const result = findUnsatisfiedPackages(api, schemaPackages); 50 | expect(result).toEqual([BarPackageSchema]); 51 | }); 52 | 53 | it('should return empty array if everything is satisfied', () => { 54 | const api = [FooPackage, BarPackage]; 55 | const result = findUnsatisfiedPackages(api, schemaPackages); 56 | expect(result).toEqual([]); 57 | }); 58 | }); 59 | 60 | describe('findPackageBySchema', () => { 61 | it('should not find package in empty api', () => { 62 | const pkg = findPackageBySchema([], FooPackageSchema); 63 | expect(pkg).toBeUndefined(); 64 | }); 65 | 66 | it('should not find package for invalid version', () => { 67 | const pkg = findPackageBySchema([FooPackage, BarPackageInvalidVersion], BarPackageSchema); 68 | expect(pkg).toBeUndefined(); 69 | }); 70 | 71 | it('should find foo package by schema', () => { 72 | const pkg = findPackageBySchema([FooPackage, BarPackage], FooPackageSchema); 73 | expect(pkg).toBe(FooPackage); 74 | }); 75 | }); 76 | }); 77 | -------------------------------------------------------------------------------- /src/android/utils/sdk/__tests__/index.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | 3 | import type * as index from '../'; 4 | 5 | describe('android/utils/sdk', () => { 6 | let sdkUtils: typeof index; 7 | let mockIsDir: jest.Mock; 8 | let mockHomedir: jest.Mock; 9 | let originalPlatform: string; 10 | let originalProcessEnv: NodeJS.ProcessEnv; 11 | 12 | beforeEach(() => { 13 | mockIsDir = jest.fn(); 14 | mockHomedir = jest.fn().mockReturnValue('/home/me'); 15 | originalPlatform = process.platform; 16 | originalProcessEnv = process.env; 17 | 18 | jest.resetModules(); 19 | jest.mock('path', () => path); 20 | jest.mock('os', () => ({ homedir: mockHomedir })); 21 | jest.mock('../../../../utils/fs', () => ({ isDir: mockIsDir })); 22 | }); 23 | 24 | afterEach(() => { 25 | Object.defineProperty(process, 'env', { value: originalProcessEnv }); 26 | Object.defineProperty(process, 'platform', { value: originalPlatform }); 27 | }); 28 | 29 | describe('SDK_DIRECTORIES', () => { 30 | describe('windows', () => { 31 | beforeEach(() => { 32 | jest.mock('path', () => path.win32); 33 | mockHomedir = jest.fn().mockReturnValue('C:\\Users\\me'); 34 | jest.mock('os', () => ({ homedir: mockHomedir })); 35 | }); 36 | 37 | it('should default to windows 10 local app data directory', async () => { 38 | Object.defineProperty(process, 'env', { value: {} }); 39 | sdkUtils = await import('../'); 40 | expect(sdkUtils.SDK_DIRECTORIES.get('win32')).toEqual([ 41 | path.win32.join('C:\\Users\\me\\AppData\\Local\\Android\\Sdk'), 42 | ]); 43 | }); 44 | 45 | it('should use LOCALAPPDATA environment variable if present', async () => { 46 | Object.defineProperty(process, 'env', { 47 | value: { 48 | LOCALAPPDATA: path.win32.join('C:\\', 'Documents and Settings', 'me', 'Application Data'), 49 | }, 50 | }); 51 | sdkUtils = await import('../'); 52 | expect(sdkUtils.SDK_DIRECTORIES.get('win32')).toEqual([ 53 | path.win32.join('C:\\Documents and Settings\\me\\Application Data\\Android\\Sdk'), 54 | ]); 55 | }); 56 | }); 57 | }); 58 | 59 | describe('resolveSDKRoot', () => { 60 | beforeEach(async () => { 61 | sdkUtils = await import('../'); 62 | }); 63 | 64 | it('should resolve with ANDROID_HOME if in environment', async () => { 65 | mockIsDir.mockResolvedValueOnce(true); 66 | Object.defineProperty(process, 'env', { 67 | value: { 68 | ANDROID_HOME: '/some/dir', 69 | ANDROID_SDK_ROOT: '/some/other/dir', 70 | }, 71 | }); 72 | await expect(sdkUtils.resolveSDKRoot()).resolves.toEqual('/some/dir'); 73 | expect(mockIsDir).toHaveBeenCalledTimes(1); 74 | expect(mockIsDir).toHaveBeenCalledWith('/some/dir'); 75 | }); 76 | 77 | it('should resolve with ANDROID_SDK_ROOT if in environment', async () => { 78 | mockIsDir.mockResolvedValueOnce(true); 79 | Object.defineProperty(process, 'env', { 80 | value: { ANDROID_SDK_ROOT: '/some/other/dir' }, 81 | }); 82 | await expect(sdkUtils.resolveSDKRoot()).resolves.toEqual('/some/other/dir'); 83 | expect(mockIsDir).toHaveBeenCalledTimes(1); 84 | expect(mockIsDir).toHaveBeenCalledWith('/some/other/dir'); 85 | }); 86 | 87 | it('should resolve with default value for empty environment on linux', async () => { 88 | mockIsDir.mockResolvedValueOnce(true); 89 | Object.defineProperty(process, 'env', { value: {} }); 90 | Object.defineProperty(process, 'platform', { value: 'linux' }); 91 | await expect(sdkUtils.resolveSDKRoot()).resolves.toEqual('/home/me/Android/sdk'); 92 | expect(mockIsDir).toHaveBeenCalledTimes(1); 93 | expect(mockIsDir).toHaveBeenCalledWith('/home/me/Android/sdk'); 94 | }); 95 | 96 | it('should resolve with default value for empty environment on darwin', async () => { 97 | mockIsDir.mockResolvedValueOnce(true); 98 | Object.defineProperty(process, 'env', { value: {} }); 99 | Object.defineProperty(process, 'platform', { value: 'darwin' }); 100 | await expect(sdkUtils.resolveSDKRoot()).resolves.toEqual('/home/me/Library/Android/sdk'); 101 | expect(mockIsDir).toHaveBeenCalledTimes(1); 102 | expect(mockIsDir).toHaveBeenCalledWith('/home/me/Library/Android/sdk'); 103 | }); 104 | 105 | it('should reject if no valid directories are found', async () => { 106 | mockIsDir.mockResolvedValueOnce(false); 107 | Object.defineProperty(process, 'env', { value: {} }); 108 | Object.defineProperty(process, 'platform', { value: 'darwin' }); 109 | await expect(sdkUtils.resolveSDKRoot()).rejects.toThrowError('No valid Android SDK root found.'); 110 | expect(mockIsDir).toHaveBeenCalledTimes(1); 111 | expect(mockIsDir).toHaveBeenCalledWith('/home/me/Library/Android/sdk'); 112 | }); 113 | }); 114 | }); 115 | -------------------------------------------------------------------------------- /src/android/utils/sdk/api.ts: -------------------------------------------------------------------------------- 1 | import * as Debug from 'debug'; 2 | 3 | import type { SDKPackage } from './'; 4 | 5 | const modulePrefix = 'native-run:android:utils:sdk:api'; 6 | 7 | export interface APILevel { 8 | readonly apiLevel: string; 9 | readonly packages: SDKPackage[]; 10 | } 11 | 12 | export async function getAPILevels(packages: SDKPackage[]): Promise { 13 | const debug = Debug(`${modulePrefix}:${getAPILevels.name}`); 14 | const levels = [ 15 | ...new Set( 16 | packages.map((pkg) => pkg.apiLevel).filter((apiLevel): apiLevel is string => typeof apiLevel !== 'undefined'), 17 | ), 18 | ].sort((a, b) => (a <= b ? 1 : -1)); 19 | 20 | const apis = levels.map((apiLevel) => ({ 21 | apiLevel, 22 | packages: packages.filter((pkg) => pkg.apiLevel === apiLevel), 23 | })); 24 | 25 | debug( 26 | 'Discovered installed API Levels: %O', 27 | apis.map((api) => ({ ...api, packages: api.packages.map((pkg) => pkg.path) })), 28 | ); 29 | 30 | return apis; 31 | } 32 | 33 | export function findUnsatisfiedPackages( 34 | packages: readonly SDKPackage[], 35 | schemas: readonly APISchemaPackage[], 36 | ): APISchemaPackage[] { 37 | return schemas.filter((pkg) => !findPackageBySchema(packages, pkg)); 38 | } 39 | 40 | export function findPackageBySchema(packages: readonly SDKPackage[], pkg: APISchemaPackage): SDKPackage | undefined { 41 | const apiPkg = findPackageBySchemaPath(packages, pkg.path); 42 | 43 | if (apiPkg) { 44 | if (typeof pkg.version === 'string') { 45 | if (pkg.version === apiPkg.version) { 46 | return apiPkg; 47 | } 48 | } else { 49 | if (apiPkg.version.match(pkg.version)) { 50 | return apiPkg; 51 | } 52 | } 53 | } 54 | } 55 | 56 | export function findPackageBySchemaPath( 57 | packages: readonly SDKPackage[], 58 | path: string | RegExp, 59 | ): SDKPackage | undefined { 60 | return packages.find((pkg) => { 61 | if (typeof path !== 'string') { 62 | return !!pkg.path.match(path); 63 | } 64 | 65 | return path === pkg.path; 66 | }); 67 | } 68 | 69 | export interface APISchemaPackage { 70 | readonly name: string; 71 | readonly path: string; 72 | readonly version: string | RegExp; 73 | } 74 | 75 | export interface APISchema { 76 | readonly apiLevel: string; 77 | readonly validate: (packages: readonly SDKPackage[]) => APISchemaPackage[]; 78 | } 79 | -------------------------------------------------------------------------------- /src/android/utils/sdk/index.ts: -------------------------------------------------------------------------------- 1 | import { mkdirp, readdirp } from '@ionic/utils-fs'; 2 | import * as Debug from 'debug'; 3 | import * as os from 'os'; 4 | import * as pathlib from 'path'; 5 | 6 | import { 7 | ERR_EMULATOR_HOME_NOT_FOUND, 8 | ERR_SDK_NOT_FOUND, 9 | ERR_SDK_PACKAGE_NOT_FOUND, 10 | SDKException, 11 | } from '../../../errors'; 12 | import { isDir } from '../../../utils/fs'; 13 | 14 | import { 15 | getAPILevelFromPackageXml, 16 | getNameFromPackageXml, 17 | getPathFromPackageXml, 18 | getVersionFromPackageXml, 19 | readPackageXml, 20 | } from './xml'; 21 | 22 | const modulePrefix = 'native-run:android:utils:sdk'; 23 | 24 | const homedir = os.homedir(); 25 | export const SDK_DIRECTORIES: ReadonlyMap = new Map< 26 | NodeJS.Platform, 27 | string[] | undefined 28 | >([ 29 | ['darwin', [pathlib.join(homedir, 'Library', 'Android', 'sdk')]], 30 | ['linux', [pathlib.join(homedir, 'Android', 'sdk')]], 31 | ['win32', [pathlib.join(process.env.LOCALAPPDATA || pathlib.join(homedir, 'AppData', 'Local'), 'Android', 'Sdk')]], 32 | ]); 33 | 34 | export interface SDK { 35 | readonly root: string; 36 | readonly emulatorHome: string; 37 | readonly avdHome: string; 38 | packages?: SDKPackage[]; 39 | } 40 | 41 | export async function getSDK(): Promise { 42 | const root = await resolveSDKRoot(); 43 | const emulatorHome = await resolveEmulatorHome(); 44 | const avdHome = await resolveAVDHome(); 45 | 46 | return { root, emulatorHome, avdHome }; 47 | } 48 | 49 | export interface SDKPackage { 50 | readonly path: string; 51 | readonly location: string; 52 | readonly version: string; 53 | readonly name: string; 54 | readonly apiLevel?: string; 55 | } 56 | 57 | const pkgcache = new Map(); 58 | 59 | export async function findAllSDKPackages(sdk: SDK): Promise { 60 | const debug = Debug(`${modulePrefix}:${findAllSDKPackages.name}`); 61 | 62 | if (sdk.packages) { 63 | return sdk.packages; 64 | } 65 | 66 | const sourcesRe = /^sources\/android-\d+\/.+\/.+/; 67 | debug('Walking %s to discover SDK packages', sdk.root); 68 | const contents = await readdirp(sdk.root, { 69 | filter: (item) => pathlib.basename(item.path) === 'package.xml', 70 | onError: (err) => debug('Error while walking SDK: %O', err), 71 | walkerOptions: { 72 | pathFilter: (p) => { 73 | if ( 74 | [ 75 | 'bin', 76 | 'bin64', 77 | 'lib', 78 | 'lib64', 79 | 'include', 80 | 'clang-include', 81 | 'skins', 82 | 'data', 83 | 'examples', 84 | 'resources', 85 | 'systrace', 86 | 'extras', 87 | // 'm2repository', 88 | ].includes(pathlib.basename(p)) 89 | ) { 90 | return false; 91 | } 92 | 93 | if (p.match(sourcesRe)) { 94 | return false; 95 | } 96 | 97 | return true; 98 | }, 99 | }, 100 | }); 101 | 102 | sdk.packages = await Promise.all(contents.map((p) => pathlib.dirname(p)).map((p) => getSDKPackage(p))); 103 | 104 | sdk.packages.sort((a, b) => (a.name >= b.name ? 1 : -1)); 105 | 106 | return sdk.packages; 107 | } 108 | 109 | export async function getSDKPackage(location: string): Promise { 110 | const debug = Debug(`${modulePrefix}:${getSDKPackage.name}`); 111 | let pkg = pkgcache.get(location); 112 | 113 | if (!pkg) { 114 | const packageXmlPath = pathlib.join(location, 'package.xml'); 115 | debug('Parsing %s', packageXmlPath); 116 | 117 | try { 118 | const packageXml = await readPackageXml(packageXmlPath); 119 | const name = getNameFromPackageXml(packageXml); 120 | const version = getVersionFromPackageXml(packageXml); 121 | const path = getPathFromPackageXml(packageXml); 122 | const apiLevel = getAPILevelFromPackageXml(packageXml); 123 | 124 | pkg = { 125 | path, 126 | location, 127 | version, 128 | name, 129 | apiLevel, 130 | }; 131 | } catch (e: any) { 132 | debug('Encountered error with %s: %O', packageXmlPath, e); 133 | 134 | if (e.code === 'ENOENT') { 135 | throw new SDKException(`SDK package not found by location: ${location}.`, ERR_SDK_PACKAGE_NOT_FOUND); 136 | } 137 | 138 | throw e; 139 | } 140 | 141 | pkgcache.set(location, pkg); 142 | } 143 | 144 | return pkg; 145 | } 146 | 147 | export async function resolveSDKRoot(): Promise { 148 | const debug = Debug(`${modulePrefix}:${resolveSDKRoot.name}`); 149 | debug('Looking for $ANDROID_HOME'); 150 | 151 | // $ANDROID_HOME is deprecated, but still overrides $ANDROID_SDK_ROOT if 152 | // defined and valid. 153 | if (process.env.ANDROID_HOME && (await isDir(process.env.ANDROID_HOME))) { 154 | debug('Using $ANDROID_HOME at %s', process.env.ANDROID_HOME); 155 | return process.env.ANDROID_HOME; 156 | } 157 | 158 | debug('Looking for $ANDROID_SDK_ROOT'); 159 | 160 | // No valid $ANDROID_HOME, try $ANDROID_SDK_ROOT. 161 | if (process.env.ANDROID_SDK_ROOT && (await isDir(process.env.ANDROID_SDK_ROOT))) { 162 | debug('Using $ANDROID_SDK_ROOT at %s', process.env.ANDROID_SDK_ROOT); 163 | return process.env.ANDROID_SDK_ROOT; 164 | } 165 | 166 | const sdkDirs = SDK_DIRECTORIES.get(process.platform); 167 | 168 | if (!sdkDirs) { 169 | throw new SDKException(`Unsupported platform: ${process.platform}`); 170 | } 171 | 172 | debug('Looking at following directories: %O', sdkDirs); 173 | 174 | for (const sdkDir of sdkDirs) { 175 | if (await isDir(sdkDir)) { 176 | debug('Using %s', sdkDir); 177 | return sdkDir; 178 | } 179 | } 180 | 181 | throw new SDKException(`No valid Android SDK root found.`, ERR_SDK_NOT_FOUND); 182 | } 183 | 184 | export async function resolveEmulatorHome(): Promise { 185 | const debug = Debug(`${modulePrefix}:${resolveEmulatorHome.name}`); 186 | debug('Looking for $ANDROID_EMULATOR_HOME'); 187 | 188 | if (process.env.ANDROID_EMULATOR_HOME && (await isDir(process.env.ANDROID_EMULATOR_HOME))) { 189 | debug('Using $ANDROID_EMULATOR_HOME at %s', process.env.ANDROID_EMULATOR_HOME); 190 | return process.env.ANDROID_EMULATOR_HOME; 191 | } 192 | 193 | debug('Looking at $HOME/.android'); 194 | 195 | const homeEmulatorHome = pathlib.join(homedir, '.android'); 196 | 197 | if (await isDir(homeEmulatorHome)) { 198 | debug('Using $HOME/.android/ at %s', homeEmulatorHome); 199 | return homeEmulatorHome; 200 | } 201 | 202 | throw new SDKException(`No valid Android Emulator home found.`, ERR_EMULATOR_HOME_NOT_FOUND); 203 | } 204 | 205 | export async function resolveAVDHome(): Promise { 206 | const debug = Debug(`${modulePrefix}:${resolveAVDHome.name}`); 207 | 208 | debug('Looking for $ANDROID_AVD_HOME'); 209 | 210 | if (process.env.ANDROID_AVD_HOME && (await isDir(process.env.ANDROID_AVD_HOME))) { 211 | debug('Using $ANDROID_AVD_HOME at %s', process.env.ANDROID_AVD_HOME); 212 | return process.env.ANDROID_AVD_HOME; 213 | } 214 | 215 | debug('Looking at $HOME/.android/avd'); 216 | 217 | const homeAvdHome = pathlib.join(homedir, '.android', 'avd'); 218 | 219 | if (!(await isDir(homeAvdHome))) { 220 | debug('Creating directory: %s', homeAvdHome); 221 | await mkdirp(homeAvdHome); 222 | } 223 | 224 | debug('Using $HOME/.android/avd/ at %s', homeAvdHome); 225 | return homeAvdHome; 226 | } 227 | 228 | export function supplementProcessEnv(sdk: SDK): NodeJS.ProcessEnv { 229 | return { 230 | ...process.env, 231 | ANDROID_SDK_ROOT: sdk.root, 232 | ANDROID_EMULATOR_HOME: sdk.emulatorHome, 233 | ANDROID_AVD_HOME: sdk.avdHome, 234 | }; 235 | } 236 | -------------------------------------------------------------------------------- /src/android/utils/sdk/xml.ts: -------------------------------------------------------------------------------- 1 | import { readFile } from '@ionic/utils-fs'; 2 | import type { Element, ElementTree } from 'elementtree'; 3 | 4 | import { ERR_INVALID_SDK_PACKAGE, SDKException } from '../../../errors'; 5 | 6 | export function getAPILevelFromPackageXml(packageXml: ElementTree): string | undefined { 7 | const apiLevel = packageXml.find('./localPackage/type-details/api-level'); 8 | 9 | return apiLevel?.text?.toString(); 10 | } 11 | 12 | export async function readPackageXml(path: string): Promise { 13 | const et = await import('elementtree'); 14 | const contents = await readFile(path, { encoding: 'utf8' }); 15 | const etree = et.parse(contents); 16 | 17 | return etree; 18 | } 19 | 20 | export function getPathFromPackageXml(packageXml: ElementTree): string { 21 | const localPackage = packageXml.find('./localPackage'); 22 | 23 | if (!localPackage) { 24 | throw new SDKException(`Invalid SDK package.`, ERR_INVALID_SDK_PACKAGE); 25 | } 26 | 27 | const path = localPackage.get('path'); 28 | 29 | if (!path) { 30 | throw new SDKException(`Invalid SDK package path.`, ERR_INVALID_SDK_PACKAGE); 31 | } 32 | 33 | return path.toString(); 34 | } 35 | 36 | export function getNameFromPackageXml(packageXml: ElementTree): string { 37 | const name = packageXml.find('./localPackage/display-name'); 38 | 39 | if (!name?.text) { 40 | throw new SDKException(`Invalid SDK package name.`, ERR_INVALID_SDK_PACKAGE); 41 | } 42 | 43 | return name.text.toString(); 44 | } 45 | 46 | export function getVersionFromPackageXml(packageXml: ElementTree): string { 47 | const versionElements = [ 48 | packageXml.find('./localPackage/revision/major'), 49 | packageXml.find('./localPackage/revision/minor'), 50 | packageXml.find('./localPackage/revision/micro'), 51 | ]; 52 | 53 | const textFromElement = (e: Element | null): string => (e?.text ? e.text.toString() : ''); 54 | const versions: string[] = []; 55 | 56 | for (const version of versionElements.map(textFromElement)) { 57 | if (!version) { 58 | break; 59 | } 60 | 61 | versions.push(version); 62 | } 63 | 64 | if (versions.length === 0) { 65 | throw new SDKException(`Invalid SDK package version.`, ERR_INVALID_SDK_PACKAGE); 66 | } 67 | 68 | return versions.join('.'); 69 | } 70 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | import * as pathlib from 'path'; 2 | 3 | export const ASSETS_PATH = pathlib.resolve(__dirname, '..', 'assets'); 4 | -------------------------------------------------------------------------------- /src/errors.ts: -------------------------------------------------------------------------------- 1 | import { stringify } from './utils/json'; 2 | 3 | export const enum ExitCode { 4 | GENERAL = 1, 5 | } 6 | 7 | export class Exception extends Error implements NodeJS.ErrnoException { 8 | constructor( 9 | readonly message: string, 10 | readonly code?: T, 11 | readonly exitCode = ExitCode.GENERAL, 12 | readonly data?: D, 13 | ) { 14 | super(message); 15 | } 16 | 17 | serialize(): string { 18 | return `${this.code ? this.code : 'ERR_UNKNOWN'}: ${this.message}`; 19 | } 20 | 21 | toJSON(): any { 22 | return { 23 | error: this.message, 24 | code: this.code, 25 | ...this.data, 26 | }; 27 | } 28 | } 29 | 30 | export class AndroidException extends Exception { 31 | serialize(): string { 32 | return ( 33 | `${super.serialize()}\n\n` + 34 | `\tMore details for this error may be available online:\n\n` + 35 | `\thttps://github.com/ionic-team/native-run/wiki/Android-Errors` 36 | ); 37 | } 38 | } 39 | 40 | export const ERR_BAD_INPUT = 'ERR_BAD_INPUT'; 41 | export const ERR_ALREADY_RUNNING = 'ERR_ALREADY_RUNNING '; 42 | export const ERR_AVD_HOME_NOT_FOUND = 'ERR_AVD_HOME_NOT_FOUND'; 43 | export const ERR_EMULATOR_HOME_NOT_FOUND = 'ERR_EMULATOR_HOME_NOT_FOUND'; 44 | export const ERR_INCOMPATIBLE_UPDATE = 'ERR_INCOMPATIBLE_UPDATE'; 45 | export const ERR_VERSION_DOWNGRADE = 'ERR_VERSION_DOWNGRADE'; 46 | export const ERR_MIN_SDK_VERSION = 'ERR_MIN_SDK_VERSION'; 47 | export const ERR_NO_CERTIFICATES = 'ERR_NO_CERTIFICATES'; 48 | export const ERR_NOT_ENOUGH_SPACE = 'ERR_NOT_ENOUGH_SPACE'; 49 | export const ERR_DEVICE_OFFLINE = 'ERR_DEVICE_OFFLINE'; 50 | export const ERR_INVALID_SDK_PACKAGE = 'ERR_INVALID_SDK_PACKAGE'; 51 | export const ERR_NON_ZERO_EXIT = 'ERR_NON_ZERO_EXIT'; 52 | export const ERR_UNSUITABLE_API_INSTALLATION = 'ERR_UNSUITABLE_API_INSTALLATION'; 53 | export const ERR_SDK_NOT_FOUND = 'ERR_SDK_NOT_FOUND'; 54 | export const ERR_SDK_PACKAGE_NOT_FOUND = 'ERR_SDK_PACKAGE_NOT_FOUND'; 55 | export const ERR_TARGET_NOT_FOUND = 'ERR_TARGET_NOT_FOUND'; 56 | export const ERR_NO_DEVICE = 'ERR_NO_DEVICE'; 57 | export const ERR_NO_TARGET = 'ERR_NO_TARGET'; 58 | export const ERR_DEVICE_LOCKED = 'ERR_DEVICE_LOCKED'; 59 | export const ERR_UNKNOWN_AVD = 'ERR_UNKNOWN_AVD'; 60 | 61 | export type CLIExceptionCode = typeof ERR_BAD_INPUT; 62 | 63 | export class CLIException extends Exception {} 64 | 65 | export type ADBExceptionCode = 66 | | typeof ERR_INCOMPATIBLE_UPDATE 67 | | typeof ERR_VERSION_DOWNGRADE 68 | | typeof ERR_MIN_SDK_VERSION 69 | | typeof ERR_NO_CERTIFICATES 70 | | typeof ERR_NOT_ENOUGH_SPACE 71 | | typeof ERR_DEVICE_OFFLINE 72 | | typeof ERR_NON_ZERO_EXIT; 73 | 74 | export class ADBException extends AndroidException {} 75 | 76 | export type AVDExceptionCode = typeof ERR_UNSUITABLE_API_INSTALLATION; 77 | 78 | export class AVDException extends AndroidException {} 79 | 80 | export type EmulatorExceptionCode = 81 | | typeof ERR_ALREADY_RUNNING 82 | | typeof ERR_AVD_HOME_NOT_FOUND 83 | | typeof ERR_NON_ZERO_EXIT 84 | | typeof ERR_UNKNOWN_AVD; 85 | 86 | export class EmulatorException extends AndroidException {} 87 | 88 | export type AndroidRunExceptionCode = typeof ERR_TARGET_NOT_FOUND | typeof ERR_NO_DEVICE | typeof ERR_NO_TARGET; 89 | 90 | export class AndroidRunException extends AndroidException {} 91 | 92 | export type SDKExceptionCode = 93 | | typeof ERR_EMULATOR_HOME_NOT_FOUND 94 | | typeof ERR_INVALID_SDK_PACKAGE 95 | | typeof ERR_SDK_NOT_FOUND 96 | | typeof ERR_SDK_PACKAGE_NOT_FOUND; 97 | 98 | export class SDKException extends AndroidException {} 99 | 100 | export type IOSRunExceptionCode = 101 | | typeof ERR_TARGET_NOT_FOUND 102 | | typeof ERR_NO_DEVICE 103 | | typeof ERR_NO_TARGET 104 | | typeof ERR_DEVICE_LOCKED; 105 | 106 | export class IOSRunException extends Exception {} 107 | 108 | export function serializeError(e = new Error()): string { 109 | const stack = String(e.stack ? e.stack : e); 110 | 111 | if (process.argv.includes('--json')) { 112 | return stringify(e instanceof Exception ? e : { error: stack }); 113 | } 114 | 115 | return (e instanceof Exception ? e.serialize() : stack) + '\n'; 116 | } 117 | -------------------------------------------------------------------------------- /src/help.ts: -------------------------------------------------------------------------------- 1 | const help = ` 2 | Usage: native-run [ios|android] [options] 3 | 4 | Options: 5 | 6 | -h, --help ........... Print help for the platform, then quit 7 | --version ............ Print version, then quit 8 | --verbose ............ Print verbose output to stderr 9 | --list ............... Print connected devices and virtual devices 10 | 11 | `; 12 | 13 | export async function run(args: readonly string[]): Promise { 14 | process.stdout.write(help); 15 | } 16 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import * as Debug from 'debug'; 2 | import * as path from 'path'; 3 | 4 | import { CLIException, ERR_BAD_INPUT, Exception, ExitCode, serializeError } from './errors'; 5 | 6 | const debug = Debug('native-run'); 7 | 8 | export interface Command { 9 | readonly run: (args: readonly string[]) => Promise; 10 | } 11 | 12 | export async function run(): Promise { 13 | const args = process.argv.slice(2); 14 | 15 | if (args.includes('--version')) { 16 | const pkg = await import(path.resolve(__dirname, '../package.json')); 17 | process.stdout.write(pkg.version + '\n'); 18 | return; 19 | } 20 | 21 | let cmd: Command; 22 | const [platform, ...platformArgs] = args; 23 | 24 | try { 25 | if (platform === 'android') { 26 | cmd = await import('./android'); 27 | await cmd.run(platformArgs); 28 | } else if (platform === 'ios') { 29 | cmd = await import('./ios'); 30 | await cmd.run(platformArgs); 31 | } else if (platform === '--list') { 32 | cmd = await import('./list'); 33 | await cmd.run(args); 34 | } else { 35 | if ( 36 | !platform || 37 | platform === 'help' || 38 | args.includes('--help') || 39 | args.includes('-h') || 40 | platform.startsWith('-') 41 | ) { 42 | cmd = await import('./help'); 43 | return cmd.run(args); 44 | } 45 | 46 | throw new CLIException(`Unsupported platform: "${platform}"`, ERR_BAD_INPUT); 47 | } 48 | } catch (e: any) { 49 | debug('Caught fatal error: %O', e); 50 | process.exitCode = e instanceof Exception ? e.exitCode : ExitCode.GENERAL; 51 | process.stdout.write(serializeError(e)); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/ios/help.ts: -------------------------------------------------------------------------------- 1 | const help = ` 2 | Usage: native-run ios [options] 3 | 4 | Run an .app or .ipa on a device or simulator target 5 | 6 | Targets are selected as follows: 7 | 1) --target using device/simulator UUID 8 | 2) A connected device, unless --virtual is used 9 | 3) A running simulator 10 | 11 | If the above criteria are not met, the app is run on the default simulator 12 | (the last simulator in the list). 13 | 14 | Use --list to list available targets. 15 | 16 | Options: 17 | 18 | --list ............... Print available targets, then quit 19 | --json ............... Output JSON 20 | 21 | --app ......... Deploy specified .app or .ipa file 22 | --device ............. Use a device if available 23 | With --list prints connected devices 24 | --virtual ............ Prefer a simulator 25 | With --list prints available simulators 26 | --target ........ Use a specific target 27 | --connect ............ Tie process to app process 28 | `; 29 | 30 | export async function run(): Promise { 31 | process.stdout.write(`${help}\n`); 32 | } 33 | -------------------------------------------------------------------------------- /src/ios/index.ts: -------------------------------------------------------------------------------- 1 | import type { Command } from '../'; 2 | 3 | export async function run(args: readonly string[]): Promise { 4 | let cmd: Command; 5 | 6 | if (args.includes('--help') || args.includes('-h')) { 7 | cmd = await import('./help'); 8 | return cmd.run(args); 9 | } 10 | 11 | if (args.includes('--list') || args.includes('-l')) { 12 | cmd = await import('./list'); 13 | return cmd.run(args); 14 | } 15 | 16 | cmd = await import('./run'); 17 | await cmd.run(args); 18 | } 19 | -------------------------------------------------------------------------------- /src/ios/lib/client/afc.ts: -------------------------------------------------------------------------------- 1 | import * as Debug from 'debug'; 2 | import * as fs from 'fs'; 3 | import type * as net from 'net'; 4 | import * as path from 'path'; 5 | import { promisify } from 'util'; 6 | 7 | import type { AFCError, AFCResponse } from '../protocol/afc'; 8 | import { AFCProtocolClient, AFC_FILE_OPEN_FLAGS, AFC_OPS, AFC_STATUS } from '../protocol/afc'; 9 | 10 | import { ServiceClient } from './client'; 11 | 12 | const debug = Debug('native-run:ios:lib:client:afc'); 13 | const MAX_OPEN_FILES = 240; 14 | 15 | export class AFCClient extends ServiceClient { 16 | constructor(public socket: net.Socket) { 17 | super(socket, new AFCProtocolClient(socket)); 18 | } 19 | 20 | async getFileInfo(path: string): Promise { 21 | debug(`getFileInfo: ${path}`); 22 | 23 | const resp = await this.protocolClient.sendMessage({ 24 | operation: AFC_OPS.GET_FILE_INFO, 25 | data: toCString(path), 26 | }); 27 | 28 | const strings: string[] = []; 29 | let currentString = ''; 30 | const tokens = resp.data; 31 | tokens.forEach((token) => { 32 | if (token === 0) { 33 | strings.push(currentString); 34 | currentString = ''; 35 | } else { 36 | currentString += String.fromCharCode(token); 37 | } 38 | }); 39 | return strings; 40 | } 41 | 42 | async writeFile(fd: Buffer, data: Buffer): Promise { 43 | debug(`writeFile: ${Array.prototype.toString.call(fd)}`); 44 | 45 | return this.protocolClient.sendMessage({ 46 | operation: AFC_OPS.FILE_WRITE, 47 | data: fd, 48 | payload: data, 49 | }); 50 | } 51 | 52 | async openFile(path: string): Promise { 53 | debug(`openFile: ${path}`); 54 | // mode + path + null terminator 55 | const data = Buffer.alloc(8 + Buffer.byteLength(path) + 1); 56 | // write mode 57 | data.writeUInt32LE(AFC_FILE_OPEN_FLAGS.WRONLY, 0); 58 | // then path to file 59 | toCString(path).copy(data, 8); 60 | 61 | const resp = await this.protocolClient.sendMessage({ 62 | operation: AFC_OPS.FILE_OPEN, 63 | data, 64 | }); 65 | 66 | if (resp.operation === AFC_OPS.FILE_OPEN_RES) { 67 | return resp.data; 68 | } 69 | 70 | throw new Error( 71 | `There was an unknown error opening file ${path}, response: ${Array.prototype.toString.call(resp.data)}`, 72 | ); 73 | } 74 | 75 | async closeFile(fd: Buffer): Promise { 76 | debug(`closeFile fd: ${Array.prototype.toString.call(fd)}`); 77 | return this.protocolClient.sendMessage({ 78 | operation: AFC_OPS.FILE_CLOSE, 79 | data: fd, 80 | }); 81 | } 82 | 83 | async uploadFile(srcPath: string, destPath: string): Promise { 84 | debug(`uploadFile: ${srcPath}`); 85 | 86 | // read local file and get fd of destination 87 | const [srcFile, destFile] = await Promise.all([ 88 | await promisify(fs.readFile)(srcPath), 89 | await this.openFile(destPath), 90 | ]); 91 | 92 | try { 93 | await this.writeFile(destFile, srcFile); 94 | await this.closeFile(destFile); 95 | } catch (err) { 96 | await this.closeFile(destFile); 97 | throw err; 98 | } 99 | } 100 | 101 | async makeDirectory(path: string): Promise { 102 | debug(`makeDirectory: ${path}`); 103 | 104 | return this.protocolClient.sendMessage({ 105 | operation: AFC_OPS.MAKE_DIR, 106 | data: toCString(path), 107 | }); 108 | } 109 | 110 | async uploadDirectory(srcPath: string, destPath: string): Promise { 111 | debug(`uploadDirectory: ${srcPath}`); 112 | await this.makeDirectory(destPath); 113 | 114 | // AFC doesn't seem to give out more than 240 file handles, 115 | // so we delay any requests that would push us over until more open up 116 | let numOpenFiles = 0; 117 | const pendingFileUploads: (() => void)[] = []; 118 | const _this = this; 119 | return uploadDir(srcPath); 120 | 121 | async function uploadDir(dirPath: string): Promise { 122 | const promises: Promise[] = []; 123 | for (const file of fs.readdirSync(dirPath)) { 124 | const filePath = path.join(dirPath, file); 125 | const remotePath = path.join(destPath, path.relative(srcPath, filePath)); 126 | if (fs.lstatSync(filePath).isDirectory()) { 127 | promises.push(_this.makeDirectory(remotePath).then(() => uploadDir(filePath))); 128 | } else { 129 | // Create promise to add to promises array 130 | // this way it can be resolved once a pending upload has finished 131 | let resolve: (val?: any) => void; 132 | let reject: (err: AFCError) => void; 133 | const promise = new Promise((res, rej) => { 134 | resolve = res; 135 | reject = rej; 136 | }); 137 | promises.push(promise); 138 | 139 | // wrap upload in a function in case we need to save it for later 140 | const uploadFile = (tries = 0) => { 141 | numOpenFiles++; 142 | _this 143 | .uploadFile(filePath, remotePath) 144 | .then(() => { 145 | resolve(); 146 | numOpenFiles--; 147 | const fn = pendingFileUploads.pop(); 148 | if (fn) { 149 | fn(); 150 | } 151 | }) 152 | .catch((err: AFCError) => { 153 | // Couldn't get fd for whatever reason, try again 154 | // # of retries is arbitrary and can be adjusted 155 | if (err.status === AFC_STATUS.NO_RESOURCES && tries < 10) { 156 | debug(`Received NO_RESOURCES from AFC, retrying ${filePath} upload. ${tries}`); 157 | uploadFile(tries++); 158 | } else { 159 | numOpenFiles--; 160 | reject(err); 161 | } 162 | }); 163 | }; 164 | 165 | if (numOpenFiles < MAX_OPEN_FILES) { 166 | uploadFile(); 167 | } else { 168 | debug(`numOpenFiles >= ${MAX_OPEN_FILES}, adding to pending queue. Length: ${pendingFileUploads.length}`); 169 | pendingFileUploads.push(uploadFile); 170 | } 171 | } 172 | } 173 | await Promise.all(promises); 174 | } 175 | } 176 | } 177 | 178 | function toCString(s: string) { 179 | const buf = Buffer.alloc(Buffer.byteLength(s) + 1); 180 | const len = buf.write(s); 181 | buf.writeUInt8(0, len); 182 | return buf; 183 | } 184 | -------------------------------------------------------------------------------- /src/ios/lib/client/client.ts: -------------------------------------------------------------------------------- 1 | import type * as net from 'net'; 2 | 3 | import type { ProtocolClient } from '../protocol'; 4 | 5 | export abstract class ServiceClient { 6 | constructor( 7 | public socket: net.Socket, 8 | protected protocolClient: T, 9 | ) {} 10 | } 11 | 12 | export class ResponseError extends Error { 13 | constructor( 14 | msg: string, 15 | public response: any, 16 | ) { 17 | super(msg); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/ios/lib/client/debugserver.ts: -------------------------------------------------------------------------------- 1 | import * as Debug from 'debug'; 2 | import type * as net from 'net'; 3 | import * as path from 'path'; 4 | 5 | import { GDBProtocolClient } from '../protocol/gdb'; 6 | 7 | import { ServiceClient } from './client'; 8 | 9 | const debug = Debug('native-run:ios:lib:client:debugserver'); 10 | 11 | export class DebugserverClient extends ServiceClient { 12 | constructor(public socket: net.Socket) { 13 | super(socket, new GDBProtocolClient(socket)); 14 | } 15 | 16 | async setMaxPacketSize(size: number) { 17 | return this.sendCommand('QSetMaxPacketSize:', [size.toString()]); 18 | } 19 | 20 | async setWorkingDir(workingDir: string) { 21 | return this.sendCommand('QSetWorkingDir:', [workingDir]); 22 | } 23 | 24 | async checkLaunchSuccess() { 25 | return this.sendCommand('qLaunchSuccess', []); 26 | } 27 | 28 | async attachByName(name: string) { 29 | const hexName = Buffer.from(name).toString('hex'); 30 | return this.sendCommand(`vAttachName;${hexName}`, []); 31 | } 32 | 33 | async continue() { 34 | return this.sendCommand('c', []); 35 | } 36 | 37 | halt() { 38 | // ^C 39 | debug('Sending ^C to debugserver'); 40 | return this.protocolClient.socket.write('\u0003'); 41 | } 42 | 43 | async kill() { 44 | const msg: any = { cmd: 'k', args: [] }; 45 | return this.protocolClient.sendMessage(msg, (resp: string, resolve: any, reject: any) => { 46 | this.protocolClient.socket.write('+'); 47 | const parts = resp.split(';'); 48 | for (const part of parts) { 49 | if (part.includes('description')) { 50 | // description:{hex encoded message like: "Terminated with signal 9"} 51 | resolve(Buffer.from(part.split(':')[1], 'hex').toString('ascii')); 52 | } 53 | } 54 | }); 55 | } 56 | 57 | // TODO support app args 58 | // https://sourceware.org/gdb/onlinedocs/gdb/Packets.html#Packets 59 | // A arglen,argnum,arg, 60 | async launchApp(appPath: string, executableName: string) { 61 | const fullPath = path.join(appPath, executableName); 62 | const hexAppPath = Buffer.from(fullPath).toString('hex'); 63 | const appCommand = `A${hexAppPath.length},0,${hexAppPath}`; 64 | return this.sendCommand(appCommand, []); 65 | } 66 | 67 | async sendCommand(cmd: string, args: string[]) { 68 | const msg = { cmd, args }; 69 | debug(`Sending command: ${cmd}, args: ${args}`); 70 | const resp = await this.protocolClient.sendMessage(msg); 71 | // we need to ACK as well 72 | this.protocolClient.socket.write('+'); 73 | return resp; 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/ios/lib/client/index.ts: -------------------------------------------------------------------------------- 1 | export * from './client'; 2 | export * from './afc'; 3 | export * from './debugserver'; 4 | export * from './installation_proxy'; 5 | export * from './lockdownd'; 6 | export * from './mobile_image_mounter'; 7 | export * from './usbmuxd'; 8 | -------------------------------------------------------------------------------- /src/ios/lib/client/installation_proxy.ts: -------------------------------------------------------------------------------- 1 | import * as Debug from 'debug'; 2 | import type * as net from 'net'; 3 | 4 | import type { LockdownCommand, LockdownResponse } from '../protocol/lockdown'; 5 | import { LockdownProtocolClient } from '../protocol/lockdown'; 6 | 7 | import { ResponseError, ServiceClient } from './client'; 8 | 9 | const debug = Debug('native-run:ios:lib:client:installation_proxy'); 10 | 11 | interface IPOptions { 12 | ApplicationsType?: 'Any'; 13 | PackageType?: 'Developer'; 14 | CFBundleIdentifier?: string; 15 | ReturnAttributes?: ('CFBundleIdentifier' | 'CFBundleExecutable' | 'Container' | 'Path')[]; 16 | BundleIDs?: string[]; 17 | } 18 | 19 | interface IPInstallPercentCompleteResponseItem extends LockdownResponse { 20 | PercentComplete: number; 21 | } 22 | 23 | interface IPInstallCFBundleIdentifierResponseItem { 24 | CFBundleIdentifier: string; 25 | } 26 | 27 | interface IPInstallCompleteResponseItem extends LockdownResponse { 28 | Status: 'Complete'; 29 | } 30 | /* 31 | * [{ "PercentComplete": 5, "Status": "CreatingStagingDirectory" }] 32 | * ... 33 | * [{ "PercentComplete": 90, "Status": "GeneratingApplicationMap" }] 34 | * [{ "CFBundleIdentifier": "my.company.app" }] 35 | * [{ "Status": "Complete" }] 36 | */ 37 | type IPInstallPercentCompleteResponse = IPInstallPercentCompleteResponseItem[]; 38 | type IPInstallCFBundleIdentifierResponse = IPInstallCFBundleIdentifierResponseItem[]; 39 | type IPInstallCompleteResponse = IPInstallCompleteResponseItem[]; 40 | 41 | interface IPMessage extends LockdownCommand { 42 | Command: string; 43 | ClientOptions: IPOptions; 44 | } 45 | 46 | interface IPLookupResponseItem extends LockdownResponse { 47 | LookupResult: IPLookupResult; 48 | } 49 | /* 50 | * [{ 51 | * LookupResult: IPLookupResult, 52 | * Status: "Complete" 53 | * }] 54 | */ 55 | type IPLookupResponse = IPLookupResponseItem[]; 56 | 57 | export interface IPLookupResult { 58 | // BundleId 59 | [key: string]: { 60 | Container: string; 61 | CFBundleIdentifier: string; 62 | CFBundleExecutable: string; 63 | Path: string; 64 | }; 65 | } 66 | 67 | function isIPLookupResponse(resp: any): resp is IPLookupResponse { 68 | return resp.length && resp[0].LookupResult !== undefined; 69 | } 70 | 71 | function isIPInstallPercentCompleteResponse(resp: any): resp is IPInstallPercentCompleteResponse { 72 | return resp.length && resp[0].PercentComplete !== undefined; 73 | } 74 | 75 | function isIPInstallCFBundleIdentifierResponse(resp: any): resp is IPInstallCFBundleIdentifierResponse { 76 | return resp.length && resp[0].CFBundleIdentifier !== undefined; 77 | } 78 | 79 | function isIPInstallCompleteResponse(resp: any): resp is IPInstallCompleteResponse { 80 | return resp.length && resp[0].Status === 'Complete'; 81 | } 82 | 83 | export class InstallationProxyClient extends ServiceClient> { 84 | constructor(public socket: net.Socket) { 85 | super(socket, new LockdownProtocolClient(socket)); 86 | } 87 | 88 | async lookupApp( 89 | bundleIds: string[], 90 | options: IPOptions = { 91 | ReturnAttributes: ['Path', 'Container', 'CFBundleExecutable', 'CFBundleIdentifier'], 92 | ApplicationsType: 'Any', 93 | }, 94 | ) { 95 | debug(`lookupApp, options: ${JSON.stringify(options)}`); 96 | 97 | const resp = await this.protocolClient.sendMessage({ 98 | Command: 'Lookup', 99 | ClientOptions: { 100 | BundleIDs: bundleIds, 101 | ...options, 102 | }, 103 | }); 104 | if (isIPLookupResponse(resp)) { 105 | return resp[0].LookupResult; 106 | } else { 107 | throw new ResponseError(`There was an error looking up app`, resp); 108 | } 109 | } 110 | 111 | async installApp( 112 | packagePath: string, 113 | bundleId: string, 114 | options: IPOptions = { 115 | ApplicationsType: 'Any', 116 | PackageType: 'Developer', 117 | }, 118 | ) { 119 | debug(`installApp, packagePath: ${packagePath}, bundleId: ${bundleId}`); 120 | 121 | return this.protocolClient.sendMessage( 122 | { 123 | Command: 'Install', 124 | PackagePath: packagePath, 125 | ClientOptions: { 126 | CFBundleIdentifier: bundleId, 127 | ...options, 128 | }, 129 | }, 130 | (resp: any, resolve, reject) => { 131 | if (isIPInstallCompleteResponse(resp)) { 132 | resolve(); 133 | } else if (isIPInstallPercentCompleteResponse(resp)) { 134 | debug(`Installation status: ${resp[0].Status}, %${resp[0].PercentComplete}`); 135 | } else if (isIPInstallCFBundleIdentifierResponse(resp)) { 136 | debug(`Installed app: ${resp[0].CFBundleIdentifier}`); 137 | } else { 138 | reject(new ResponseError('There was an error installing app', resp)); 139 | } 140 | }, 141 | ); 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /src/ios/lib/client/lockdownd.ts: -------------------------------------------------------------------------------- 1 | import * as Debug from 'debug'; 2 | import type * as net from 'net'; 3 | import * as tls from 'tls'; 4 | 5 | import { LockdownProtocolClient } from '../protocol/lockdown'; 6 | 7 | import { ResponseError, ServiceClient } from './client'; 8 | import type { UsbmuxdPairRecord } from './usbmuxd'; 9 | 10 | const debug = Debug('native-run:ios:lib:client:lockdownd'); 11 | 12 | export interface DeviceValues { 13 | BasebandCertId: number; 14 | BasebandKeyHashInformation: { 15 | AKeyStatus: number; 16 | SKeyHash: Buffer; 17 | SKeyStatus: number; 18 | }; 19 | BasebandSerialNumber: Buffer; 20 | BasebandVersion: string; 21 | BoardId: number; 22 | BuildVersion: string; 23 | ChipID: number; 24 | DeviceClass: string; 25 | DeviceColor: string; 26 | DeviceName: string; 27 | DieID: number; 28 | HardwareModel: string; 29 | HasSiDP: boolean; 30 | PartitionType: string; 31 | ProductName: string; 32 | ProductType: string; 33 | ProductVersion: string; 34 | ProductionSOC: boolean; 35 | ProtocolVersion: string; 36 | TelephonyCapability: boolean; 37 | UniqueChipID: number; 38 | UniqueDeviceID: string; 39 | WiFiAddress: string; 40 | [key: string]: any; 41 | } 42 | 43 | interface LockdowndServiceResponse { 44 | Request: 'StartService'; 45 | Service: string; 46 | Port: number; 47 | EnableServiceSSL?: boolean; // Only on iOS 13+ 48 | } 49 | 50 | interface LockdowndSessionResponse { 51 | Request: 'StartSession'; 52 | EnableSessionSSL: boolean; 53 | } 54 | 55 | interface LockdowndAllValuesResponse { 56 | Request: 'GetValue'; 57 | Value: DeviceValues; 58 | } 59 | 60 | interface LockdowndValueResponse { 61 | Request: 'GetValue'; 62 | Key: string; 63 | Value: string; 64 | } 65 | 66 | interface LockdowndQueryTypeResponse { 67 | Request: 'QueryType'; 68 | Type: string; 69 | } 70 | 71 | function isLockdowndServiceResponse(resp: any): resp is LockdowndServiceResponse { 72 | return resp.Request === 'StartService' && resp.Service !== undefined && resp.Port !== undefined; 73 | } 74 | 75 | function isLockdowndSessionResponse(resp: any): resp is LockdowndSessionResponse { 76 | return resp.Request === 'StartSession'; 77 | } 78 | 79 | function isLockdowndAllValuesResponse(resp: any): resp is LockdowndAllValuesResponse { 80 | return resp.Request === 'GetValue' && resp.Value !== undefined; 81 | } 82 | 83 | function isLockdowndValueResponse(resp: any): resp is LockdowndValueResponse { 84 | return resp.Request === 'GetValue' && resp.Key !== undefined && typeof resp.Value === 'string'; 85 | } 86 | 87 | function isLockdowndQueryTypeResponse(resp: any): resp is LockdowndQueryTypeResponse { 88 | return resp.Request === 'QueryType' && resp.Type !== undefined; 89 | } 90 | 91 | export class LockdowndClient extends ServiceClient { 92 | constructor(public socket: net.Socket) { 93 | super(socket, new LockdownProtocolClient(socket)); 94 | } 95 | 96 | async startService(name: string) { 97 | debug(`startService: ${name}`); 98 | 99 | const resp = await this.protocolClient.sendMessage({ 100 | Request: 'StartService', 101 | Service: name, 102 | }); 103 | 104 | if (isLockdowndServiceResponse(resp)) { 105 | return { port: resp.Port, enableServiceSSL: !!resp.EnableServiceSSL }; 106 | } else { 107 | throw new ResponseError(`Error starting service ${name}`, resp); 108 | } 109 | } 110 | 111 | async startSession(pairRecord: UsbmuxdPairRecord) { 112 | debug(`startSession: ${pairRecord}`); 113 | 114 | const resp = await this.protocolClient.sendMessage({ 115 | Request: 'StartSession', 116 | HostID: pairRecord.HostID, 117 | SystemBUID: pairRecord.SystemBUID, 118 | }); 119 | 120 | if (isLockdowndSessionResponse(resp)) { 121 | if (resp.EnableSessionSSL) { 122 | this.protocolClient.socket = new tls.TLSSocket(this.protocolClient.socket, { 123 | secureContext: tls.createSecureContext({ 124 | secureProtocol: 'TLSv1_2_method', 125 | cert: pairRecord.RootCertificate, 126 | key: pairRecord.RootPrivateKey, 127 | }), 128 | }); 129 | debug(`Socket upgraded to TLS connection`); 130 | } 131 | // TODO: save sessionID for StopSession? 132 | } else { 133 | throw new ResponseError('Error starting session', resp); 134 | } 135 | } 136 | 137 | async getAllValues() { 138 | debug(`getAllValues`); 139 | 140 | const resp = await this.protocolClient.sendMessage({ Request: 'GetValue' }); 141 | 142 | if (isLockdowndAllValuesResponse(resp)) { 143 | return resp.Value; 144 | } else { 145 | throw new ResponseError('Error getting lockdown value', resp); 146 | } 147 | } 148 | 149 | async getValue(val: string) { 150 | debug(`getValue: ${val}`); 151 | 152 | const resp = await this.protocolClient.sendMessage({ 153 | Request: 'GetValue', 154 | Key: val, 155 | }); 156 | 157 | if (isLockdowndValueResponse(resp)) { 158 | return resp.Value; 159 | } else { 160 | throw new ResponseError('Error getting lockdown value', resp); 161 | } 162 | } 163 | 164 | async queryType() { 165 | debug('queryType'); 166 | 167 | const resp = await this.protocolClient.sendMessage({ 168 | Request: 'QueryType', 169 | }); 170 | 171 | if (isLockdowndQueryTypeResponse(resp)) { 172 | return resp.Type; 173 | } else { 174 | throw new ResponseError('Error getting lockdown query type', resp); 175 | } 176 | } 177 | 178 | async doHandshake(pairRecord: UsbmuxdPairRecord) { 179 | debug('doHandshake'); 180 | 181 | // if (await this.lockdownQueryType() !== 'com.apple.mobile.lockdown') { 182 | // throw new Error('Invalid type received from lockdown handshake'); 183 | // } 184 | // await this.getLockdownValue('ProductVersion'); 185 | // TODO: validate pair and pair 186 | await this.startSession(pairRecord); 187 | } 188 | } 189 | -------------------------------------------------------------------------------- /src/ios/lib/client/mobile_image_mounter.ts: -------------------------------------------------------------------------------- 1 | import * as Debug from 'debug'; 2 | import * as fs from 'fs'; 3 | import type * as net from 'net'; 4 | 5 | import type { LockdownCommand, LockdownResponse } from '../protocol/lockdown'; 6 | import { LockdownProtocolClient, isLockdownResponse } from '../protocol/lockdown'; 7 | 8 | import { ResponseError, ServiceClient } from './client'; 9 | 10 | const debug = Debug('native-run:ios:lib:client:mobile_image_mounter'); 11 | 12 | export type MIMMountResponse = LockdownResponse; 13 | 14 | export interface MIMMessage extends LockdownCommand { 15 | ImageType: string; 16 | } 17 | 18 | export interface MIMLookupResponse extends LockdownResponse { 19 | ImageSignature?: string; 20 | } 21 | 22 | export interface MIMUploadCompleteResponse extends LockdownResponse { 23 | Status: 'Complete'; 24 | } 25 | 26 | export interface MIMUploadReceiveBytesResponse extends LockdownResponse { 27 | Status: 'ReceiveBytesAck'; 28 | } 29 | 30 | function isMIMUploadCompleteResponse(resp: any): resp is MIMUploadCompleteResponse { 31 | return resp.Status === 'Complete'; 32 | } 33 | 34 | function isMIMUploadReceiveBytesResponse(resp: any): resp is MIMUploadReceiveBytesResponse { 35 | return resp.Status === 'ReceiveBytesAck'; 36 | } 37 | 38 | export class MobileImageMounterClient extends ServiceClient> { 39 | constructor(socket: net.Socket) { 40 | super(socket, new LockdownProtocolClient(socket)); 41 | } 42 | 43 | async mountImage(imagePath: string, imageSig: Buffer) { 44 | debug(`mountImage: ${imagePath}`); 45 | 46 | const resp = await this.protocolClient.sendMessage({ 47 | Command: 'MountImage', 48 | ImagePath: imagePath, 49 | ImageSignature: imageSig, 50 | ImageType: 'Developer', 51 | }); 52 | 53 | if (!isLockdownResponse(resp) || resp.Status !== 'Complete') { 54 | throw new ResponseError(`There was an error mounting ${imagePath} on device`, resp); 55 | } 56 | } 57 | 58 | async uploadImage(imagePath: string, imageSig: Buffer) { 59 | debug(`uploadImage: ${imagePath}`); 60 | 61 | const imageSize = fs.statSync(imagePath).size; 62 | return this.protocolClient.sendMessage( 63 | { 64 | Command: 'ReceiveBytes', 65 | ImageSize: imageSize, 66 | ImageSignature: imageSig, 67 | ImageType: 'Developer', 68 | }, 69 | (resp: any, resolve, reject) => { 70 | if (isMIMUploadReceiveBytesResponse(resp)) { 71 | const imageStream = fs.createReadStream(imagePath); 72 | imageStream.pipe(this.protocolClient.socket, { end: false }); 73 | imageStream.on('error', (err) => reject(err)); 74 | } else if (isMIMUploadCompleteResponse(resp)) { 75 | resolve(); 76 | } else { 77 | reject(new ResponseError(`There was an error uploading image ${imagePath} to the device`, resp)); 78 | } 79 | }, 80 | ); 81 | } 82 | 83 | async lookupImage() { 84 | debug('lookupImage'); 85 | 86 | return this.protocolClient.sendMessage({ 87 | Command: 'LookupImage', 88 | ImageType: 'Developer', 89 | }); 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/ios/lib/client/usbmuxd.ts: -------------------------------------------------------------------------------- 1 | import * as Debug from 'debug'; 2 | import * as net from 'net'; 3 | import * as plist from 'plist'; 4 | 5 | import { UsbmuxProtocolClient } from '../protocol/usbmux'; 6 | 7 | import { ResponseError, ServiceClient } from './client'; 8 | 9 | const debug = Debug('native-run:ios:lib:client:usbmuxd'); 10 | 11 | export interface UsbmuxdDeviceProperties { 12 | ConnectionSpeed: number; 13 | ConnectionType: 'USB'; 14 | DeviceID: number; 15 | LocationID: number; 16 | ProductID: number; 17 | SerialNumber: string; 18 | } 19 | 20 | export interface UsbmuxdDevice { 21 | DeviceID: number; 22 | MessageType: 'Attached'; // TODO: what else? 23 | Properties: UsbmuxdDeviceProperties; 24 | } 25 | 26 | export interface UsbmuxdConnectResponse { 27 | MessageType: 'Result'; 28 | Number: number; 29 | } 30 | 31 | export interface UsbmuxdDeviceResponse { 32 | DeviceList: UsbmuxdDevice[]; 33 | } 34 | 35 | export interface UsbmuxdPairRecordResponse { 36 | PairRecordData: Buffer; 37 | } 38 | 39 | export interface UsbmuxdPairRecord { 40 | DeviceCertificate: Buffer; 41 | EscrowBag: Buffer; 42 | HostCertificate: Buffer; 43 | HostID: string; 44 | HostPrivateKey: Buffer; 45 | RootCertificate: Buffer; 46 | RootPrivateKey: Buffer; 47 | SystemBUID: string; 48 | WiFiMACAddress: string; 49 | } 50 | 51 | function isUsbmuxdConnectResponse(resp: any): resp is UsbmuxdConnectResponse { 52 | return resp.MessageType === 'Result' && resp.Number !== undefined; 53 | } 54 | 55 | function isUsbmuxdDeviceResponse(resp: any): resp is UsbmuxdDeviceResponse { 56 | return resp.DeviceList !== undefined; 57 | } 58 | 59 | function isUsbmuxdPairRecordResponse(resp: any): resp is UsbmuxdPairRecordResponse { 60 | return resp.PairRecordData !== undefined; 61 | } 62 | 63 | export class UsbmuxdClient extends ServiceClient { 64 | constructor(public socket: net.Socket) { 65 | super(socket, new UsbmuxProtocolClient(socket)); 66 | } 67 | 68 | static connectUsbmuxdSocket() { 69 | debug('connectUsbmuxdSocket'); 70 | if ('win32' === process.platform) { 71 | return net.connect({ port: 27015, host: 'localhost' }); 72 | } else { 73 | return net.connect({ path: '/var/run/usbmuxd' }); 74 | } 75 | } 76 | 77 | async connect(device: UsbmuxdDevice, port: number) { 78 | debug(`connect: ${device.DeviceID} on port ${port}`); 79 | 80 | const resp = await this.protocolClient.sendMessage({ 81 | messageType: 'Connect', 82 | extraFields: { 83 | DeviceID: device.DeviceID, 84 | PortNumber: htons(port), 85 | }, 86 | }); 87 | 88 | if (isUsbmuxdConnectResponse(resp) && resp.Number === 0) { 89 | return this.protocolClient.socket; 90 | } else { 91 | throw new ResponseError(`There was an error connecting to ${device.DeviceID} on port ${port}`, resp); 92 | } 93 | } 94 | 95 | async getDevices() { 96 | debug('getDevices'); 97 | 98 | const resp = await this.protocolClient.sendMessage({ 99 | messageType: 'ListDevices', 100 | }); 101 | 102 | if (isUsbmuxdDeviceResponse(resp)) { 103 | return resp.DeviceList; 104 | } else { 105 | throw new ResponseError('Invalid response from getDevices', resp); 106 | } 107 | } 108 | 109 | async getDevice(udid?: string) { 110 | debug(`getDevice ${udid ? 'udid: ' + udid : ''}`); 111 | const devices = await this.getDevices(); 112 | 113 | if (!devices.length) { 114 | throw new Error('No devices found'); 115 | } 116 | 117 | if (!udid) { 118 | return devices[0]; 119 | } 120 | 121 | for (const device of devices) { 122 | if (device.Properties && device.Properties.SerialNumber === udid) { 123 | return device; 124 | } 125 | } 126 | 127 | throw new Error(`No device with udid ${udid} found`); 128 | } 129 | 130 | async readPairRecord(udid: string): Promise { 131 | debug(`readPairRecord: ${udid}`); 132 | 133 | const resp = await this.protocolClient.sendMessage({ 134 | messageType: 'ReadPairRecord', 135 | extraFields: { PairRecordID: udid }, 136 | }); 137 | 138 | if (isUsbmuxdPairRecordResponse(resp)) { 139 | // the pair record can be created as a binary plist 140 | const BPLIST_MAGIC = Buffer.from('bplist00'); 141 | if (BPLIST_MAGIC.compare(resp.PairRecordData, 0, 8) === 0) { 142 | debug('Binary plist pair record detected.'); 143 | const bplistParser = await import('bplist-parser'); 144 | return bplistParser.parseBuffer(resp.PairRecordData)[0]; 145 | } else { 146 | return plist.parse(resp.PairRecordData.toString()) as any; // TODO: type guard 147 | } 148 | } else { 149 | throw new ResponseError(`There was an error reading pair record for udid: ${udid}`, resp); 150 | } 151 | } 152 | } 153 | 154 | function htons(n: number) { 155 | return ((n & 0xff) << 8) | ((n >> 8) & 0xff); 156 | } 157 | -------------------------------------------------------------------------------- /src/ios/lib/index.ts: -------------------------------------------------------------------------------- 1 | export * from './client'; 2 | export * from './protocol'; 3 | export * from './manager'; 4 | -------------------------------------------------------------------------------- /src/ios/lib/lib-errors.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Type union of error codes we get back from the protocol. 3 | */ 4 | export type IOSLibErrorCode = 'DeviceLocked'; 5 | 6 | export class IOSLibError extends Error implements NodeJS.ErrnoException { 7 | constructor( 8 | message: string, 9 | readonly code: IOSLibErrorCode, 10 | ) { 11 | super(message); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/ios/lib/manager.ts: -------------------------------------------------------------------------------- 1 | import type * as net from 'net'; 2 | import { Duplex } from 'stream'; 3 | import * as tls from 'tls'; 4 | 5 | import type { ServiceClient } from './client'; 6 | import { AFCClient } from './client/afc'; 7 | import { DebugserverClient } from './client/debugserver'; 8 | import { InstallationProxyClient } from './client/installation_proxy'; 9 | import { LockdowndClient } from './client/lockdownd'; 10 | import { MobileImageMounterClient } from './client/mobile_image_mounter'; 11 | import type { UsbmuxdDevice, UsbmuxdPairRecord } from './client/usbmuxd'; 12 | import { UsbmuxdClient } from './client/usbmuxd'; 13 | 14 | export class ClientManager { 15 | private connections: net.Socket[]; 16 | constructor( 17 | public pairRecord: UsbmuxdPairRecord, 18 | public device: UsbmuxdDevice, 19 | private lockdowndClient: LockdowndClient, 20 | ) { 21 | this.connections = [lockdowndClient.socket]; 22 | } 23 | 24 | static async create(udid?: string) { 25 | const usbmuxClient = new UsbmuxdClient(UsbmuxdClient.connectUsbmuxdSocket()); 26 | const device = await usbmuxClient.getDevice(udid); 27 | const pairRecord = await usbmuxClient.readPairRecord(device.Properties.SerialNumber); 28 | const lockdownSocket = await usbmuxClient.connect(device, 62078); 29 | const lockdownClient = new LockdowndClient(lockdownSocket); 30 | await lockdownClient.doHandshake(pairRecord); 31 | return new ClientManager(pairRecord, device, lockdownClient); 32 | } 33 | 34 | async getUsbmuxdClient() { 35 | const usbmuxClient = new UsbmuxdClient(UsbmuxdClient.connectUsbmuxdSocket()); 36 | this.connections.push(usbmuxClient.socket); 37 | return usbmuxClient; 38 | } 39 | 40 | async getLockdowndClient() { 41 | const usbmuxClient = new UsbmuxdClient(UsbmuxdClient.connectUsbmuxdSocket()); 42 | const lockdownSocket = await usbmuxClient.connect(this.device, 62078); 43 | const lockdownClient = new LockdowndClient(lockdownSocket); 44 | this.connections.push(lockdownClient.socket); 45 | return lockdownClient; 46 | } 47 | 48 | async getLockdowndClientWithHandshake() { 49 | const lockdownClient = await this.getLockdowndClient(); 50 | await lockdownClient.doHandshake(this.pairRecord); 51 | return lockdownClient; 52 | } 53 | 54 | async getAFCClient() { 55 | return this.getServiceClient('com.apple.afc', AFCClient); 56 | } 57 | 58 | async getInstallationProxyClient() { 59 | return this.getServiceClient('com.apple.mobile.installation_proxy', InstallationProxyClient); 60 | } 61 | 62 | async getMobileImageMounterClient() { 63 | return this.getServiceClient('com.apple.mobile.mobile_image_mounter', MobileImageMounterClient); 64 | } 65 | 66 | async getDebugserverClient() { 67 | try { 68 | // iOS 14 added support for a secure debug service so try to connect to that first 69 | return await this.getServiceClient('com.apple.debugserver.DVTSecureSocketProxy', DebugserverClient); 70 | } catch { 71 | // otherwise, fall back to the previous implementation 72 | return this.getServiceClient('com.apple.debugserver', DebugserverClient, true); 73 | } 74 | } 75 | 76 | private async getServiceClient>( 77 | name: string, 78 | ServiceType: new (...args: any[]) => T, 79 | disableSSL = false, 80 | ) { 81 | const { port: servicePort, enableServiceSSL } = await this.lockdowndClient.startService(name); 82 | const usbmuxClient = new UsbmuxdClient(UsbmuxdClient.connectUsbmuxdSocket()); 83 | let usbmuxdSocket = await usbmuxClient.connect(this.device, servicePort); 84 | 85 | if (enableServiceSSL) { 86 | const tlsOptions: tls.ConnectionOptions = { 87 | rejectUnauthorized: false, 88 | secureContext: tls.createSecureContext({ 89 | secureProtocol: 'TLSv1_2_method', 90 | cert: this.pairRecord.RootCertificate, 91 | key: this.pairRecord.RootPrivateKey, 92 | }), 93 | }; 94 | 95 | // Some services seem to not support TLS/SSL after the initial handshake 96 | // More info: https://github.com/libimobiledevice/libimobiledevice/issues/793 97 | if (disableSSL) { 98 | // According to https://nodejs.org/api/tls.html#tls_tls_connect_options_callback we can 99 | // pass any Duplex in to tls.connect instead of a Socket. So we'll use our proxy to keep 100 | // the TLS wrapper and underlying usbmuxd socket separate. 101 | const proxy: any = new UsbmuxdProxy(usbmuxdSocket); 102 | tlsOptions.socket = proxy; 103 | 104 | await new Promise((res, rej) => { 105 | const timeoutId = setTimeout(() => { 106 | rej('The TLS handshake failed to complete after 5s.'); 107 | }, 5000); 108 | tls.connect(tlsOptions, function (this: tls.TLSSocket) { 109 | clearTimeout(timeoutId); 110 | // After the handshake, we don't need TLS or the proxy anymore, 111 | // since we'll just pass in the naked usbmuxd socket to the service client 112 | this.destroy(); 113 | res(); 114 | }); 115 | }); 116 | } else { 117 | tlsOptions.socket = usbmuxdSocket; 118 | usbmuxdSocket = tls.connect(tlsOptions); 119 | } 120 | } 121 | const client = new ServiceType(usbmuxdSocket); 122 | this.connections.push(client.socket); 123 | return client; 124 | } 125 | 126 | end() { 127 | for (const socket of this.connections) { 128 | // may already be closed 129 | try { 130 | socket.end(); 131 | } catch (err) { 132 | // ignore 133 | } 134 | } 135 | } 136 | } 137 | 138 | class UsbmuxdProxy extends Duplex { 139 | constructor(private usbmuxdSock: net.Socket) { 140 | super(); 141 | 142 | this.usbmuxdSock.on('data', (data) => { 143 | this.push(data); 144 | }); 145 | } 146 | 147 | _write(chunk: any, encoding: string, callback: (err?: Error) => void) { 148 | this.usbmuxdSock.write(chunk); 149 | callback(); 150 | } 151 | 152 | _read(size: number) { 153 | // Stub so we don't error, since we push everything we get from usbmuxd as it comes in. 154 | // TODO: better way to do this? 155 | } 156 | 157 | _destroy() { 158 | this.usbmuxdSock.removeAllListeners(); 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /src/ios/lib/protocol/afc.ts: -------------------------------------------------------------------------------- 1 | import * as Debug from 'debug'; 2 | import type * as net from 'net'; 3 | 4 | import type { ProtocolReaderCallback, ProtocolWriter } from './protocol'; 5 | import { ProtocolClient, ProtocolReader, ProtocolReaderFactory } from './protocol'; 6 | 7 | const debug = Debug('native-run:ios:lib:protocol:afc'); 8 | 9 | export const AFC_MAGIC = 'CFA6LPAA'; 10 | export const AFC_HEADER_SIZE = 40; 11 | 12 | export interface AFCHeader { 13 | magic: typeof AFC_MAGIC; 14 | totalLength: number; 15 | headerLength: number; 16 | requestId: number; 17 | operation: AFC_OPS; 18 | } 19 | 20 | export interface AFCMessage { 21 | operation: AFC_OPS; 22 | data?: any; 23 | payload?: any; 24 | } 25 | 26 | export interface AFCResponse { 27 | operation: AFC_OPS; 28 | id: number; 29 | data: Buffer; 30 | } 31 | 32 | export interface AFCStatusResponse { 33 | operation: AFC_OPS.STATUS; 34 | id: number; 35 | data: number; 36 | } 37 | 38 | /** 39 | * AFC Operations 40 | */ 41 | export enum AFC_OPS { 42 | /** 43 | * Invalid 44 | */ 45 | INVALID = 0x00000000, 46 | 47 | /** 48 | * Status 49 | */ 50 | STATUS = 0x00000001, 51 | 52 | /** 53 | * Data 54 | */ 55 | DATA = 0x00000002, 56 | 57 | /** 58 | * ReadDir 59 | */ 60 | READ_DIR = 0x00000003, 61 | 62 | /** 63 | * ReadFile 64 | */ 65 | READ_FILE = 0x00000004, 66 | 67 | /** 68 | * WriteFile 69 | */ 70 | WRITE_FILE = 0x00000005, 71 | 72 | /** 73 | * WritePart 74 | */ 75 | WRITE_PART = 0x00000006, 76 | 77 | /** 78 | * TruncateFile 79 | */ 80 | TRUNCATE = 0x00000007, 81 | 82 | /** 83 | * RemovePath 84 | */ 85 | REMOVE_PATH = 0x00000008, 86 | 87 | /** 88 | * MakeDir 89 | */ 90 | MAKE_DIR = 0x00000009, 91 | 92 | /** 93 | * GetFileInfo 94 | */ 95 | GET_FILE_INFO = 0x0000000a, 96 | 97 | /** 98 | * GetDeviceInfo 99 | */ 100 | GET_DEVINFO = 0x0000000b, 101 | 102 | /** 103 | * WriteFileAtomic (tmp file+rename) 104 | */ 105 | WRITE_FILE_ATOM = 0x0000000c, 106 | 107 | /** 108 | * FileRefOpen 109 | */ 110 | FILE_OPEN = 0x0000000d, 111 | 112 | /** 113 | * FileRefOpenResult 114 | */ 115 | FILE_OPEN_RES = 0x0000000e, 116 | 117 | /** 118 | * FileRefRead 119 | */ 120 | FILE_READ = 0x0000000f, 121 | 122 | /** 123 | * FileRefWrite 124 | */ 125 | FILE_WRITE = 0x00000010, 126 | 127 | /** 128 | * FileRefSeek 129 | */ 130 | FILE_SEEK = 0x00000011, 131 | 132 | /** 133 | * FileRefTell 134 | */ 135 | FILE_TELL = 0x00000012, 136 | 137 | /** 138 | * FileRefTellResult 139 | */ 140 | FILE_TELL_RES = 0x00000013, 141 | 142 | /** 143 | * FileRefClose 144 | */ 145 | FILE_CLOSE = 0x00000014, 146 | 147 | /** 148 | * FileRefSetFileSize (ftruncate) 149 | */ 150 | FILE_SET_SIZE = 0x00000015, 151 | 152 | /** 153 | * GetConnectionInfo 154 | */ 155 | GET_CON_INFO = 0x00000016, 156 | 157 | /** 158 | * SetConnectionOptions 159 | */ 160 | SET_CON_OPTIONS = 0x00000017, 161 | 162 | /** 163 | * RenamePath 164 | */ 165 | RENAME_PATH = 0x00000018, 166 | 167 | /** 168 | * SetFSBlockSize (0x800000) 169 | */ 170 | SET_FS_BS = 0x00000019, 171 | 172 | /** 173 | * SetSocketBlockSize (0x800000) 174 | */ 175 | SET_SOCKET_BS = 0x0000001a, 176 | 177 | /** 178 | * FileRefLock 179 | */ 180 | FILE_LOCK = 0x0000001b, 181 | 182 | /** 183 | * MakeLink 184 | */ 185 | MAKE_LINK = 0x0000001c, 186 | 187 | /** 188 | * GetFileHash 189 | */ 190 | GET_FILE_HASH = 0x0000001d, 191 | 192 | /** 193 | * SetModTime 194 | */ 195 | SET_FILE_MOD_TIME = 0x0000001e, 196 | 197 | /** 198 | * GetFileHashWithRange 199 | */ 200 | GET_FILE_HASH_RANGE = 0x0000001f, 201 | 202 | // iOS 6+ 203 | 204 | /** 205 | * FileRefSetImmutableHint 206 | */ 207 | FILE_SET_IMMUTABLE_HINT = 0x00000020, 208 | 209 | /** 210 | * GetSizeOfPathContents 211 | */ 212 | GET_SIZE_OF_PATH_CONTENTS = 0x00000021, 213 | 214 | /** 215 | * RemovePathAndContents 216 | */ 217 | REMOVE_PATH_AND_CONTENTS = 0x00000022, 218 | 219 | /** 220 | * DirectoryEnumeratorRefOpen 221 | */ 222 | DIR_OPEN = 0x00000023, 223 | 224 | /** 225 | * DirectoryEnumeratorRefOpenResult 226 | */ 227 | DIR_OPEN_RESULT = 0x00000024, 228 | 229 | /** 230 | * DirectoryEnumeratorRefRead 231 | */ 232 | DIR_READ = 0x00000025, 233 | 234 | /** 235 | * DirectoryEnumeratorRefClose 236 | */ 237 | DIR_CLOSE = 0x00000026, 238 | 239 | // iOS 7+ 240 | 241 | /** 242 | * FileRefReadWithOffset 243 | */ 244 | FILE_READ_OFFSET = 0x00000027, 245 | 246 | /** 247 | * FileRefWriteWithOffset 248 | */ 249 | FILE_WRITE_OFFSET = 0x00000028, 250 | } 251 | 252 | /** 253 | * Error Codes 254 | */ 255 | export enum AFC_STATUS { 256 | SUCCESS = 0, 257 | UNKNOWN_ERROR = 1, 258 | OP_HEADER_INVALID = 2, 259 | NO_RESOURCES = 3, 260 | READ_ERROR = 4, 261 | WRITE_ERROR = 5, 262 | UNKNOWN_PACKET_TYPE = 6, 263 | INVALID_ARG = 7, 264 | OBJECT_NOT_FOUND = 8, 265 | OBJECT_IS_DIR = 9, 266 | PERM_DENIED = 10, 267 | SERVICE_NOT_CONNECTED = 11, 268 | OP_TIMEOUT = 12, 269 | TOO_MUCH_DATA = 13, 270 | END_OF_DATA = 14, 271 | OP_NOT_SUPPORTED = 15, 272 | OBJECT_EXISTS = 16, 273 | OBJECT_BUSY = 17, 274 | NO_SPACE_LEFT = 18, 275 | OP_WOULD_BLOCK = 19, 276 | IO_ERROR = 20, 277 | OP_INTERRUPTED = 21, 278 | OP_IN_PROGRESS = 22, 279 | INTERNAL_ERROR = 23, 280 | MUX_ERROR = 30, 281 | NO_MEM = 31, 282 | NOT_ENOUGH_DATA = 32, 283 | DIR_NOT_EMPTY = 33, 284 | FORCE_SIGNED_TYPE = -1, 285 | } 286 | 287 | export enum AFC_FILE_OPEN_FLAGS { 288 | /** 289 | * r (O_RDONLY) 290 | */ 291 | RDONLY = 0x00000001, 292 | 293 | /** 294 | * r+ (O_RDWR | O_CREAT) 295 | */ 296 | RW = 0x00000002, 297 | 298 | /** 299 | * w (O_WRONLY | O_CREAT | O_TRUNC) 300 | */ 301 | WRONLY = 0x00000003, 302 | 303 | /** 304 | * w+ (O_RDWR | O_CREAT | O_TRUNC) 305 | */ 306 | WR = 0x00000004, 307 | 308 | /** 309 | * a (O_WRONLY | O_APPEND | O_CREAT) 310 | */ 311 | APPEND = 0x00000005, 312 | 313 | /** 314 | * a+ (O_RDWR | O_APPEND | O_CREAT) 315 | */ 316 | RDAPPEND = 0x00000006, 317 | } 318 | 319 | function isAFCResponse(resp: any): resp is AFCResponse { 320 | return AFC_OPS[resp.operation] !== undefined && resp.id !== undefined && resp.data !== undefined; 321 | } 322 | 323 | function isStatusResponse(resp: any): resp is AFCStatusResponse { 324 | return isAFCResponse(resp) && resp.operation === AFC_OPS.STATUS; 325 | } 326 | 327 | function isErrorStatusResponse(resp: AFCResponse): boolean { 328 | return isStatusResponse(resp) && resp.data !== AFC_STATUS.SUCCESS; 329 | } 330 | 331 | class AFCInternalError extends Error { 332 | constructor( 333 | msg: string, 334 | public requestId: number, 335 | ) { 336 | super(msg); 337 | } 338 | } 339 | 340 | export class AFCError extends Error { 341 | constructor( 342 | msg: string, 343 | public status: AFC_STATUS, 344 | ) { 345 | super(msg); 346 | } 347 | } 348 | 349 | export class AFCProtocolClient extends ProtocolClient { 350 | private requestId = 0; 351 | private requestCallbacks: { [key: number]: ProtocolReaderCallback } = {}; 352 | 353 | constructor(socket: net.Socket) { 354 | super(socket, new ProtocolReaderFactory(AFCProtocolReader), new AFCProtocolWriter()); 355 | 356 | const reader = this.readerFactory.create((resp, err) => { 357 | if (err && err instanceof AFCInternalError) { 358 | this.requestCallbacks[err.requestId](resp, err); 359 | } else if (isErrorStatusResponse(resp)) { 360 | this.requestCallbacks[resp.id](resp, new AFCError(AFC_STATUS[resp.data], resp.data)); 361 | } else { 362 | this.requestCallbacks[resp.id](resp); 363 | } 364 | }); 365 | socket.on('data', reader.onData); 366 | } 367 | 368 | sendMessage(msg: AFCMessage): Promise { 369 | return new Promise((resolve, reject) => { 370 | const requestId = this.requestId++; 371 | this.requestCallbacks[requestId] = async (resp: any, err?: Error) => { 372 | if (err) { 373 | reject(err); 374 | return; 375 | } 376 | if (isAFCResponse(resp)) { 377 | resolve(resp); 378 | } else { 379 | reject(new Error('Malformed AFC response')); 380 | } 381 | }; 382 | this.writer.write(this.socket, { ...msg, requestId }); 383 | }); 384 | } 385 | } 386 | 387 | export class AFCProtocolReader extends ProtocolReader { 388 | private header!: AFCHeader; // TODO: ! -> ? 389 | 390 | constructor(callback: ProtocolReaderCallback) { 391 | super(AFC_HEADER_SIZE, callback); 392 | } 393 | 394 | parseHeader(data: Buffer) { 395 | const magic = data.slice(0, 8).toString('ascii'); 396 | if (magic !== AFC_MAGIC) { 397 | throw new AFCInternalError(`Invalid AFC packet received (magic != ${AFC_MAGIC})`, data.readUInt32LE(24)); 398 | } 399 | // technically these are uint64 400 | this.header = { 401 | magic, 402 | totalLength: data.readUInt32LE(8), 403 | headerLength: data.readUInt32LE(16), 404 | requestId: data.readUInt32LE(24), 405 | operation: data.readUInt32LE(32), 406 | }; 407 | 408 | debug(`parse header: ${JSON.stringify(this.header)}`); 409 | if (this.header.headerLength < AFC_HEADER_SIZE) { 410 | throw new AFCInternalError('Invalid AFC header', this.header.requestId); 411 | } 412 | return this.header.totalLength - AFC_HEADER_SIZE; 413 | } 414 | 415 | parseBody(data: Buffer): AFCResponse | AFCStatusResponse { 416 | const body: any = { 417 | operation: this.header.operation, 418 | id: this.header.requestId, 419 | data, 420 | }; 421 | if (isStatusResponse(body)) { 422 | const status = data.readUInt32LE(0); 423 | debug(`${AFC_OPS[this.header.operation]} response: ${AFC_STATUS[status]}`); 424 | body.data = status; 425 | } else if (data.length <= 8) { 426 | debug(`${AFC_OPS[this.header.operation]} response: ${Array.prototype.toString.call(body)}`); 427 | } else { 428 | debug(`${AFC_OPS[this.header.operation]} response length: ${data.length} bytes`); 429 | } 430 | return body; 431 | } 432 | } 433 | 434 | export class AFCProtocolWriter implements ProtocolWriter { 435 | write(socket: net.Socket, msg: AFCMessage & { requestId: number }) { 436 | const { data, payload, operation, requestId } = msg; 437 | 438 | const dataLength = data ? data.length : 0; 439 | const payloadLength = payload ? payload.length : 0; 440 | 441 | const header = Buffer.alloc(AFC_HEADER_SIZE); 442 | const magic = Buffer.from(AFC_MAGIC); 443 | magic.copy(header); 444 | header.writeUInt32LE(AFC_HEADER_SIZE + dataLength + payloadLength, 8); 445 | header.writeUInt32LE(AFC_HEADER_SIZE + dataLength, 16); 446 | header.writeUInt32LE(requestId, 24); 447 | header.writeUInt32LE(operation, 32); 448 | socket.write(header); 449 | socket.write(data); 450 | if (data.length <= 8) { 451 | debug( 452 | `socket write, header: { requestId: ${requestId}, operation: ${ 453 | AFC_OPS[operation] 454 | }}, body: ${Array.prototype.toString.call(data)}`, 455 | ); 456 | } else { 457 | debug( 458 | `socket write, header: { requestId: ${requestId}, operation: ${AFC_OPS[operation]}}, body: ${data.length} bytes`, 459 | ); 460 | } 461 | 462 | debug(`socket write, bytes written ${header.length} (header), ${data.length} (body)`); 463 | if (payload) { 464 | socket.write(payload); 465 | } 466 | } 467 | } 468 | -------------------------------------------------------------------------------- /src/ios/lib/protocol/gdb.ts: -------------------------------------------------------------------------------- 1 | import * as Debug from 'debug'; 2 | import type * as net from 'net'; 3 | 4 | import type { ProtocolReaderCallback, ProtocolWriter } from './protocol'; 5 | import { ProtocolClient, ProtocolReader, ProtocolReaderFactory } from './protocol'; 6 | 7 | const debug = Debug('native-run:ios:lib:protocol:gdb'); 8 | const ACK_SUCCESS = '+'.charCodeAt(0); 9 | 10 | export interface GDBMessage { 11 | cmd: string; 12 | args: string[]; 13 | } 14 | 15 | export class GDBProtocolClient extends ProtocolClient { 16 | constructor(socket: net.Socket) { 17 | super(socket, new ProtocolReaderFactory(GDBProtocolReader), new GDBProtocolWriter()); 18 | } 19 | } 20 | 21 | export class GDBProtocolReader extends ProtocolReader { 22 | constructor(callback: ProtocolReaderCallback) { 23 | super(1 /* "Header" is '+' or '-' */, callback); 24 | } 25 | 26 | onData(data?: Buffer) { 27 | // the GDB protocol does not support body length in its header so we cannot rely on 28 | // the parent implementation to determine when a payload is complete 29 | try { 30 | // if there's data, add it to the existing buffer 31 | this.buffer = data ? Buffer.concat([this.buffer, data]) : this.buffer; 32 | 33 | // do we have enough bytes to proceed 34 | if (this.buffer.length < this.headerSize) { 35 | return; // incomplete header, wait for more 36 | } 37 | 38 | // first, check the header 39 | if (this.parseHeader(this.buffer) === -1) { 40 | // we have a valid header so check the body. GDB packets will always be a leading '$', data bytes, 41 | // a trailing '#', and a two digit checksum. minimum valid body is the empty response '$#00' 42 | // https://developer.apple.com/library/archive/documentation/DeveloperTools/gdb/gdb/gdb_33.html 43 | const packetData = this.buffer.toString().match('\\$.*#[0-9a-f]{2}'); 44 | if (packetData == null) { 45 | return; // incomplete body, wait for more 46 | } 47 | // extract the body and update the buffer 48 | const body = Buffer.from(packetData[0]); 49 | this.buffer = this.buffer.slice(this.headerSize + body.length); 50 | // parse the payload and recurse if there is more data to process 51 | this.callback(this.parseBody(body)); 52 | if (this.buffer.length) { 53 | this.onData(); 54 | } 55 | } 56 | } catch (err: any) { 57 | this.callback(null, err); 58 | } 59 | } 60 | 61 | parseHeader(data: Buffer) { 62 | if (data[0] !== ACK_SUCCESS) { 63 | throw new Error('Unsuccessful debugserver response'); 64 | } // TODO: retry? 65 | return -1; 66 | } 67 | 68 | parseBody(buffer: Buffer) { 69 | debug(`Response body: ${buffer.toString()}`); 70 | // check for checksum 71 | const checksum = buffer.slice(-3).toString(); 72 | if (checksum.match(/#[0-9a-f]{2}/)) { 73 | // remove '$' prefix and checksum 74 | const msg = buffer.slice(1, -3).toString(); 75 | if (validateChecksum(checksum, msg)) { 76 | return msg; 77 | } else { 78 | throw new Error('Invalid checksum received from debugserver'); 79 | } 80 | } else { 81 | throw new Error("Didn't receive checksum"); 82 | } 83 | } 84 | } 85 | 86 | export class GDBProtocolWriter implements ProtocolWriter { 87 | write(socket: net.Socket, msg: GDBMessage) { 88 | const { cmd, args } = msg; 89 | debug(`Socket write: ${cmd}, args: ${args}`); 90 | // hex encode and concat all args 91 | const encodedArgs = args 92 | .map((arg) => Buffer.from(arg).toString('hex')) 93 | .join() 94 | .toUpperCase(); 95 | const checksumStr = calculateChecksum(cmd + encodedArgs); 96 | const formattedCmd = `$${cmd}${encodedArgs}#${checksumStr}`; 97 | socket.write(formattedCmd); 98 | } 99 | } 100 | 101 | // hex value of (sum of cmd chars mod 256) 102 | function calculateChecksum(cmdStr: string) { 103 | let checksum = 0; 104 | for (let i = 0; i < cmdStr.length; i++) { 105 | checksum += cmdStr.charCodeAt(i); 106 | } 107 | let result = (checksum % 256).toString(16); 108 | // pad if necessary 109 | if (result.length === 1) { 110 | result = `0${result}`; 111 | } 112 | return result; 113 | } 114 | 115 | function validateChecksum(checksum: string, msg: string) { 116 | // remove '#' from checksum 117 | const checksumVal = checksum.slice(1); 118 | // remove '$' from msg and calculate its checksum 119 | const computedChecksum = calculateChecksum(msg); 120 | debug(`Checksum: ${checksumVal}, computed checksum: ${computedChecksum}`); 121 | return checksumVal === computedChecksum; 122 | } 123 | -------------------------------------------------------------------------------- /src/ios/lib/protocol/index.ts: -------------------------------------------------------------------------------- 1 | export * from './protocol'; 2 | export * from './afc'; 3 | export * from './gdb'; 4 | export * from './lockdown'; 5 | export * from './usbmux'; 6 | -------------------------------------------------------------------------------- /src/ios/lib/protocol/lockdown.ts: -------------------------------------------------------------------------------- 1 | import * as Debug from 'debug'; 2 | import type * as net from 'net'; 3 | import * as plist from 'plist'; 4 | 5 | import { IOSLibError } from '../lib-errors'; 6 | 7 | import type { ProtocolWriter } from './protocol'; 8 | import { PlistProtocolReader, ProtocolClient, ProtocolReaderFactory } from './protocol'; 9 | 10 | const debug = Debug('native-run:ios:lib:protocol:lockdown'); 11 | export const LOCKDOWN_HEADER_SIZE = 4; 12 | 13 | export interface LockdownCommand { 14 | Command: string; 15 | [key: string]: any; 16 | } 17 | 18 | export interface LockdownResponse { 19 | Status: string; 20 | [key: string]: any; 21 | } 22 | 23 | export interface LockdownErrorResponse { 24 | Error: string; 25 | } 26 | 27 | export interface LockdownRequest { 28 | Request: string; 29 | [key: string]: any; 30 | } 31 | 32 | function isDefined(val: any) { 33 | return typeof val !== 'undefined'; 34 | } 35 | 36 | export function isLockdownResponse(resp: any): resp is LockdownResponse { 37 | return isDefined(resp.Status); 38 | } 39 | 40 | export function isLockdownErrorResponse(resp: any): resp is LockdownErrorResponse { 41 | return isDefined(resp.Error); 42 | } 43 | 44 | export class LockdownProtocolClient< 45 | MessageType extends LockdownRequest | LockdownCommand = LockdownRequest, 46 | > extends ProtocolClient { 47 | constructor(socket: net.Socket) { 48 | super(socket, new ProtocolReaderFactory(LockdownProtocolReader), new LockdownProtocolWriter()); 49 | } 50 | } 51 | 52 | export class LockdownProtocolReader extends PlistProtocolReader { 53 | constructor(callback: (data: any) => any) { 54 | super(LOCKDOWN_HEADER_SIZE, callback); 55 | } 56 | 57 | parseHeader(data: Buffer) { 58 | return data.readUInt32BE(0); 59 | } 60 | 61 | parseBody(data: Buffer) { 62 | const resp = super.parseBody(data); 63 | debug(`Response: ${JSON.stringify(resp)}`); 64 | if (isLockdownErrorResponse(resp)) { 65 | if (resp.Error === 'DeviceLocked') { 66 | throw new IOSLibError('Device is currently locked.', 'DeviceLocked'); 67 | } 68 | 69 | throw new Error(resp.Error); 70 | } 71 | return resp; 72 | } 73 | } 74 | 75 | export class LockdownProtocolWriter implements ProtocolWriter { 76 | write(socket: net.Socket, plistData: any) { 77 | debug(`socket write: ${JSON.stringify(plistData)}`); 78 | const plistMessage = plist.build(plistData); 79 | const header = Buffer.alloc(LOCKDOWN_HEADER_SIZE); 80 | header.writeUInt32BE(plistMessage.length, 0); 81 | socket.write(header); 82 | socket.write(plistMessage); 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/ios/lib/protocol/protocol.ts: -------------------------------------------------------------------------------- 1 | import * as bplistParser from 'bplist-parser'; 2 | import type * as net from 'net'; 3 | import * as plist from 'plist'; 4 | 5 | const BPLIST_MAGIC = Buffer.from('bplist00'); 6 | 7 | export type ProtocolReaderCallback = (resp: any, err?: Error) => void; 8 | 9 | export class ProtocolReaderFactory { 10 | constructor(private ProtocolReader: new (callback: ProtocolReaderCallback) => T) {} 11 | 12 | create(callback: (resp: any, err?: Error) => void): T { 13 | return new this.ProtocolReader(callback); 14 | } 15 | } 16 | 17 | export abstract class ProtocolReader { 18 | protected body!: Buffer; // TODO: ! -> ? 19 | protected bodyLength!: number; // TODO: ! -> ? 20 | protected buffer = Buffer.alloc(0); 21 | constructor( 22 | protected headerSize: number, 23 | protected callback: ProtocolReaderCallback, 24 | ) { 25 | this.onData = this.onData.bind(this); 26 | } 27 | 28 | /** Returns length of body, or -1 if header doesn't contain length */ 29 | protected abstract parseHeader(data: Buffer): number; 30 | protected abstract parseBody(data: Buffer): any; 31 | 32 | onData(data?: Buffer) { 33 | try { 34 | // if there's data, add it on to existing buffer 35 | this.buffer = data ? Buffer.concat([this.buffer, data]) : this.buffer; 36 | // we haven't gotten the body length from the header yet 37 | if (!this.bodyLength) { 38 | if (this.buffer.length < this.headerSize) { 39 | // partial header, wait for rest 40 | return; 41 | } 42 | this.bodyLength = this.parseHeader(this.buffer); 43 | // move on to body 44 | this.buffer = this.buffer.slice(this.headerSize); 45 | if (!this.buffer.length) { 46 | // only got header, wait for body 47 | return; 48 | } 49 | } 50 | if (this.buffer.length < this.bodyLength) { 51 | // wait for rest of body 52 | return; 53 | } 54 | 55 | if (this.bodyLength === -1) { 56 | this.callback(this.parseBody(this.buffer)); 57 | this.buffer = Buffer.alloc(0); 58 | } else { 59 | this.body = this.buffer.slice(0, this.bodyLength); 60 | this.bodyLength -= this.body.length; 61 | if (!this.bodyLength) { 62 | this.callback(this.parseBody(this.body)); 63 | } 64 | this.buffer = this.buffer.slice(this.body.length); 65 | // There are multiple messages here, call parse again 66 | if (this.buffer.length) { 67 | this.onData(); 68 | } 69 | } 70 | } catch (err: any) { 71 | this.callback(null, err); 72 | } 73 | } 74 | } 75 | 76 | export abstract class PlistProtocolReader extends ProtocolReader { 77 | protected parseBody(body: Buffer) { 78 | if (BPLIST_MAGIC.compare(body, 0, 8) === 0) { 79 | return bplistParser.parseBuffer(body); 80 | } else { 81 | return plist.parse(body.toString('utf8')); 82 | } 83 | } 84 | } 85 | 86 | export interface ProtocolWriter { 87 | write(sock: net.Socket, msg: any): void; 88 | } 89 | 90 | export abstract class ProtocolClient { 91 | constructor( 92 | public socket: net.Socket, 93 | protected readerFactory: ProtocolReaderFactory, 94 | protected writer: ProtocolWriter, 95 | ) {} 96 | 97 | sendMessage(msg: MessageType): Promise; 98 | sendMessage( 99 | msg: MessageType, 100 | callback: (response: ResponseType, resolve: any, reject: any) => void, 101 | ): Promise; 102 | sendMessage( 103 | msg: MessageType, 104 | callback?: (response: ResponseType, resolve: any, reject: any) => void, 105 | ): Promise { 106 | return new Promise((resolve, reject) => { 107 | const reader = this.readerFactory.create(async (resp: ResponseType, err?: Error) => { 108 | if (err) { 109 | reject(err); 110 | return; 111 | } 112 | if (callback) { 113 | callback( 114 | resp, 115 | (value: any) => { 116 | this.socket.removeListener('data', reader.onData); 117 | resolve(value); 118 | }, 119 | reject, 120 | ); 121 | } else { 122 | this.socket.removeListener('data', reader.onData); 123 | resolve(resp); 124 | } 125 | }); 126 | this.socket.on('error', (err) => { 127 | throw err; 128 | }); 129 | this.socket.on('data', reader.onData); 130 | this.writer.write(this.socket, msg); 131 | }); 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /src/ios/lib/protocol/usbmux.ts: -------------------------------------------------------------------------------- 1 | import * as Debug from 'debug'; 2 | import type * as net from 'net'; 3 | import * as plist from 'plist'; 4 | 5 | import type { ProtocolWriter } from './protocol'; 6 | import { PlistProtocolReader, ProtocolClient, ProtocolReaderFactory } from './protocol'; 7 | 8 | const debug = Debug('native-run:ios:lib:protocol:usbmux'); 9 | 10 | export const USBMUXD_HEADER_SIZE = 16; 11 | 12 | export interface UsbmuxMessage { 13 | messageType: string; 14 | extraFields?: { [key: string]: any }; 15 | } 16 | 17 | export class UsbmuxProtocolClient extends ProtocolClient { 18 | constructor(socket: net.Socket) { 19 | super(socket, new ProtocolReaderFactory(UsbmuxProtocolReader), new UsbmuxProtocolWriter()); 20 | } 21 | } 22 | 23 | export class UsbmuxProtocolReader extends PlistProtocolReader { 24 | constructor(callback: (data: any) => any) { 25 | super(USBMUXD_HEADER_SIZE, callback); 26 | } 27 | 28 | parseHeader(data: Buffer) { 29 | return data.readUInt32LE(0) - USBMUXD_HEADER_SIZE; 30 | } 31 | 32 | parseBody(data: Buffer) { 33 | const resp = super.parseBody(data); 34 | debug(`Response: ${JSON.stringify(resp)}`); 35 | return resp; 36 | } 37 | } 38 | 39 | export class UsbmuxProtocolWriter implements ProtocolWriter { 40 | private useTag = 0; 41 | 42 | write(socket: net.Socket, msg: UsbmuxMessage) { 43 | // TODO Usbmux message type 44 | debug(`socket write: ${JSON.stringify(msg)}`); 45 | const { messageType, extraFields } = msg; 46 | const plistMessage = plist.build({ 47 | BundleID: 'io.ionic.native-run', // TODO 48 | ClientVersionString: 'usbmux.js', // TODO 49 | MessageType: messageType, 50 | ProgName: 'native-run', // TODO 51 | kLibUSBMuxVersion: 3, 52 | ...extraFields, 53 | }); 54 | 55 | const dataSize = plistMessage ? plistMessage.length : 0; 56 | const protocolVersion = 1; 57 | const messageCode = 8; 58 | 59 | const header = Buffer.alloc(USBMUXD_HEADER_SIZE); 60 | header.writeUInt32LE(USBMUXD_HEADER_SIZE + dataSize, 0); 61 | header.writeUInt32LE(protocolVersion, 4); 62 | header.writeUInt32LE(messageCode, 8); 63 | header.writeUInt32LE(this.useTag++, 12); // TODO 64 | socket.write(header); 65 | socket.write(plistMessage); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/ios/list.ts: -------------------------------------------------------------------------------- 1 | import type { Exception } from '../errors'; 2 | import type { Target, Targets } from '../utils/list'; 3 | import { formatTargets } from '../utils/list'; 4 | 5 | import type { DeviceValues } from './lib'; 6 | import { getConnectedDevices } from './utils/device'; 7 | import type { Simulator } from './utils/simulator'; 8 | import { getSimulators } from './utils/simulator'; 9 | 10 | export async function run(args: readonly string[]): Promise { 11 | const targets = await list(args); 12 | process.stdout.write(`\n${formatTargets(args, targets)}\n`); 13 | } 14 | 15 | export async function list(args: readonly string[]): Promise { 16 | const errors: Exception[] = []; 17 | const [devices, virtualDevices] = await Promise.all([ 18 | (async () => { 19 | try { 20 | const devices = await getConnectedDevices(); 21 | return devices.map(deviceToTarget); 22 | } catch (e: any) { 23 | errors.push(e); 24 | return []; 25 | } 26 | })(), 27 | (async () => { 28 | try { 29 | const simulators = await getSimulators(); 30 | return simulators.map(simulatorToTarget); 31 | } catch (e: any) { 32 | errors.push(e); 33 | return []; 34 | } 35 | })(), 36 | ]); 37 | 38 | return { devices, virtualDevices, errors }; 39 | } 40 | 41 | function deviceToTarget(device: DeviceValues): Target { 42 | return { 43 | platform: 'ios', 44 | name: device.DeviceName, 45 | model: device.ProductType, 46 | sdkVersion: device.ProductVersion, 47 | id: device.UniqueDeviceID, 48 | }; 49 | } 50 | 51 | function simulatorToTarget(simulator: Simulator): Target { 52 | return { 53 | platform: 'ios', 54 | name: simulator.name, 55 | sdkVersion: simulator.runtime.version, 56 | id: simulator.udid, 57 | }; 58 | } 59 | -------------------------------------------------------------------------------- /src/ios/run.ts: -------------------------------------------------------------------------------- 1 | import { remove } from '@ionic/utils-fs'; 2 | import * as Debug from 'debug'; 3 | import { existsSync, mkdtempSync } from 'fs'; 4 | import * as path from 'path'; 5 | 6 | import { CLIException, ERR_BAD_INPUT, ERR_DEVICE_LOCKED, ERR_TARGET_NOT_FOUND, IOSRunException } from '../errors'; 7 | import { getOptionValue } from '../utils/cli'; 8 | import { wait } from '../utils/process'; 9 | 10 | import type { DeviceValues } from './lib'; 11 | import { IOSLibError } from './lib/lib-errors'; 12 | import { getBundleId, unzipIPA } from './utils/app'; 13 | import { getConnectedDevices, runOnDevice } from './utils/device'; 14 | import type { SimulatorResult } from './utils/simulator'; 15 | import { getSimulators, runOnSimulator } from './utils/simulator'; 16 | 17 | const debug = Debug('native-run:ios:run'); 18 | 19 | interface IOSRunConfig { 20 | udid?: string; 21 | devices: DeviceValues[]; 22 | simulators: SimulatorResult[]; 23 | appPath: string; 24 | bundleId: string; 25 | waitForApp: boolean; 26 | preferSimulator: boolean; 27 | } 28 | 29 | async function runIpaOrAppFile({ 30 | udid, 31 | devices, 32 | simulators, 33 | appPath, 34 | bundleId, 35 | waitForApp, 36 | preferSimulator, 37 | }: IOSRunConfig): Promise { 38 | if (udid) { 39 | if (devices.find((d) => d.UniqueDeviceID === udid)) { 40 | await runOnDevice(udid, appPath, bundleId, waitForApp); 41 | } else if (simulators.find((s) => s.udid === udid)) { 42 | await runOnSimulator(udid, appPath, bundleId, waitForApp); 43 | } else { 44 | throw new IOSRunException(`No device or simulator with UDID "${udid}" found`, ERR_TARGET_NOT_FOUND); 45 | } 46 | } else if (devices.length && !preferSimulator) { 47 | // no udid, use first connected device 48 | await runOnDevice(devices[0].UniqueDeviceID, appPath, bundleId, waitForApp); 49 | } else { 50 | // use default sim 51 | await runOnSimulator(simulators[simulators.length - 1].udid, appPath, bundleId, waitForApp); 52 | } 53 | } 54 | 55 | async function runIpaOrAppFileOnInterval(config: IOSRunConfig): Promise { 56 | const maxRetryCount = 12; // 1 minute 57 | const retryInterval = 5000; // 5 seconds 58 | let error: Error | undefined; 59 | let retryCount = 0; 60 | 61 | const retry = async () => { 62 | process.stderr.write('Please unlock your device. Waiting 5 seconds...\n'); 63 | retryCount++; 64 | await wait(retryInterval); 65 | await run(); 66 | }; 67 | 68 | const run = async () => { 69 | try { 70 | await runIpaOrAppFile(config); 71 | } catch (err: any) { 72 | if (err instanceof IOSLibError && err.code == 'DeviceLocked' && retryCount < maxRetryCount) { 73 | await retry(); 74 | } else { 75 | if (retryCount >= maxRetryCount) { 76 | error = new IOSRunException(`Device still locked after 1 minute. Aborting.`, ERR_DEVICE_LOCKED); 77 | } else { 78 | error = err; 79 | } 80 | } 81 | } 82 | }; 83 | 84 | await run(); 85 | 86 | if (error) { 87 | throw error; 88 | } 89 | } 90 | 91 | export async function run(args: readonly string[]): Promise { 92 | let appPath = getOptionValue(args, '--app'); 93 | if (!appPath) { 94 | throw new CLIException('--app is required', ERR_BAD_INPUT); 95 | } 96 | const udid = getOptionValue(args, '--target'); 97 | const preferSimulator = args.includes('--virtual'); 98 | const waitForApp = args.includes('--connect'); 99 | const isIPA = appPath.endsWith('.ipa'); 100 | 101 | if (!existsSync(appPath)) { 102 | throw new IOSRunException(`Path '${appPath}' not found`); 103 | } 104 | 105 | try { 106 | if (isIPA) { 107 | const { tmpdir } = await import('os'); 108 | const tempDir = mkdtempSync(`${tmpdir()}${path.sep}`); 109 | debug(`Unzipping .ipa to ${tempDir}`); 110 | const appDir = await unzipIPA(appPath, tempDir); 111 | appPath = path.join(tempDir, appDir); 112 | } 113 | 114 | const bundleId = await getBundleId(appPath); 115 | 116 | const [devices, simulators] = await Promise.all([getConnectedDevices(), getSimulators()]); 117 | // try to run on device or simulator with udid 118 | const config: IOSRunConfig = { 119 | udid, 120 | devices, 121 | simulators, 122 | appPath, 123 | bundleId, 124 | waitForApp, 125 | preferSimulator, 126 | }; 127 | 128 | await runIpaOrAppFileOnInterval(config); 129 | } finally { 130 | if (isIPA) { 131 | try { 132 | await remove(appPath); 133 | } catch { 134 | // ignore 135 | } 136 | } 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /src/ios/utils/app.ts: -------------------------------------------------------------------------------- 1 | import { mkdirp } from '@ionic/utils-fs'; 2 | import * as Debug from 'debug'; 3 | import { createWriteStream } from 'fs'; 4 | import * as path from 'path'; 5 | 6 | import { Exception } from '../../errors'; 7 | import { execFile } from '../../utils/process'; 8 | import { unzip } from '../../utils/unzip'; 9 | 10 | const debug = Debug('native-run:ios:utils:app'); 11 | 12 | // TODO: cross platform? Use plist/bplist 13 | export async function getBundleId(appPath: string) { 14 | const plistPath = path.resolve(appPath, 'Info.plist'); 15 | try { 16 | const { stdout } = await execFile('/usr/libexec/PlistBuddy', ['-c', 'Print :CFBundleIdentifier', plistPath], { 17 | encoding: 'utf8', 18 | }); 19 | if (stdout) { 20 | return stdout.trim(); 21 | } 22 | } catch { 23 | // ignore 24 | } 25 | throw new Exception('Unable to get app bundle identifier'); 26 | } 27 | 28 | export async function unzipIPA(ipaPath: string, destPath: string) { 29 | let error: Error | undefined; 30 | let appPath = ''; 31 | 32 | await unzip(ipaPath, async (entry, zipfile, openReadStream) => { 33 | debug(`Unzip: ${entry.fileName}`); 34 | const dest = path.join(destPath, entry.fileName); 35 | if (entry.fileName.endsWith('/')) { 36 | await mkdirp(dest); 37 | if (entry.fileName.endsWith('.app/')) { 38 | appPath = entry.fileName; 39 | } 40 | zipfile.readEntry(); 41 | } else { 42 | await mkdirp(path.dirname(dest)); 43 | const readStream = await openReadStream(entry); 44 | readStream.on('error', (err: Error) => (error = err)); 45 | readStream.on('end', () => { 46 | zipfile.readEntry(); 47 | }); 48 | readStream.pipe(createWriteStream(dest)); 49 | } 50 | }); 51 | 52 | if (error) { 53 | throw error; 54 | } 55 | 56 | if (!appPath) { 57 | throw new Exception('Unable to determine .app directory from .ipa'); 58 | } 59 | return appPath; 60 | } 61 | -------------------------------------------------------------------------------- /src/ios/utils/device.ts: -------------------------------------------------------------------------------- 1 | import { spawn } from 'child_process'; 2 | import * as Debug from 'debug'; 3 | import { readFileSync } from 'fs'; 4 | import * as path from 'path'; 5 | 6 | import { Exception } from '../../errors'; 7 | import { onBeforeExit, wait } from '../../utils/process'; 8 | import type { DeviceValues, IPLookupResult } from '../lib'; 9 | import { AFCError, AFC_STATUS, ClientManager, LockdowndClient, UsbmuxdClient } from '../lib'; 10 | 11 | import { getDeveloperDiskImagePath, getXcodeVersionInfo } from './xcode'; 12 | 13 | const debug = Debug('native-run:ios:utils:device'); 14 | 15 | export async function getConnectedDevices() { 16 | const usbmuxClient = new UsbmuxdClient(UsbmuxdClient.connectUsbmuxdSocket()); 17 | const usbmuxDevices = await usbmuxClient.getDevices(); 18 | usbmuxClient.socket.end(); 19 | 20 | return Promise.all( 21 | usbmuxDevices.map(async (d): Promise => { 22 | const socket = await new UsbmuxdClient(UsbmuxdClient.connectUsbmuxdSocket()).connect(d, 62078); 23 | const device = await new LockdowndClient(socket).getAllValues(); 24 | socket.end(); 25 | return device; 26 | }), 27 | ); 28 | } 29 | 30 | export async function runOnDevice(udid: string, appPath: string, bundleId: string, waitForApp: boolean) { 31 | const clientManager = await ClientManager.create(udid); 32 | 33 | try { 34 | await mountDeveloperDiskImage(clientManager); 35 | 36 | const packageName = path.basename(appPath); 37 | const destPackagePath = path.join('PublicStaging', packageName); 38 | 39 | await uploadApp(clientManager, appPath, destPackagePath); 40 | 41 | const installer = await clientManager.getInstallationProxyClient(); 42 | await installer.installApp(destPackagePath, bundleId); 43 | 44 | const { [bundleId]: appInfo } = await installer.lookupApp([bundleId]); 45 | // launch fails with EBusy or ENotFound if you try to launch immediately after install 46 | await wait(200); 47 | try { 48 | const debugServerClient = await launchApp(clientManager, appInfo); 49 | if (waitForApp) { 50 | onBeforeExit(async () => { 51 | // causes continue() to return 52 | debugServerClient.halt(); 53 | // give continue() time to return response 54 | await wait(64); 55 | }); 56 | 57 | debug(`Waiting for app to close...\n`); 58 | const result = await debugServerClient.continue(); 59 | // TODO: I have no idea what this packet means yet (successful close?) 60 | // if not a close (ie, most likely due to halt from onBeforeExit), then kill the app 61 | if (result !== 'W00') { 62 | await debugServerClient.kill(); 63 | } 64 | } 65 | } catch { 66 | // if launching app throws, try with devicectl, but requires Xcode 15 67 | const [xcodeVersion] = getXcodeVersionInfo(); 68 | const xcodeMajorVersion = Number(xcodeVersion.split('.')[0]); 69 | if (xcodeMajorVersion >= 15) { 70 | const launchResult = spawn('xcrun', ['devicectl', 'device', 'process', 'launch', '--device', udid, bundleId]); 71 | return new Promise((resolve, reject) => { 72 | launchResult.on('close', (code) => { 73 | if (code === 0) { 74 | resolve(); 75 | } else { 76 | reject(new Exception(`There was an error launching app on device`)); 77 | } 78 | }); 79 | launchResult.on('error', (err) => { 80 | reject(err); 81 | }); 82 | }); 83 | } else { 84 | throw new Exception(`running on iOS 17 devices requires Xcode 15 and later`); 85 | } 86 | } 87 | } finally { 88 | clientManager.end(); 89 | } 90 | } 91 | 92 | async function mountDeveloperDiskImage(clientManager: ClientManager) { 93 | const imageMounter = await clientManager.getMobileImageMounterClient(); 94 | // Check if already mounted. If not, mount. 95 | if (!(await imageMounter.lookupImage()).ImageSignature) { 96 | // verify DeveloperDiskImage exists (TODO: how does this work on Windows/Linux?) 97 | // TODO: if windows/linux, download? 98 | const version = await (await clientManager.getLockdowndClient()).getValue('ProductVersion'); 99 | const developerDiskImagePath = await getDeveloperDiskImagePath(version); 100 | const developerDiskImageSig = readFileSync(`${developerDiskImagePath}.signature`); 101 | await imageMounter.uploadImage(developerDiskImagePath, developerDiskImageSig); 102 | await imageMounter.mountImage(developerDiskImagePath, developerDiskImageSig); 103 | } 104 | } 105 | 106 | async function uploadApp(clientManager: ClientManager, srcPath: string, destinationPath: string) { 107 | const afcClient = await clientManager.getAFCClient(); 108 | try { 109 | await afcClient.getFileInfo('PublicStaging'); 110 | } catch (err) { 111 | if (err instanceof AFCError && err.status === AFC_STATUS.OBJECT_NOT_FOUND) { 112 | await afcClient.makeDirectory('PublicStaging'); 113 | } else { 114 | throw err; 115 | } 116 | } 117 | await afcClient.uploadDirectory(srcPath, destinationPath); 118 | } 119 | 120 | async function launchApp(clientManager: ClientManager, appInfo: IPLookupResult[string]) { 121 | let tries = 0; 122 | while (tries < 3) { 123 | const debugServerClient = await clientManager.getDebugserverClient(); 124 | await debugServerClient.setMaxPacketSize(1024); 125 | await debugServerClient.setWorkingDir(appInfo.Container); 126 | await debugServerClient.launchApp(appInfo.Path, appInfo.CFBundleExecutable); 127 | 128 | const result = await debugServerClient.checkLaunchSuccess(); 129 | if (result === 'OK') { 130 | return debugServerClient; 131 | } else if (result === 'EBusy' || result === 'ENotFound') { 132 | debug('Device busy or app not found, trying to launch again in .5s...'); 133 | tries++; 134 | debugServerClient.socket.end(); 135 | await wait(500); 136 | } else { 137 | throw new Exception(`There was an error launching app: ${result}`); 138 | } 139 | } 140 | throw new Exception('Unable to launch app, number of tries exceeded'); 141 | } 142 | -------------------------------------------------------------------------------- /src/ios/utils/simulator.ts: -------------------------------------------------------------------------------- 1 | import { spawnSync } from 'child_process'; // TODO: need cross-spawn for windows? 2 | import * as Debug from 'debug'; 3 | 4 | import { Exception } from '../../errors'; 5 | import { log } from '../../utils/log'; 6 | import { onBeforeExit } from '../../utils/process'; 7 | 8 | import { getXCodePath } from './xcode'; 9 | 10 | const debug = Debug('native-run:ios:utils:simulator'); 11 | 12 | export interface Simulator { 13 | availability: '(available)' | '(unavailable)'; 14 | isAvailable: boolean; 15 | name: string; // "iPhone 5"; 16 | state: string; // "Shutdown" 17 | udid: string; 18 | runtime: SimCtlRuntime; 19 | } 20 | 21 | interface SimCtlRuntime { 22 | readonly buildversion: string; // "14B72" 23 | readonly availability: '(available)' | '(unavailable)'; 24 | readonly name: string; // "iOS 10.1" 25 | readonly identifier: string; // "com.apple.CoreSimulator.SimRuntime.iOS-10-1" 26 | readonly version: string; // "10.1" 27 | } 28 | 29 | interface SimCtlType { 30 | readonly name: string; // "iPhone 7" 31 | readonly identifier: string; // "com.apple.CoreSimulator.SimDeviceType.iPhone-7" 32 | } 33 | 34 | interface SimCtlOutput { 35 | readonly devices: { 36 | readonly [key: string]: Simulator[]; 37 | }; 38 | readonly runtimes: SimCtlRuntime[]; 39 | readonly devicetypes: SimCtlType[]; 40 | } 41 | 42 | export interface SimulatorResult extends Simulator { 43 | runtime: SimCtlRuntime; 44 | } 45 | 46 | export async function getSimulators(): Promise { 47 | const simctl = spawnSync('xcrun', ['simctl', 'list', '--json'], { 48 | encoding: 'utf8', 49 | }); 50 | if (simctl.status) { 51 | throw new Exception(`Unable to retrieve simulator list: ${simctl.stderr}`); 52 | } 53 | 54 | try { 55 | const output: SimCtlOutput = JSON.parse(simctl.stdout); 56 | return output.runtimes 57 | .filter((runtime) => runtime.name.indexOf('watch') === -1 && runtime.name.indexOf('tv') === -1) 58 | .map((runtime) => 59 | (output.devices[runtime.identifier] || output.devices[runtime.name]) 60 | .filter((device) => device.isAvailable) 61 | .map((device) => ({ ...device, runtime })), 62 | ) 63 | .reduce((prev, next) => prev.concat(next)) // flatten 64 | .sort((a, b) => (a.name < b.name ? -1 : 1)); 65 | } catch (err: any) { 66 | throw new Exception(`Unable to retrieve simulator list: ${err.message}`); 67 | } 68 | } 69 | 70 | export async function runOnSimulator(udid: string, appPath: string, bundleId: string, waitForApp: boolean) { 71 | debug(`Booting simulator ${udid}`); 72 | const bootResult = spawnSync('xcrun', ['simctl', 'boot', udid], { 73 | encoding: 'utf8', 74 | }); 75 | // TODO: is there a better way to check this? 76 | if (bootResult.status && !bootResult.stderr.includes('Unable to boot device in current state: Booted')) { 77 | throw new Exception(`There was an error booting simulator: ${bootResult.stderr}`); 78 | } 79 | 80 | debug(`Installing ${appPath} on ${udid}`); 81 | const installResult = spawnSync('xcrun', ['simctl', 'install', udid, appPath], { encoding: 'utf8' }); 82 | if (installResult.status) { 83 | throw new Exception(`There was an error installing app on simulator: ${installResult.stderr}`); 84 | } 85 | 86 | const xCodePath = await getXCodePath(); 87 | debug(`Running simulator ${udid}`); 88 | const openResult = spawnSync( 89 | 'open', 90 | [`${xCodePath}/Applications/Simulator.app`, '--args', '-CurrentDeviceUDID', udid], 91 | { encoding: 'utf8' }, 92 | ); 93 | if (openResult.status) { 94 | throw new Exception(`There was an error opening simulator: ${openResult.stderr}`); 95 | } 96 | 97 | debug(`Launching ${appPath} on ${udid}`); 98 | const launchResult = spawnSync('xcrun', ['simctl', 'launch', udid, bundleId], { encoding: 'utf8' }); 99 | if (launchResult.status) { 100 | throw new Exception(`There was an error launching app on simulator: ${launchResult.stderr}`); 101 | } 102 | 103 | if (waitForApp) { 104 | onBeforeExit(async () => { 105 | const terminateResult = spawnSync('xcrun', ['simctl', 'terminate', udid, bundleId], { encoding: 'utf8' }); 106 | if (terminateResult.status) { 107 | debug('Unable to terminate app on simulator'); 108 | } 109 | }); 110 | 111 | log(`Waiting for app to close...\n`); 112 | await waitForSimulatorClose(udid, bundleId); 113 | } 114 | } 115 | 116 | async function waitForSimulatorClose(udid: string, bundleId: string) { 117 | return new Promise((resolve) => { 118 | // poll service list for bundle id 119 | const interval = setInterval(async () => { 120 | try { 121 | const data = spawnSync('xcrun', ['simctl', 'spawn', udid, 'launchctl', 'list'], { encoding: 'utf8' }); 122 | // if bundle id isn't in list, app isn't running 123 | if (data.stdout.indexOf(bundleId) === -1) { 124 | clearInterval(interval); 125 | resolve(); 126 | } 127 | } catch (e) { 128 | debug('Error received from launchctl: %O', e); 129 | debug('App %s no longer found in process list for %s', bundleId, udid); 130 | clearInterval(interval); 131 | resolve(); 132 | } 133 | }, 500); 134 | }); 135 | } 136 | -------------------------------------------------------------------------------- /src/ios/utils/xcode.ts: -------------------------------------------------------------------------------- 1 | import { readdir } from '@ionic/utils-fs'; 2 | import { spawnSync } from 'child_process'; 3 | 4 | import { Exception } from '../../errors'; 5 | import { execFile } from '../../utils/process'; 6 | 7 | type XcodeVersion = string; 8 | type XcodeBuildVersion = string; 9 | 10 | export function getXcodeVersionInfo(): readonly [XcodeVersion, XcodeBuildVersion] { 11 | const xcodeVersionInfo = spawnSync('xcodebuild', ['-version'], { 12 | encoding: 'utf8', 13 | }); 14 | if (xcodeVersionInfo.error) { 15 | throw xcodeVersionInfo.error; 16 | } 17 | 18 | try { 19 | const trimmed = xcodeVersionInfo.stdout.trim().split('\n'); 20 | return ['Xcode ', 'Build version'].map((s, i) => trimmed[i].replace(s, '')) as [string, string]; 21 | } catch (error) { 22 | throw new Exception(`There was an error trying to retrieve the Xcode version: ${xcodeVersionInfo.stderr}`); 23 | } 24 | } 25 | 26 | export async function getXCodePath() { 27 | try { 28 | const { stdout } = await execFile('xcode-select', ['-p'], { 29 | encoding: 'utf8', 30 | }); 31 | if (stdout) { 32 | return stdout.trim(); 33 | } 34 | } catch { 35 | // ignore 36 | } 37 | throw new Exception('Unable to get Xcode location. Is Xcode installed?'); 38 | } 39 | 40 | export async function getDeveloperDiskImagePath(version: string) { 41 | const xCodePath = await getXCodePath(); 42 | const versionDirs = await readdir(`${xCodePath}/Platforms/iPhoneOS.platform/DeviceSupport/`); 43 | const versionPrefix = version.match(/\d+\.\d+/); 44 | if (versionPrefix === null) { 45 | throw new Exception(`Invalid iOS version: ${version}`); 46 | } 47 | // Can look like "11.2 (15C107)" 48 | for (const dir of versionDirs) { 49 | if (dir.includes(versionPrefix[0])) { 50 | return `${xCodePath}/Platforms/iPhoneOS.platform/DeviceSupport/${dir}/DeveloperDiskImage.dmg`; 51 | } 52 | } 53 | throw new Exception( 54 | `Unable to find Developer Disk Image path for SDK ${version}. Do you have the right version of Xcode?`, 55 | ); 56 | } 57 | -------------------------------------------------------------------------------- /src/list.ts: -------------------------------------------------------------------------------- 1 | import { stringify } from './utils/json'; 2 | import type { Targets } from './utils/list'; 3 | import { formatTargets } from './utils/list'; 4 | 5 | export async function run(args: readonly string[]): Promise { 6 | const [ios, android] = await Promise.all([ 7 | (async (): Promise => { 8 | const cmd = await import('./ios/list'); 9 | return cmd.list(args); 10 | })(), 11 | (async (): Promise => { 12 | const cmd = await import('./android/list'); 13 | return cmd.list(args); 14 | })(), 15 | ]); 16 | 17 | if (args.includes('--json')) { 18 | process.stdout.write(stringify({ ios, android })); 19 | } else { 20 | process.stdout.write(` 21 | iOS 22 | --- 23 | 24 | ${formatTargets(args, ios)} 25 | 26 | Android 27 | ------- 28 | 29 | ${formatTargets(args, android)} 30 | 31 | `); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/utils/__tests__/fn.ts: -------------------------------------------------------------------------------- 1 | import { once } from '../fn'; 2 | 3 | describe('utils/fn', () => { 4 | describe('once', () => { 5 | it('should call function once despite multiple calls', () => { 6 | const mock = jest.fn(); 7 | const fn = once(mock); 8 | fn(); 9 | fn(); 10 | expect(mock).toHaveBeenCalledTimes(1); 11 | }); 12 | 13 | it('should call function with original parameters', () => { 14 | const mock = jest.fn(); 15 | const fn = once(mock); 16 | fn(5, 'foobar', { foo: 'bar' }); 17 | expect(mock).toHaveBeenCalledTimes(1); 18 | expect(mock).toHaveBeenCalledWith(5, 'foobar', { foo: 'bar' }); 19 | }); 20 | 21 | it('should return the exact same object', () => { 22 | const expected = {}; 23 | const mock = jest.fn(() => expected); 24 | const fn = once(mock); 25 | const r = fn(); 26 | expect(mock).toHaveBeenCalledTimes(1); 27 | expect(r).toBe(expected); 28 | }); 29 | }); 30 | }); 31 | -------------------------------------------------------------------------------- /src/utils/__tests__/object.ts: -------------------------------------------------------------------------------- 1 | import { sort } from '../object'; 2 | 3 | describe('utils/object', () => { 4 | describe('sort', () => { 5 | it('should return the same object', () => { 6 | const obj = { c: 3, b: 2, a: 1 }; 7 | const result = sort(obj); 8 | expect(result).toBe(obj); 9 | expect(obj).toEqual({ c: 3, b: 2, a: 1 }); 10 | }); 11 | 12 | it('should sort the keys', () => { 13 | const obj = { c: 3, b: 2, a: 1 }; 14 | expect(Object.keys(obj)).toEqual(['c', 'b', 'a']); 15 | sort(obj); 16 | expect(Object.keys(obj)).toEqual(['a', 'b', 'c']); 17 | }); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /src/utils/cli.ts: -------------------------------------------------------------------------------- 1 | export function getOptionValue(args: readonly string[], arg: string): string | undefined; 2 | export function getOptionValue(args: readonly string[], arg: string, defaultValue: string): string; 3 | export function getOptionValue(args: readonly string[], arg: string, defaultValue?: string): string | undefined { 4 | const i = args.indexOf(arg); 5 | 6 | if (i >= 0) { 7 | return args[i + 1]; 8 | } 9 | 10 | return defaultValue; 11 | } 12 | 13 | export function getOptionValues(args: readonly string[], arg: string): string[] { 14 | const returnVal: string[] = []; 15 | args.map((entry: string, idx: number) => { 16 | if (entry === arg) { 17 | returnVal.push(args[idx + 1]); 18 | } 19 | }); 20 | return returnVal; 21 | } 22 | -------------------------------------------------------------------------------- /src/utils/fn.ts: -------------------------------------------------------------------------------- 1 | export function once any>(fn: T): T { 2 | let called = false; 3 | let r: any; 4 | 5 | const wrapper: any = (...args: any[]): any => { 6 | if (!called) { 7 | called = true; 8 | r = fn(...args); 9 | } 10 | 11 | return r; 12 | }; 13 | 14 | return wrapper; 15 | } 16 | -------------------------------------------------------------------------------- /src/utils/fs.ts: -------------------------------------------------------------------------------- 1 | import { statSafe } from '@ionic/utils-fs'; 2 | 3 | export async function isDir(p: string): Promise { 4 | const stats = await statSafe(p); 5 | 6 | if (stats?.isDirectory()) { 7 | return true; 8 | } 9 | 10 | return false; 11 | } 12 | -------------------------------------------------------------------------------- /src/utils/ini.ts: -------------------------------------------------------------------------------- 1 | import { readFile, writeFile } from '@ionic/utils-fs'; 2 | import * as Debug from 'debug'; 3 | import * as util from 'util'; 4 | 5 | const debug = Debug('native-run:android:utils:ini'); 6 | 7 | export type INIGuard = (o: any) => o is T; 8 | 9 | export async function readINI(p: string, guard: INIGuard = (o: any): o is T => true): Promise { 10 | const ini = await import('ini'); 11 | 12 | try { 13 | const contents = await readFile(p, { encoding: 'utf8' }); 14 | const config = ini.decode(contents); 15 | 16 | if (!guard(config)) { 17 | throw new Error( 18 | `Invalid ini configuration file: ${p}\n` + 19 | `The following guard was used: ${guard.toString()}\n` + 20 | `INI config parsed as: ${util.inspect(config)}`, 21 | ); 22 | } 23 | 24 | return { __filename: p, ...(config as any) }; 25 | } catch (e) { 26 | debug(e); 27 | } 28 | } 29 | 30 | export async function writeINI(p: string, o: T): Promise { 31 | const ini = await import('ini'); 32 | const contents = ini.encode(o); 33 | 34 | await writeFile(p, contents, { encoding: 'utf8' }); 35 | } 36 | -------------------------------------------------------------------------------- /src/utils/json.ts: -------------------------------------------------------------------------------- 1 | export function stringify(obj: any): string { 2 | return JSON.stringify(obj, (k, v) => (v instanceof RegExp ? v.toString() : v), '\t'); 3 | } 4 | -------------------------------------------------------------------------------- /src/utils/list.ts: -------------------------------------------------------------------------------- 1 | import { columnar, indent } from '@ionic/utils-terminal'; 2 | 3 | import type { Exception } from '../errors'; 4 | import { CLIException, ERR_BAD_INPUT, serializeError } from '../errors'; 5 | 6 | import { stringify } from './json'; 7 | 8 | export interface Targets { 9 | readonly devices: readonly Target[]; 10 | readonly virtualDevices: readonly Target[]; 11 | readonly errors: readonly Exception[]; 12 | } 13 | 14 | export interface Target { 15 | readonly platform: 'android' | 'ios'; 16 | readonly model?: string; 17 | readonly name?: string; 18 | readonly sdkVersion: string; 19 | readonly id: string; 20 | } 21 | 22 | export function formatTargets(args: readonly string[], targets: Targets): string { 23 | const { devices, virtualDevices, errors } = targets; 24 | 25 | const virtualOnly = args.includes('--virtual'); 26 | const devicesOnly = args.includes('--device'); 27 | 28 | if (virtualOnly && devicesOnly) { 29 | throw new CLIException('Only one of --device or --virtual may be specified', ERR_BAD_INPUT); 30 | } 31 | 32 | if (args.includes('--json')) { 33 | let result; 34 | if (virtualOnly) { 35 | result = { virtualDevices, errors }; 36 | } else if (devicesOnly) { 37 | result = { devices, errors }; 38 | } else { 39 | result = { devices, virtualDevices, errors }; 40 | } 41 | return stringify(result); 42 | } 43 | 44 | let output = ''; 45 | 46 | if (errors.length > 0) { 47 | output += `Errors (!):\n\n${errors.map((e) => ` ${serializeError(e)}`)}\n`; 48 | } 49 | 50 | if (!virtualOnly) { 51 | output += printTargets('Connected Device', devices); 52 | if (devicesOnly) { 53 | return output; 54 | } 55 | output += '\n'; 56 | } 57 | output += printTargets('Virtual Device', virtualDevices); 58 | return output; 59 | } 60 | 61 | function printTargets(name: string, targets: readonly Target[]) { 62 | let output = `${name}s:\n\n`; 63 | if (targets.length === 0) { 64 | output += ` No ${name.toLowerCase()}s found\n`; 65 | } else { 66 | output += formatTargetTable(targets) + '\n'; 67 | } 68 | return output; 69 | } 70 | 71 | function formatTargetTable(targets: readonly Target[]): string { 72 | const spacer = indent(2); 73 | 74 | return ( 75 | spacer + 76 | columnar(targets.map(targetToRow), { 77 | headers: ['Name', 'API', 'Target ID'], 78 | vsep: ' ', 79 | }) 80 | .split('\n') 81 | .join(`\n${spacer}`) 82 | ); 83 | } 84 | 85 | function targetToRow(target: Target): string[] { 86 | return [ 87 | target.name ?? target.model ?? target.id ?? '?', 88 | `${target.platform === 'ios' ? 'iOS' : 'API'} ${target.sdkVersion}`, 89 | target.id ?? '?', 90 | ]; 91 | } 92 | -------------------------------------------------------------------------------- /src/utils/log.ts: -------------------------------------------------------------------------------- 1 | import { stringify } from './json'; 2 | 3 | export function log(message: string): void { 4 | if (process.argv.includes('--json')) { 5 | message = stringify({ message }); 6 | } 7 | 8 | process.stdout.write(message); 9 | } 10 | -------------------------------------------------------------------------------- /src/utils/object.ts: -------------------------------------------------------------------------------- 1 | export function sort(obj: T): T { 2 | const entries = [...Object.entries(obj)]; 3 | 4 | entries.sort(([k1], [k2]) => k1.localeCompare(k2)); 5 | 6 | for (const [key] of entries) { 7 | delete obj[key]; 8 | } 9 | 10 | for (const [key, value] of entries) { 11 | (obj as any)[key] = value; 12 | } 13 | 14 | return obj; 15 | } 16 | -------------------------------------------------------------------------------- /src/utils/process.ts: -------------------------------------------------------------------------------- 1 | import * as cp from 'child_process'; 2 | import * as Debug from 'debug'; 3 | import * as util from 'util'; 4 | 5 | import { once } from './fn'; 6 | 7 | const debug = Debug('native-run:utils:process'); 8 | 9 | export const exec = util.promisify(cp.exec); 10 | export const execFile = util.promisify(cp.execFile); 11 | export const wait = util.promisify(setTimeout); 12 | 13 | export type ExitQueueFn = () => Promise; 14 | 15 | const exitQueue: ExitQueueFn[] = []; 16 | 17 | export function onBeforeExit(fn: ExitQueueFn): void { 18 | exitQueue.push(fn); 19 | } 20 | 21 | const BEFORE_EXIT_SIGNALS: NodeJS.Signals[] = ['SIGINT', 'SIGTERM', 'SIGHUP', 'SIGBREAK']; 22 | 23 | const beforeExitHandlerWrapper = (signal: NodeJS.Signals) => 24 | once(async () => { 25 | debug('onBeforeExit handler: %s received', signal); 26 | debug('onBeforeExit handler: running %s queued functions', exitQueue.length); 27 | 28 | for (const [i, fn] of exitQueue.entries()) { 29 | try { 30 | await fn(); 31 | } catch (e) { 32 | debug('Error from function %d in exit queue: %O', i, e); 33 | } 34 | } 35 | 36 | debug('onBeforeExit handler: exiting (exit code %s)', process.exitCode ? process.exitCode : 0); 37 | 38 | process.exit(); 39 | }); 40 | 41 | for (const signal of BEFORE_EXIT_SIGNALS) { 42 | process.on(signal, beforeExitHandlerWrapper(signal)); 43 | } 44 | -------------------------------------------------------------------------------- /src/utils/unzip.ts: -------------------------------------------------------------------------------- 1 | import type { Readable } from 'stream'; 2 | import { promisify } from 'util'; 3 | import type { Entry, ZipFile, ZipFileOptions } from 'yauzl'; 4 | 5 | // Specify which of possible overloads is being promisified 6 | type YauzlOpenReadStream = ( 7 | entry: Entry, 8 | options?: ZipFileOptions, 9 | callback?: (err: Error, stream: Readable) => void, 10 | ) => void; 11 | type UnzipOnEntry = ( 12 | entry: Entry, 13 | zipfile: ZipFile, 14 | openReadStream: (arg1: Entry, arg2?: ZipFileOptions) => Promise, 15 | ) => void; 16 | 17 | export async function unzip(srcPath: string, onEntry: UnzipOnEntry) { 18 | const yauzl = await import('yauzl'); 19 | 20 | return new Promise((resolve, reject) => { 21 | yauzl.open(srcPath, { lazyEntries: true }, (err, zipfile) => { 22 | if (!zipfile || err) { 23 | return reject(err); 24 | } 25 | 26 | const openReadStream = promisify(zipfile.openReadStream.bind(zipfile) as YauzlOpenReadStream); 27 | zipfile.once('error', reject); 28 | // resolve when either one happens 29 | zipfile.once('close', resolve); // fd of zip closed 30 | zipfile.once('end', resolve); // last entry read 31 | zipfile.on('entry', (entry) => onEntry(entry, zipfile, openReadStream)); 32 | zipfile.readEntry(); 33 | }); 34 | }); 35 | } 36 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowUnreachableCode": false, 4 | "importHelpers": true, 5 | "module": "commonjs", 6 | "moduleResolution": "node", 7 | "resolveJsonModule": true, 8 | "noFallthroughCasesInSwitch": true, 9 | "noUnusedLocals": true, 10 | "outDir": "./dist", 11 | "pretty": true, 12 | "strict": true, 13 | "target": "es2019", 14 | "lib": [ 15 | "es2019" 16 | ] 17 | }, 18 | "include": [ 19 | "src/**/*", 20 | "types/**/*" 21 | ], 22 | "exclude": [ 23 | "node_modules", 24 | "src/**/__tests__/*.ts" 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /types/bplist-parser.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'bplist-parser' { 2 | export const maxObjectSize = 100000000; // 100Meg 3 | export const maxObjectCount = 32768; 4 | export function UID(id: string): void; 5 | export function parseFile(fileNameOrBuffer: string | Buffer, callback: (err: Error, result: any) => any): any; 6 | export function parseBuffer(buffer: Buffer): any; 7 | } 8 | --------------------------------------------------------------------------------