1import Debug from 'debug'; 2import fs from 'fs'; 3import path from 'path'; 4 5import { ClientManager } from './ClientManager'; 6import { IPLookupResult, OnInstallProgressCallback } from './client/InstallationProxyClient'; 7import { LockdowndClient } from './client/LockdowndClient'; 8import { UsbmuxdClient } from './client/UsbmuxdClient'; 9import { AFC_STATUS, AFCError } from './protocol/AFCProtocol'; 10import { Log } from '../../../log'; 11import { XcodeDeveloperDiskImagePrerequisite } from '../../../start/doctor/apple/XcodeDeveloperDiskImagePrerequisite'; 12import { delayAsync } from '../../../utils/delay'; 13import { CommandError } from '../../../utils/errors'; 14import { installExitHooks } from '../../../utils/exit'; 15 16const debug = Debug('expo:apple-device'); 17 18// NOTE(EvanBacon): I have a feeling this shape will change with new iOS versions (tested against iOS 15). 19export interface ConnectedDevice { 20 /** @example `00008101-001964A22629003A` */ 21 udid: string; 22 /** @example `Evan's phone` */ 23 name: string; 24 /** @example `iPhone13,4` */ 25 model: string; 26 /** @example `device` */ 27 deviceType: 'device' | 'catalyst'; 28 /** @example `USB` */ 29 connectionType: 'USB' | 'Network'; 30 /** @example `15.4.1` */ 31 osVersion: string; 32} 33 34/** @returns a list of connected Apple devices. */ 35export async function getConnectedDevicesAsync(): Promise<ConnectedDevice[]> { 36 const client = new UsbmuxdClient(UsbmuxdClient.connectUsbmuxdSocket()); 37 const devices = await client.getDevices(); 38 client.socket.end(); 39 40 return Promise.all( 41 devices.map(async (device): Promise<ConnectedDevice> => { 42 const socket = await new UsbmuxdClient(UsbmuxdClient.connectUsbmuxdSocket()).connect( 43 device, 44 62078 45 ); 46 const deviceValues = await new LockdowndClient(socket).getAllValues(); 47 socket.end(); 48 // TODO(EvanBacon): Add support for osType (ipad, watchos, etc) 49 return { 50 // TODO(EvanBacon): Better name 51 name: deviceValues.DeviceName ?? deviceValues.ProductType ?? 'unknown iOS device', 52 model: deviceValues.ProductType, 53 osVersion: deviceValues.ProductVersion, 54 deviceType: 'device', 55 connectionType: device.Properties.ConnectionType, 56 udid: device.Properties.SerialNumber, 57 }; 58 }) 59 ); 60} 61 62/** Install and run an Apple app binary on a connected Apple device. */ 63export async function runOnDevice({ 64 udid, 65 appPath, 66 bundleId, 67 waitForApp, 68 deltaPath, 69 onProgress, 70}: { 71 /** Apple device UDID */ 72 udid: string; 73 /** File path to the app binary (ipa) */ 74 appPath: string; 75 /** Bundle identifier for the app at `appPath` */ 76 bundleId: string; 77 /** Wait for the app to launch before returning */ 78 waitForApp: boolean; 79 /** File path to the app deltas folder to use for faster subsequent installs */ 80 deltaPath: string; 81 /** Callback to be called with progress updates */ 82 onProgress: OnInstallProgressCallback; 83}) { 84 const clientManager = await ClientManager.create(udid); 85 86 try { 87 await mountDeveloperDiskImage(clientManager); 88 89 const packageName = path.basename(appPath); 90 const destPackagePath = path.join('PublicStaging', packageName); 91 92 await uploadApp(clientManager, { appBinaryPath: appPath, destinationPath: destPackagePath }); 93 94 const installer = await clientManager.getInstallationProxyClient(); 95 await installer.installApp( 96 destPackagePath, 97 bundleId, 98 { 99 // https://github.com/ios-control/ios-deploy/blob/0f2ffb1e564aa67a2dfca7cdf13de47ce489d835/src/ios-deploy/ios-deploy.m#L2491-L2508 100 ApplicationsType: 'Any', 101 102 CFBundleIdentifier: bundleId, 103 CloseOnInvalidate: '1', 104 InvalidateOnDetach: '1', 105 IsUserInitiated: '1', 106 // Disable checking for wifi devices, this is nominally faster. 107 PreferWifi: '0', 108 // Only info I could find on these: 109 // https://github.com/wwxxyx/Quectel_BG96/blob/310876f90fc1093a59e45e381160eddcc31697d0/Apple_Homekit/homekit_certification_tools/ATS%206/ATS%206/ATS.app/Contents/Frameworks/CaptureKit.framework/Versions/A/Resources/MobileDevice/MobileInstallation.h#L112-L121 110 PackageType: 'Developer', 111 ShadowParentKey: deltaPath, 112 // SkipUninstall: '1' 113 }, 114 onProgress 115 ); 116 117 const { 118 // TODO(EvanBacon): This can be undefined when querying App Clips. 119 [bundleId]: appInfo, 120 } = await installer.lookupApp([bundleId]); 121 122 if (appInfo) { 123 // launch fails with EBusy or ENotFound if you try to launch immediately after install 124 await delayAsync(200); 125 const debugServerClient = await launchApp(clientManager, { appInfo, detach: !waitForApp }); 126 if (waitForApp) { 127 installExitHooks(async () => { 128 // causes continue() to return 129 debugServerClient.halt(); 130 // give continue() time to return response 131 await delayAsync(64); 132 }); 133 134 debug(`Waiting for app to close...\n`); 135 const result = await debugServerClient.continue(); 136 // TODO: I have no idea what this packet means yet (successful close?) 137 // if not a close (ie, most likely due to halt from onBeforeExit), then kill the app 138 if (result !== 'W00') { 139 await debugServerClient.kill(); 140 } 141 } 142 } else { 143 Log.warn(`App "${bundleId}" installed but couldn't be launched. Open on device manually.`); 144 } 145 } finally { 146 clientManager.end(); 147 } 148} 149 150/** Mount the developer disk image for Xcode. */ 151async function mountDeveloperDiskImage(clientManager: ClientManager) { 152 const imageMounter = await clientManager.getMobileImageMounterClient(); 153 // Check if already mounted. If not, mount. 154 if (!(await imageMounter.lookupImage()).ImageSignature) { 155 // verify DeveloperDiskImage exists (TODO: how does this work on Windows/Linux?) 156 // TODO: if windows/linux, download? 157 const version = await (await clientManager.getLockdowndClient()).getValue('ProductVersion'); 158 const developerDiskImagePath = await XcodeDeveloperDiskImagePrerequisite.instance.assertAsync({ 159 version, 160 }); 161 const developerDiskImageSig = fs.readFileSync(`${developerDiskImagePath}.signature`); 162 await imageMounter.uploadImage(developerDiskImagePath, developerDiskImageSig); 163 await imageMounter.mountImage(developerDiskImagePath, developerDiskImageSig); 164 } 165} 166 167async function uploadApp( 168 clientManager: ClientManager, 169 { appBinaryPath, destinationPath }: { appBinaryPath: string; destinationPath: string } 170) { 171 const afcClient = await clientManager.getAFCClient(); 172 try { 173 await afcClient.getFileInfo('PublicStaging'); 174 } catch (err: any) { 175 if (err instanceof AFCError && err.status === AFC_STATUS.OBJECT_NOT_FOUND) { 176 await afcClient.makeDirectory('PublicStaging'); 177 } else { 178 throw err; 179 } 180 } 181 await afcClient.uploadDirectory(appBinaryPath, destinationPath); 182} 183 184async function launchApp( 185 clientManager: ClientManager, 186 { appInfo, detach }: { appInfo: IPLookupResult[string]; detach?: boolean } 187) { 188 let tries = 0; 189 while (tries < 3) { 190 const debugServerClient = await clientManager.getDebugserverClient(); 191 await debugServerClient.setMaxPacketSize(1024); 192 await debugServerClient.setWorkingDir(appInfo.Container); 193 await debugServerClient.launchApp(appInfo.Path, appInfo.CFBundleExecutable); 194 195 const result = await debugServerClient.checkLaunchSuccess(); 196 if (result === 'OK') { 197 if (detach) { 198 // https://github.com/libimobiledevice/libimobiledevice/blob/25059d4c7d75e03aab516af2929d7c6e6d4c17de/tools/idevicedebug.c#L455-L464 199 const res = await debugServerClient.sendCommand('D', []); 200 debug('Disconnect from debug server request:', res); 201 if (res !== 'OK') { 202 console.warn( 203 'Something went wrong while attempting to disconnect from iOS debug server, you may need to reopen the app manually.' 204 ); 205 } 206 } 207 208 return debugServerClient; 209 } else if (result === 'EBusy' || result === 'ENotFound') { 210 debug('Device busy or app not found, trying to launch again in .5s...'); 211 tries++; 212 debugServerClient.socket.end(); 213 await delayAsync(500); 214 } else { 215 throw new CommandError(`There was an error launching app: ${result}`); 216 } 217 } 218 throw new CommandError('Unable to launch app, number of tries exceeded'); 219} 220