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