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