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