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