1/**
2 * Copyright (c) 2021 Expo, Inc.
3 * Copyright (c) 2018 Drifty Co.
4 *
5 * This source code is licensed under the MIT license found in the
6 * LICENSE file in the root directory of this source tree.
7 */
8import Debug from 'debug';
9import * as fs from 'fs';
10import { Socket } from 'net';
11import * as path from 'path';
12import { promisify } from 'util';
13
14import { ServiceClient } from './ServiceClient';
15import { CommandError } from '../../../../utils/errors';
16import {
17  AFC_FILE_OPEN_FLAGS,
18  AFC_OPS,
19  AFC_STATUS,
20  AFCError,
21  AFCProtocolClient,
22  AFCResponse,
23} from '../protocol/AFCProtocol';
24
25const debug = Debug('expo:apple-device:client:afc');
26const MAX_OPEN_FILES = 240;
27
28export class AFCClient extends ServiceClient<AFCProtocolClient> {
29  constructor(public socket: Socket) {
30    super(socket, new AFCProtocolClient(socket));
31  }
32
33  async getFileInfo(path: string): Promise<string[]> {
34    debug(`getFileInfo: ${path}`);
35
36    const response = await this.protocolClient.sendMessage({
37      operation: AFC_OPS.GET_FILE_INFO,
38      data: toCString(path),
39    });
40    debug(`getFileInfo:response: %O`, response);
41
42    const strings: string[] = [];
43    let currentString = '';
44    const tokens = response.data;
45    tokens.forEach((token) => {
46      if (token === 0) {
47        strings.push(currentString);
48        currentString = '';
49      } else {
50        currentString += String.fromCharCode(token);
51      }
52    });
53    return strings;
54  }
55
56  async writeFile(fd: Buffer, data: Buffer): Promise<AFCResponse> {
57    debug(`writeFile: ${Array.prototype.toString.call(fd)} data size: ${data.length}`);
58
59    const response = await this.protocolClient.sendMessage({
60      operation: AFC_OPS.FILE_WRITE,
61      data: fd,
62      payload: data,
63    });
64
65    debug(`writeFile:response:`, response);
66    return response;
67  }
68
69  protected async openFile(path: string): Promise<Buffer> {
70    debug(`openFile: ${path}`);
71    // mode + path + null terminator
72    const data = Buffer.alloc(8 + path.length + 1);
73    // write mode
74    data.writeUInt32LE(AFC_FILE_OPEN_FLAGS.WRONLY, 0);
75    // then path to file
76    toCString(path).copy(data, 8);
77
78    const response = await this.protocolClient.sendMessage({
79      operation: AFC_OPS.FILE_OPEN,
80      data,
81    });
82
83    // debug(`openFile:response:`, response);
84
85    if (response.operation === AFC_OPS.FILE_OPEN_RES) {
86      return response.data;
87    }
88
89    throw new CommandError(
90      'APPLE_DEVICE_AFC',
91      `There was an unknown error opening file ${path}, response: ${Array.prototype.toString.call(
92        response.data
93      )}`
94    );
95  }
96
97  protected async closeFile(fd: Buffer): Promise<AFCResponse> {
98    debug(`closeFile fd: ${Array.prototype.toString.call(fd)}`);
99    const response = await this.protocolClient.sendMessage({
100      operation: AFC_OPS.FILE_CLOSE,
101      data: fd,
102    });
103
104    debug(`closeFile:response:`, response);
105    return response;
106  }
107
108  protected async uploadFile(srcPath: string, destPath: string): Promise<void> {
109    debug(`uploadFile: ${srcPath}, ${destPath}`);
110
111    // read local file and get fd of destination
112    const [srcFile, destFile] = await Promise.all([
113      await promisify(fs.readFile)(srcPath),
114      await this.openFile(destPath),
115    ]);
116
117    try {
118      await this.writeFile(destFile, srcFile);
119      await this.closeFile(destFile);
120    } catch (err) {
121      await this.closeFile(destFile);
122      throw err;
123    }
124  }
125
126  async makeDirectory(path: string): Promise<AFCResponse> {
127    debug(`makeDirectory: ${path}`);
128
129    const response = await this.protocolClient.sendMessage({
130      operation: AFC_OPS.MAKE_DIR,
131      data: toCString(path),
132    });
133
134    debug(`makeDirectory:response:`, response);
135    return response;
136  }
137
138  async uploadDirectory(srcPath: string, destPath: string): Promise<void> {
139    debug(`uploadDirectory: ${srcPath}`);
140    await this.makeDirectory(destPath);
141
142    // AFC doesn't seem to give out more than 240 file handles,
143    // so we delay any requests that would push us over until more open up
144    let numOpenFiles = 0;
145    const pendingFileUploads: (() => void)[] = [];
146    const _this = this;
147    return uploadDir(srcPath);
148
149    async function uploadDir(dirPath: string): Promise<void> {
150      const promises: Promise<void>[] = [];
151      for (const file of fs.readdirSync(dirPath)) {
152        const filePath = path.join(dirPath, file);
153        const remotePath = path.join(destPath, path.relative(srcPath, filePath));
154        if (fs.lstatSync(filePath).isDirectory()) {
155          promises.push(_this.makeDirectory(remotePath).then(() => uploadDir(filePath)));
156        } else {
157          // Create promise to add to promises array
158          // this way it can be resolved once a pending upload has finished
159          let resolve: (val?: any) => void;
160          let reject: (err: AFCError) => void;
161          const promise = new Promise<void>((res, rej) => {
162            resolve = res;
163            reject = rej;
164          });
165          promises.push(promise);
166
167          // wrap upload in a function in case we need to save it for later
168          const uploadFile = (tries = 0) => {
169            numOpenFiles++;
170            _this
171              .uploadFile(filePath, remotePath)
172              .then(() => {
173                resolve();
174                numOpenFiles--;
175                const fn = pendingFileUploads.pop();
176                if (fn) {
177                  fn();
178                }
179              })
180              .catch((err: AFCError) => {
181                // Couldn't get fd for whatever reason, try again
182                // # of retries is arbitrary and can be adjusted
183                if (err.status === AFC_STATUS.NO_RESOURCES && tries < 10) {
184                  debug(`Received NO_RESOURCES from AFC, retrying ${filePath} upload. ${tries}`);
185                  uploadFile(tries++);
186                } else {
187                  numOpenFiles--;
188                  reject(err);
189                }
190              });
191          };
192
193          if (numOpenFiles < MAX_OPEN_FILES) {
194            uploadFile();
195          } else {
196            debug(
197              `numOpenFiles >= ${MAX_OPEN_FILES}, adding to pending queue. Length: ${pendingFileUploads.length}`
198            );
199            pendingFileUploads.push(uploadFile);
200          }
201        }
202      }
203      await Promise.all(promises);
204    }
205  }
206}
207
208function toCString(s: string) {
209  const buf = Buffer.alloc(s.length + 1);
210  const len = buf.write(s);
211  buf.writeUInt8(0, len);
212  return buf;
213}
214