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