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