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