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