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