microsoft/vscode-react-native
Publicmirrored fromhttps://github.com/microsoft/vscode-react-nativeAvailable
src/debugger/ios/deviceRunner.ts
286lines · modecode
| 1 | // Copyright (c) Microsoft Corporation. All rights reserved. |
| 2 | // Licensed under the MIT license. See LICENSE file in the project root for details. |
| 3 | |
| 4 | import {ChildProcess} from "child_process"; |
| 5 | import * as net from "net"; |
| 6 | import * as Q from "q"; |
| 7 | |
| 8 | import {Node} from "../../common/node/node"; |
| 9 | import {PlistBuddy} from "../../common/ios/plistBuddy"; |
| 10 | |
| 11 | export class DeviceRunner { |
| 12 | private projectRoot: string; |
| 13 | private nativeDebuggerProxyInstance: ChildProcess; |
| 14 | |
| 15 | constructor(projectRoot: string) { |
| 16 | this.projectRoot = projectRoot; |
| 17 | process.on("exit", () => this.cleanup()); |
| 18 | } |
| 19 | |
| 20 | public run(): Q.Promise<void> { |
| 21 | const proxyPort = 9999; |
| 22 | const appLaunchStepTimeout = 5000; |
| 23 | return new PlistBuddy().getBundleId(this.projectRoot, /*simulator=*/false) |
| 24 | .then((bundleId: string) => this.getPathOnDevice(bundleId)) |
| 25 | .then((path: string) => |
| 26 | this.startNativeDebugProxy(proxyPort).then(() => |
| 27 | this.startAppViaDebugger(proxyPort, path, appLaunchStepTimeout) |
| 28 | ) |
| 29 | ) |
| 30 | .then(() => { }); |
| 31 | } |
| 32 | |
| 33 | private cleanup(): void { |
| 34 | if (this.nativeDebuggerProxyInstance) { |
| 35 | this.nativeDebuggerProxyInstance.kill("SIGHUP"); |
| 36 | this.nativeDebuggerProxyInstance = null; |
| 37 | } |
| 38 | } |
| 39 | |
| 40 | private startNativeDebugProxy(proxyPort: number): Q.Promise<void> { |
| 41 | this.cleanup(); |
| 42 | |
| 43 | return this.mountDeveloperImage().then(function(): Q.Promise<void> { |
| 44 | const {spawnedProcess} = new Node.ChildProcess().spawnWithExitHandler("idevicedebugserverproxy", [proxyPort.toString()]); |
| 45 | this.nativeDebuggerProxyInstance = spawnedProcess; |
| 46 | const deferred = Q.defer<ChildProcess>(); |
| 47 | |
| 48 | spawnedProcess.on("error", (err: Error) => { |
| 49 | deferred.reject(err); |
| 50 | }); |
| 51 | |
| 52 | // Allow 200ms for the spawn to error out |
| 53 | return Q.delay(200); |
| 54 | }); |
| 55 | } |
| 56 | |
| 57 | private mountDeveloperImage(): Q.Promise<void> { |
| 58 | return this.getDiskImage().then(function(path: string): Q.Promise<void> { |
| 59 | const imagemounter = new Node.ChildProcess().spawnWithExitHandler("ideviceimagemounter", [path]).spawnedProcess; |
| 60 | const deferred = Q.defer<void>(); |
| 61 | let stdout: string = ""; |
| 62 | imagemounter.stdout.on("data", function(data: any): void { |
| 63 | stdout += data.toString(); |
| 64 | }); |
| 65 | imagemounter.on("exit", function(code: number): void { |
| 66 | if (code !== 0) { |
| 67 | if (stdout.indexOf("Error:") !== -1) { |
| 68 | deferred.resolve(void 0); // Technically failed, but likely caused by the image already being mounted. |
| 69 | } else if (stdout.indexOf("No device found, is it plugged in?") !== -1) { |
| 70 | deferred.reject(new Error("Unable to find device. Is the device plugged in?")); |
| 71 | } |
| 72 | |
| 73 | deferred.reject(new Error("Unable to mount developer disk image.")); |
| 74 | } else { |
| 75 | deferred.resolve(void 0); |
| 76 | } |
| 77 | }); |
| 78 | imagemounter.on("error", function(err: any): void { |
| 79 | deferred.reject(err); |
| 80 | }); |
| 81 | return deferred.promise; |
| 82 | }); |
| 83 | } |
| 84 | |
| 85 | private getDiskImage(): Q.Promise<string> { |
| 86 | const nodeChildProcess = new Node.ChildProcess(); |
| 87 | // Attempt to find the OS version of the iDevice, e.g. 7.1 |
| 88 | const versionInfo = nodeChildProcess.exec("ideviceinfo -s -k ProductVersion").outcome.then((stdout: Buffer) => { |
| 89 | return stdout.toString().trim().substring(0, 3); // Versions for DeveloperDiskImage seem to be X.Y, while some device versions are X.Y.Z |
| 90 | // NOTE: This will almost certainly be wrong in the next few years, once we hit version 10.0 |
| 91 | }, function(): string { |
| 92 | throw new Error("Unable to get device OS version"); |
| 93 | }); |
| 94 | |
| 95 | // Attempt to find the path where developer resources exist. |
| 96 | const pathInfo = nodeChildProcess.exec("xcrun -sdk iphoneos --show-sdk-platform-path").outcome.then((stdout: Buffer) => { |
| 97 | return stdout.toString().trim(); |
| 98 | }); |
| 99 | |
| 100 | // Attempt to find the developer disk image for the appropriate |
| 101 | return Q.all([versionInfo, pathInfo]).spread<string>(function(version: string, sdkpath: string): Q.Promise<string> { |
| 102 | const find = nodeChildProcess.spawn("find", [sdkpath, "-path", "*" + version + "*", "-name", "DeveloperDiskImage.dmg"]).spawnedProcess; |
| 103 | const deferred = Q.defer<string>(); |
| 104 | |
| 105 | find.stdout.on("data", function(data: any): void { |
| 106 | const dataStr: string = data.toString(); |
| 107 | const path: string = dataStr.split("\n")[0].trim(); |
| 108 | if (!path) { |
| 109 | deferred.reject(new Error("Unable to find developer disk image")); |
| 110 | } else { |
| 111 | deferred.resolve(path); |
| 112 | } |
| 113 | }); |
| 114 | find.on("exit", function(code: number): void { |
| 115 | deferred.reject(new Error("Unable to find developer disk image")); |
| 116 | }); |
| 117 | |
| 118 | return deferred.promise; |
| 119 | }); |
| 120 | } |
| 121 | |
| 122 | private getPathOnDevice(packageId: string): Q.Promise<string> { |
| 123 | const nodeChildProcess = new Node.ChildProcess(); |
| 124 | const nodeFileSystem = new Node.FileSystem(); |
| 125 | return nodeChildProcess.execToString("ideviceinstaller -l -o xml > /tmp/$$.ideviceinstaller && echo /tmp/$$.ideviceinstaller") |
| 126 | .catch(function(err: any): any { |
| 127 | if (err.code === "ENOENT") { |
| 128 | throw new Error("Unable to find ideviceinstaller."); |
| 129 | } |
| 130 | throw err; |
| 131 | }).then((stdout: string): Q.Promise<string> => { |
| 132 | // First find the path of the app on the device |
| 133 | let filename: string = stdout.trim(); |
| 134 | if (!/^\/tmp\/[0-9]+\.ideviceinstaller$/.test(filename)) { |
| 135 | throw new Error("Unable to list installed applications on device"); |
| 136 | } |
| 137 | |
| 138 | const plistBuddy = new PlistBuddy(); |
| 139 | // Search thrown the unknown-length array until we find the package |
| 140 | const findPackageEntry = (index: number): Q.Promise<string> => { |
| 141 | return plistBuddy.readPlistProperty(filename, `:${index}:CFBundleIdentifier`) |
| 142 | .then((bundleId: string) => { |
| 143 | if (bundleId === packageId) { |
| 144 | return plistBuddy.readPlistProperty(filename, `:${index}:Path`); |
| 145 | } |
| 146 | return findPackageEntry(index + 1); |
| 147 | }); |
| 148 | }; |
| 149 | |
| 150 | return findPackageEntry(0) |
| 151 | .finally(() => { |
| 152 | nodeFileSystem.unlink(filename); |
| 153 | }).catch((): string => { |
| 154 | throw new Error("Application not installed on the device"); |
| 155 | }); |
| 156 | }); |
| 157 | } |
| 158 | |
| 159 | // Attempt to start the app on the device, using the debug server proxy on a given port. |
| 160 | // Returns a socket speaking remote gdb protocol with the debug server proxy. |
| 161 | private startAppViaDebugger(portNumber: number, packagePath: string, appLaunchStepTimeout: number): Q.Promise<string> { |
| 162 | const encodedPath: string = this.encodePath(packagePath); |
| 163 | |
| 164 | // We need to send 3 messages to the proxy, waiting for responses between each message: |
| 165 | // A(length of encoded path),0,(encoded path) |
| 166 | // Hc0 |
| 167 | // c |
| 168 | // We expect a '+' for each message sent, followed by a $OK#9a to indicate that everything has worked. |
| 169 | // For more info, see http://www.opensource.apple.com/source/lldb/lldb-167.2/docs/lldb-gdb-remote.txt |
| 170 | const socket: net.Socket = new net.Socket(); |
| 171 | let initState: number = 0; |
| 172 | let endStatus: number = null; |
| 173 | let endSignal: number = null; |
| 174 | |
| 175 | const deferred1: Q.Deferred<net.Socket> = Q.defer<net.Socket>(); |
| 176 | const deferred2: Q.Deferred<net.Socket> = Q.defer<net.Socket>(); |
| 177 | const deferred3: Q.Deferred<net.Socket> = Q.defer<net.Socket>(); |
| 178 | |
| 179 | socket.on("data", function(data: any): void { |
| 180 | data = data.toString(); |
| 181 | while (data[0] === "+") { data = data.substring(1); } |
| 182 | // Acknowledge any packets sent our way |
| 183 | if (data[0] === "$") { |
| 184 | socket.write("+"); |
| 185 | if (data[1] === "W") { |
| 186 | // The app process has exited, with hex status given by data[2-3] |
| 187 | let status: number = parseInt(data.substring(2, 4), 16); |
| 188 | endStatus = status; |
| 189 | socket.end(); |
| 190 | } else if (data[1] === "X") { |
| 191 | // The app rocess exited because of signal given by data[2-3] |
| 192 | let signal: number = parseInt(data.substring(2, 4), 16); |
| 193 | endSignal = signal; |
| 194 | socket.end(); |
| 195 | } else if (data.substring(1, 3) === "OK") { |
| 196 | // last command was received OK; |
| 197 | if (initState === 1) { |
| 198 | deferred1.resolve(socket); |
| 199 | } else if (initState === 2) { |
| 200 | deferred2.resolve(socket); |
| 201 | } |
| 202 | } else if (data[1] === "O") { |
| 203 | // STDOUT was written to, and the rest of the input until reaching a "#" is a hex-encoded string of that output |
| 204 | if (initState === 3) { |
| 205 | deferred3.resolve(socket); |
| 206 | initState++; |
| 207 | } |
| 208 | } else if (data[1] === "E") { |
| 209 | // An error has occurred, with error code given by data[2-3]: parseInt(data.substring(2, 4), 16) |
| 210 | const error = new Error("Unable to launch application."); |
| 211 | deferred1.reject(error); |
| 212 | deferred2.reject(error); |
| 213 | deferred3.reject(error); |
| 214 | } |
| 215 | } |
| 216 | }); |
| 217 | |
| 218 | socket.on("end", function(): void { |
| 219 | const error = new Error("Unable to launch application."); |
| 220 | deferred1.reject(error); |
| 221 | deferred2.reject(error); |
| 222 | deferred3.reject(error); |
| 223 | }); |
| 224 | |
| 225 | socket.on("error", function(err: Error): void { |
| 226 | deferred1.reject(err); |
| 227 | deferred2.reject(err); |
| 228 | deferred3.reject(err); |
| 229 | }); |
| 230 | |
| 231 | socket.connect(portNumber, "localhost", () => { |
| 232 | // set argument 0 to the (encoded) path of the app |
| 233 | const cmd: string = this.makeGdbCommand("A" + encodedPath.length + ",0," + encodedPath); |
| 234 | initState++; |
| 235 | socket.write(cmd); |
| 236 | setTimeout(function(): void { |
| 237 | deferred1.reject(new Error("Timeout launching application. Is the device locked?")); |
| 238 | }, appLaunchStepTimeout); |
| 239 | }); |
| 240 | |
| 241 | return deferred1.promise.then((sock: net.Socket): Q.Promise<net.Socket> => { |
| 242 | // Set the step and continue thread to any thread |
| 243 | const cmd: string = this.makeGdbCommand("Hc0"); |
| 244 | initState++; |
| 245 | sock.write(cmd); |
| 246 | setTimeout(function(): void { |
| 247 | deferred2.reject(new Error("Timeout launching application. Is the device locked?")); |
| 248 | }, appLaunchStepTimeout); |
| 249 | return deferred2.promise; |
| 250 | }).then((sock: net.Socket): Q.Promise<net.Socket> => { |
| 251 | // Continue execution; actually start the app running. |
| 252 | const cmd: string = this.makeGdbCommand("c"); |
| 253 | initState++; |
| 254 | sock.write(cmd); |
| 255 | setTimeout(function(): void { |
| 256 | deferred3.reject(new Error("Timeout launching application. Is the device locked?")); |
| 257 | }, appLaunchStepTimeout); |
| 258 | return deferred3.promise; |
| 259 | }).then(() => packagePath); |
| 260 | } |
| 261 | |
| 262 | private encodePath(packagePath: string): string { |
| 263 | // Encode the path by converting each character value to hex |
| 264 | return packagePath.split("").map((c: string) => c.charCodeAt(0).toString(16)).join("").toUpperCase(); |
| 265 | } |
| 266 | |
| 267 | private makeGdbCommand(command: string): string { |
| 268 | let commandString: string = `$${command}#`; |
| 269 | let stringSum: number = 0; |
| 270 | for (let i: number = 0; i < command.length; i++) { |
| 271 | stringSum += command.charCodeAt(i); |
| 272 | } |
| 273 | |
| 274 | /* tslint:disable:no-bitwise */ |
| 275 | // We need some bitwise operations to calculate the checksum |
| 276 | stringSum = stringSum & 0xFF; |
| 277 | /* tslint:enable:no-bitwise */ |
| 278 | let checksum: string = stringSum.toString(16).toUpperCase(); |
| 279 | if (checksum.length < 2) { |
| 280 | checksum = "0" + checksum; |
| 281 | } |
| 282 | |
| 283 | commandString += checksum; |
| 284 | return commandString; |
| 285 | } |
| 286 | } |