/* * Copyright (C) 2013 RoboVM AB * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License * as published by the Free Software Foundation; either version 2 * of the License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see <http://www.gnu.org/licenses/gpl-2.0.html>. */ package org.robovm.libimobiledevice.util; import java.io.File; import java.io.FileNotFoundException; import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.InterruptedIOException; import java.io.OutputStream; import java.io.UnsupportedEncodingException; import java.lang.ProcessBuilder.Redirect; import java.net.ServerSocket; import java.net.Socket; import java.nio.file.Files; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Map.Entry; import java.util.Set; import java.util.TreeSet; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeoutException; import java.util.zip.ZipEntry; import java.util.zip.ZipFile; import org.robovm.libimobiledevice.AfcClient; import org.robovm.libimobiledevice.AfcClient.UploadProgressCallback; import org.robovm.libimobiledevice.IDevice; import org.robovm.libimobiledevice.IDeviceConnection; import org.robovm.libimobiledevice.InstallationProxyClient; import org.robovm.libimobiledevice.InstallationProxyClient.Options; import org.robovm.libimobiledevice.InstallationProxyClient.Options.PackageType; import org.robovm.libimobiledevice.InstallationProxyClient.StatusCallback; import org.robovm.libimobiledevice.LibIMobileDeviceException; import org.robovm.libimobiledevice.LockdowndClient; import org.robovm.libimobiledevice.LockdowndServiceDescriptor; import org.robovm.libimobiledevice.MobileImageMounterClient; import org.robovm.libimobiledevice.binding.LockdowndError; import org.robovm.libimobiledevice.util.AppLauncherCallback.AppLauncherInfo; import com.dd.plist.NSArray; import com.dd.plist.NSDictionary; import com.dd.plist.NSNumber; import com.dd.plist.NSString; import com.dd.plist.PropertyListParser; /** * Launches an application on a device using the {@code com.apple.debuserver} * service. The app must have the {@code get-task-allow} entitlement set to * {@code true} in order to be allowed to be launched by the debug server. */ public class AppLauncher { public static final int DEFAULT_FORWARD_PORT = 17777; private static final String DEBUG_SERVER_SERVICE_NAME = "com.apple.debugserver"; private static final char[] HEX_CHARS = "0123456789abcdef".toCharArray(); private static final int RECEIVE_TIMEOUT = 5000; private static final byte[] BREAK = new byte[] { 0x03 }; private byte[] buffer = new byte[4096]; private StringBuilder bufferedResponses = new StringBuilder(4096); private final IDevice device; private final String appId; private final File localAppPath; private boolean installed = false; private List<String> args = new ArrayList<>(); private Map<String, String> env = new HashMap<String, String>(); private OutputStream stdout = System.out; private boolean closeOutOnExit = false; private boolean debug = false; private int localPort = -1; private AppLauncherCallback appLauncherCallback = null; private volatile boolean killed = false; private StatusCallback installStatusCallback; private UploadProgressCallback uploadProgressCallback; private String xcodePath; private int launchOnLockedRetries = 5; private int secondsBetweenLaunchOnLockedRetries = 5; /** * Creates a new {@link AppLauncher} which will launch an already installed * app with the specified id. * * @param device the device to connect to. * @param appId the id (CFBundleIdentifier) of the app to run. */ public AppLauncher(IDevice device, String appId) { this(device, appId, null); } /** * Creates a new {@link AppLauncher} which will install the app from the * specified IPA file or app bundle dir and launch it. * * @param device the device to connect to. * @param localAppPath the IPA file of app bundle dir containing the app to * install and launch. */ public AppLauncher(IDevice device, File localAppPath) throws IOException { this(device, getAppId(localAppPath), localAppPath); } private AppLauncher(IDevice device, String appId, File localAppPath) { if (device == null) { throw new NullPointerException("device"); } if (appId == null) { throw new NullPointerException("appId"); } this.device = device; this.appId = appId; this.localAppPath = localAppPath; } private static String getAppId(File f) throws IOException { if (f == null) { throw new NullPointerException("localAppPath"); } if (!f.exists()) { throw new FileNotFoundException(f.getAbsolutePath()); } NSDictionary infoPlistDict = null; if (f.getName().toLowerCase().endsWith(".ipa")) { try (ZipFile zipFile = new ZipFile(f)) { for (ZipEntry entry : Collections.list(zipFile.entries())) { if (entry.getName().matches("Payload/[^/]+\\.app/Info\\.plist")) { try (InputStream is = zipFile.getInputStream(entry)) { try { infoPlistDict = (NSDictionary) PropertyListParser.parse(is); } catch (IOException e) { throw e; } catch (Exception e) { throw new IOException(e); } break; } } } } } else if (f.isDirectory()) { File infoPlistFile = new File(f, "Info.plist"); if (infoPlistFile.exists()) { try { infoPlistDict = (NSDictionary) PropertyListParser.parse(infoPlistFile); } catch (IOException e) { throw e; } catch (Exception e) { throw new IOException(e); } } } if (infoPlistDict == null) { throw new IllegalArgumentException("Path " + f + " is neither a " + ".ipa file nor an iOS app bundle directory."); } NSString appId = (NSString) infoPlistDict.objectForKey("CFBundleIdentifier"); if (appId == null) { throw new IllegalArgumentException("No CFBundleIdentifier found in " + "the Info.plist file in " + f); } return appId.toString(); } /** * Sets an {@link UploadProgressCallback} which will be used to report the * progress when the app is uploaded to the device. * * @param callback the callback. */ public AppLauncher uploadProgressCallback(UploadProgressCallback callback) { this.uploadProgressCallback = callback; return this; } /** * Sets an {@link StatusCallback} which will be used to report the * progress when the app is installed on the device. * * @param callback the callback. */ public AppLauncher installStatusCallback(StatusCallback callback) { this.installStatusCallback = callback; return this; } /** * Adds command line arguments which will be passed to the app on launch. * * @param args the arguments to be added. */ public AppLauncher args(String ... args) { this.args.addAll(Arrays.asList(args)); return this; } /** * Sets an {@link OutputStream} which all console output (stdout and stderr) * of the app will be written to when the app is launched. By default all * output will be written to {@link System#out} * * @param stdout the {@link OutputStream}. */ public AppLauncher stdout(OutputStream stdout) { if (stdout == null) { throw new NullPointerException("stdout"); } this.stdout = stdout; return this; } /** * Sets whether the stdout stream should be closed once the app has * terminated. * * @param closeOutOnExit <code>true</code> or <code>false</code>. */ public AppLauncher closeOutOnExit(boolean closeOutOnExit) { this.closeOutOnExit = closeOutOnExit; return this; } /** * Adds an environment variable which will be set when launching the app. * * @param name the variable name. * @param value the variable value. */ public AppLauncher env(String name, String value) { if (name == null) { throw new NullPointerException("name"); } if (value == null) { throw new NullPointerException("value"); } this.env.put(name, value); return this; } /** * Adds environment variables which will be set when launching the app. * * @param env the variables. */ public AppLauncher env(Map<String, String> env) { if (env == null) { throw new NullPointerException("env"); } this.env.putAll(env); return this; } /** * Sets whether GDB protocol packets should be logged to {@link System#out}. * Disabled by default. * * @param debug <code>true</code> to enabled debug logging. */ public AppLauncher debug(boolean debug) { this.debug = debug; return this; } /** * Forwards all GDB communication to the local TCP port after the app * has been successfully launched. * @param localPort local port or -1 to disable */ public AppLauncher forward(int localPort) { this.localPort = localPort; return this; } /** * Sets a callback that is invoked when the remote app info is known. */ public AppLauncher appLauncherCallback(AppLauncherCallback callback) { this.appLauncherCallback = callback; return this; } /** * Sets the path to Xcode where developer images will be searched for. This * should be set to the value returned by {@code xcode-select}. If not set * {@code /Applications/Xcode.app/Contents/Developer} will be used. * * @param xcodePath the Xcode path. */ public AppLauncher xcodePath(String xcodePath) { this.xcodePath = xcodePath; return this; } /** * Sets the number of times to retry a launch if the device is locked. * Default is 5. */ public AppLauncher launchOnLockedRetries(int launchOnLockedRetries) { this.launchOnLockedRetries = launchOnLockedRetries; return this; } /** * Sets the number of seconds to wait between launch retries when the device * is locked. The default is 5. */ public AppLauncher secondsBetweenLaunchOnLockedRetries(int secondsBetweenLaunchOnLockedRetries) { this.secondsBetweenLaunchOnLockedRetries = secondsBetweenLaunchOnLockedRetries; return this; } public void kill() { killed = true; } private static String toHex(String s) { StringBuilder sb = new StringBuilder(s.length() * 2); byte[] bytes; try { bytes = s.getBytes("UTF8"); } catch (UnsupportedEncodingException e) { throw new Error(e); } for (int i = 0; i < bytes.length; i++) { int c = bytes[i] & 0xff; sb.append(HEX_CHARS[c >> 4]); sb.append(HEX_CHARS[c & 0xf]); } return sb.toString(); } private static byte fromHex(char c1, char c2) { int d = 0; if (c1 <= '9') { d = c1 - '0'; } else { d = c1 - 'a' + 10; } d <<= 4; if (c2 <= '9') { d |= c2 - '0'; } else { d |= c2 - 'a' + 10; } return (byte) d; } private static byte[] fromHex(String s) { int length = s.length(); byte[] data = new byte[length / 2]; for (int i = 0; i < (length >> 1); i++) { data[i] = fromHex(s.charAt(i * 2), s.charAt(i * 2 + 1)); } return data; } private static byte[] fromHex(byte[] buffer, int offset, int length) { byte[] data = new byte[length / 2]; for (int i = 0; i < (length >> 1); i++) { data[i] = fromHex((char)buffer[offset + i * 2], (char)buffer[offset + i * 2 + 1]); } return data; } private String encode(String cmd) { int checksum = 0; for (int i = 0; i < cmd.length(); i++) { checksum += cmd.charAt(i); } return String.format("$%s#%02x", cmd, checksum & 0xff); } private String decode(String packet) { int start = 1; if (packet.charAt(0) == '+' || packet.charAt(0) == '-') { start = 2; } int end = packet.lastIndexOf('#'); return packet.substring(start, end); } private void debugGdb(String s) { if (debug) { System.out.println(s); } } /** * Logs a message to {@link System#out}. Override this method to use a * custom logger. */ protected void log(String s, Object ... args) { System.out.format(s, args); System.out.println(); } private void sendGdbPacket(IDeviceConnection conn, String packet) throws IOException { debugGdb("Sending packet: " + packet); byte[] data = packet.getBytes("ASCII"); while (true) { int sentBytes = conn.send(data, 0, data.length); if (sentBytes == data.length) { break; } data = Arrays.copyOfRange(data, sentBytes, data.length); } } private String receiveGdbPacket(IDeviceConnection conn) throws IOException, TimeoutException { return receiveGdbPacket(conn, Integer.MAX_VALUE); } private String receiveGdbPacket(IDeviceConnection conn, long timeout) throws IOException, TimeoutException { int packetEnd = bufferedResponses.indexOf("#"); if (packetEnd != -1 && bufferedResponses.length() - packetEnd > 2) { String packet = bufferedResponses.substring(0, packetEnd + 3); bufferedResponses.delete(0, packetEnd + 3); debugGdb("Received packet: " + packet); return packet; } long deadline = System.currentTimeMillis() + timeout; while (true) { if (killed || Thread.currentThread().isInterrupted()) { killed = true; throw new InterruptedIOException(); } int receivedBytes = conn.receive(buffer, 0, buffer.length, 10); if (receivedBytes > 0) { bufferedResponses.append(new String(buffer, 0, receivedBytes, "ASCII")); packetEnd = bufferedResponses.indexOf("#"); if (packetEnd != -1 && bufferedResponses.length() - packetEnd > 2) { String packet = bufferedResponses.substring(0, packetEnd + 3); bufferedResponses.delete(0, packetEnd + 3); debugGdb("Received packet: " + packet); return packet; } } if (System.currentTimeMillis() > deadline) { throw new TimeoutException(); } } } private boolean receiveGdbAck(IDeviceConnection conn) throws IOException { if (bufferedResponses.length() > 0) { char c = bufferedResponses.charAt(0); bufferedResponses.delete(0, 1); return c == '+'; } byte[] buffer = new byte[1]; conn.receive(buffer, 0, buffer.length, RECEIVE_TIMEOUT); debugGdb("Received ack: " + (char) buffer[0]); return buffer[0] == '+'; } private void sendReceivePacket(IDeviceConnection conn, String packet, String expectedResponse, boolean ackMode) throws IOException, TimeoutException { sendGdbPacket(conn, packet); if (ackMode) { receiveGdbAck(conn); } String response = decode(receiveGdbPacket(conn, RECEIVE_TIMEOUT)); if (!expectedResponse.equals(response)) { if (response.startsWith("E")) { throw new RuntimeException("Launch failed: " + response.substring(1)); } throw new RuntimeException("Launch failed: Unexpected response '" + response + "' to command '" + decode(packet) + "'"); } } private void kill(IDeviceConnection conn) throws IOException, TimeoutException { // We're killed. Try to shutdown nicely. killed = false; Thread.interrupted(); debugGdb("Sending break"); conn.send(BREAK, 0, BREAK.length); receiveGdbPacket(conn, RECEIVE_TIMEOUT); sendGdbPacket(conn, encode("k")); } private String encodeArgs(String appPath) { StringBuilder sb = new StringBuilder(); String hex = toHex(appPath); sb.append(String.format("%d,0,%s", hex.length(), hex)); for (int i = 0; i < args.size(); i++) { hex = toHex(args.get(i)); sb.append(String.format(",%d,%d,%s", hex.length(), i + 1, hex)); } return sb.toString(); } private String getAppPath(LockdowndClient lockdowndClient, String appId) throws IOException { LockdowndServiceDescriptor instService = lockdowndClient.startService(InstallationProxyClient.SERVICE_NAME); try (InstallationProxyClient instClient = new InstallationProxyClient(device, instService)) { NSArray apps = instClient.browse(); for (int i = 0; i < apps.count(); i++) { NSDictionary appInfo = (NSDictionary) apps.objectAtIndex(i); NSString bundleId = (NSString) appInfo.objectForKey("CFBundleIdentifier"); if (bundleId != null && appId.equals(bundleId.toString())) { NSString path = (NSString) appInfo.objectForKey("Path"); NSDictionary entitlements = (NSDictionary) appInfo.objectForKey("Entitlements"); if (entitlements == null || entitlements.objectForKey("get-task-allow") == null || !entitlements.objectForKey("get-task-allow").equals(new NSNumber(true))) { throw new RuntimeException("App with id '" + appId + "' does not " + "have the 'get-task-allow' entitlement and cannot be debugged"); } if (path == null) { throw new RuntimeException("Path for app with id '" + appId + "' not found"); } return path.toString(); } } throw new RuntimeException("No app with id '" + appId + "' found on device"); } } public void install() throws IOException { if (!installed) { try (LockdowndClient lockdowndClient = new LockdowndClient(device, getClass().getSimpleName(), true)) { uploadInternal(); if (uploadProgressCallback == null) { log("[ 50%%] Upload done. Installing app..."); } installInternal(); installed = true; } catch (IOException e) { throw e; } catch (RuntimeException e) { throw e; } catch (Exception e) { throw new RuntimeException(); } } } private File getXcodePath() throws Exception { if (xcodePath != null) { return new File(xcodePath); } File tmpFile = File.createTempFile(this.getClass().getSimpleName(), ".tmp"); try { int ret = new ProcessBuilder("xcode-select", "-print-path") .redirectErrorStream(true) .redirectOutput(Redirect.to(tmpFile)) .start().waitFor(); if (ret != 0) { throw new IOException("xcode-select failed with error code: " + ret); } return new File(new String(Files.readAllBytes(tmpFile.toPath()), "UTF-8").trim()); } finally { tmpFile.delete(); } } static File findDeveloperImage(File dsDir, String productVersion, String buildVersion) throws FileNotFoundException { String[] versionParts = getProductVersionParts(productVersion); String[] patterns = new String[] { // 7.0.3 (11B508) String.format("%s\\.%s\\.%s \\(%s\\)", versionParts[0], versionParts[1], versionParts[2], buildVersion), // 7.0.3 (*) String.format("%s\\.%s\\.%s \\(.*\\)", versionParts[0], versionParts[1], versionParts[2], buildVersion), // 7.0.3 String.format("%s\\.%s\\.%s", versionParts[0], versionParts[1], versionParts[2]), // 7.0 (11A465) String.format("%s\\.%s \\(%s\\)", versionParts[0], versionParts[1], buildVersion), // 7.0 (*) String.format("%s\\.%s \\(.*\\)", versionParts[0], versionParts[1], buildVersion), // 7.0 String.format("%s\\.%s", versionParts[0], versionParts[1]) }; File[] dirs = dsDir.listFiles(); for (String pattern : patterns) { for (File dir : dirs) { if (dir.isDirectory() && dir.getName().matches(pattern)) { File dmg = new File(dir, "DeveloperDiskImage.dmg"); File sig = new File(dir, dmg.getName() + ".signature"); if (dmg.isFile() && sig.isFile()) { return dmg; } } } } throw new FileNotFoundException("No DeveloperDiskImage.dmg found in " + dsDir.getAbsolutePath() + " for iOS version " + productVersion + " (" + buildVersion + ")"); } /** * Splits productVersion and expand to 3 parts (e.g. 7.0 -> 7.0.0) */ private static String[] getProductVersionParts(String productVersion) { String[] versionParts = Arrays.copyOf(productVersion.split("\\."), 3); for (int i = 0; i < versionParts.length; i++) { if (versionParts[i] == null) { versionParts[i] = "0"; } } return versionParts; } private void mountDeveloperImage(LockdowndClient lockdowndClient) throws Exception { // Find the DeveloperDiskImage.dmg path that best matches the current device. Here's what // the paths look like: // Platforms/iPhoneOS.platform/DeviceSupport/5.0/DeveloperDiskImage.dmg // Platforms/iPhoneOS.platform/DeviceSupport/6.0/DeveloperDiskImage.dmg // Platforms/iPhoneOS.platform/DeviceSupport/6.1/DeveloperDiskImage.dmg // Platforms/iPhoneOS.platform/DeviceSupport/7.0/DeveloperDiskImage.dmg // Platforms/iPhoneOS.platform/DeviceSupport/7.0 (11A465)/DeveloperDiskImage.dmg // Platforms/iPhoneOS.platform/DeviceSupport/7.0.3 (11B508)/DeveloperDiskImage.dmg String productVersion = lockdowndClient.getValue(null, "ProductVersion").toString(); // E.g. 7.0.2 String buildVersion = lockdowndClient.getValue(null, "BuildVersion").toString(); // E.g. 11B508 File deviceSupport = new File(getXcodePath(), "Platforms/iPhoneOS.platform/DeviceSupport"); log("Looking up developer disk image for iOS version %s (%s) in %s", productVersion, buildVersion, deviceSupport); File devImage = findDeveloperImage(deviceSupport, productVersion, buildVersion); File devImageSig = new File(devImage.getParentFile(), devImage.getName() + ".signature"); byte[] devImageSigBytes = Files.readAllBytes(devImageSig.toPath()); LockdowndServiceDescriptor mimService = lockdowndClient.startService(MobileImageMounterClient.SERVICE_NAME); try (MobileImageMounterClient mimClient = new MobileImageMounterClient(device, mimService)) { log("Copying developer disk image %s to device", devImage); int majorVersion = Integer.parseInt(getProductVersionParts(productVersion)[0]); if (majorVersion >= 7) { // Use new upload method mimClient.uploadImage(devImage, null, devImageSigBytes); } else { LockdowndServiceDescriptor afcService = lockdowndClient.startService(AfcClient.SERVICE_NAME); try (AfcClient afcClient = new AfcClient(device, afcService)) { afcClient.makeDirectory("/PublicStaging"); afcClient.fileCopy(devImage, "/PublicStaging/staging.dimage"); } } log("Mounting developer disk image"); NSDictionary result = mimClient.mountImage("/PublicStaging/staging.dimage", devImageSigBytes, null); NSString status = (NSString) result.objectForKey("Status"); if (status == null || !"Complete".equals(status.toString())) { throw new IOException("Failed to mount " + devImage.getAbsolutePath() + " on the device."); } } } private int launchInternal() throws Exception { install(); int lockedRetriesLeft = launchOnLockedRetries; while (true) { IDeviceConnection conn = null; String appPath = null; try (LockdowndClient lockdowndClient = new LockdowndClient(device, getClass().getSimpleName(), true)) { appPath = getAppPath(lockdowndClient, appId); String productVersion = lockdowndClient.getValue(null, "ProductVersion").toString(); // E.g. 7.0.2 String buildVersion = lockdowndClient.getValue(null, "BuildVersion").toString(); // E.g. 11B508 if(appLauncherCallback != null) { appLauncherCallback.setAppLaunchInfo(new AppLauncherInfo(device, appPath, productVersion, buildVersion)); } LockdowndServiceDescriptor debugService = null; try { debugService = lockdowndClient.startService(DEBUG_SERVER_SERVICE_NAME); } catch (LibIMobileDeviceException e) { if (e.getErrorCode() == LockdowndError.LOCKDOWN_E_INVALID_SERVICE.swigValue()) { // This happens when the developer image hasn't been mounted. // Mount and try again. mountDeveloperImage(lockdowndClient); debugService = lockdowndClient.startService(DEBUG_SERVER_SERVICE_NAME); } else { throw e; } } conn = device.connect(debugService.getPort()); log("Debug server port: " + debugService.getPort()); if (localPort != -1) { String exe = ((NSDictionary) PropertyListParser.parse(new File(localAppPath, "Info.plist"))).objectForKey("CFBundleExecutable").toString(); log("launchios \"" + new File(localAppPath, exe).getAbsolutePath() + "\" \"" + appPath + "\" " + localPort); StringBuilder argsString = new StringBuilder(); for (String arg : args) { if (argsString.length() > 0) { argsString.append(' '); } argsString.append(arg); } log("process launch -- " + argsString); } } if (lockedRetriesLeft == launchOnLockedRetries) { // First try log("Remote app path: " + appPath); log("Launching app..."); } else { log("Launching app (retry %d of %d)...", (launchOnLockedRetries - lockedRetriesLeft), launchOnLockedRetries); } try { // just pipe stdout if no port forwarding should be done // otherwise perform port forwarding and stdout piping if(localPort == -1) { return pipeStdOut(conn, appPath); } else { return forward(conn, appPath); } } catch (RuntimeException e) { if (!e.getMessage().contains("Locked") || lockedRetriesLeft == 0) { throw e; } lockedRetriesLeft--; log("Device locked. Retrying launch in %d seconds...", secondsBetweenLaunchOnLockedRetries); Thread.sleep(secondsBetweenLaunchOnLockedRetries * 1000); } finally { conn.dispose(); } } } private int pipeStdOut(IDeviceConnection conn, String appPath) throws Exception { log("App Path: %s", appPath); // Talk to the debugserver using the GDB remote protocol. // See https://sourceware.org/gdb/onlinedocs/gdb/Remote-Protocol.html. // This process has been determined by observing how Xcode talks to // the debugserver. To enable GDB remote protocol logging in Xcode // write the following to ~/.lldbinit: // log enable -v -f /tmp/gdb-remote.log gdb-remote all // Disable ack mode sendGdbPacket(conn, "+"); sendReceivePacket(conn, encode("QStartNoAckMode"), "OK", true); sendGdbPacket(conn, "+"); // Disable buffered IO. Xcode does it so we do it too. sendReceivePacket(conn, encode("QEnvironment:NSUnbufferedIO=YES"), "OK", false); // Set environment variables for (Entry<String, String> entry : env.entrySet()) { String cmd = String.format("QEnvironment:%s=%s", entry.getKey(), entry.getValue()); sendReceivePacket(conn, encode(cmd), "OK", false); } // Tell the debuserver to send threads:xxx,yyy,... in stop replies sendReceivePacket(conn, encode("QListThreadsInStopReply"), "OK", false); // Initialize argv with the app path and args sendReceivePacket(conn, encode("A" + encodeArgs(appPath)), "OK", false); // Make sure the launch was successful sendReceivePacket(conn, encode("qLaunchSuccess"), "OK", false); // Continue sendGdbPacket(conn, encode("c")); boolean wasInterrupted = false; try { while (true) { try { String response = receiveGdbPacket(conn); String payload = decode(response); if (payload.charAt(0) == 'W') { // The app exited. The number following W is the exit code. int exitCode = Integer.parseInt(payload.substring(1), 16); return exitCode; } else if (payload.charAt(0) == 'O') { // Console output encoded as hex. byte[] data = fromHex(payload.substring(1)); if (appLauncherCallback != null) { data = appLauncherCallback.filterOutput(data); } stdout.write(data); } else if (payload.charAt(0) == 'T') { // Signal received. Just continue. // The Continue packet looks like this (thread 0x2403 was interrupted by signal 0x0b): // $vCont;c:2603;c:2703;c:2803;c:2903;c:2a03;c:2b03;c:2c03;c:2d03;C0b:2403#ed String signal = payload.substring(1, 3); String data = payload.substring(3); String threadId = data.replaceAll(".*thread:([0-9a-fA-F]+).*", "$1"); String allThreadIds = data.replaceAll(".*threads:([0-9a-fA-F,]+).*", "$1"); Set<String> ids = new TreeSet<>(Arrays.asList(allThreadIds.split(","))); ids.remove(threadId); StringBuilder sb = new StringBuilder("vCont;"); for (String id : ids) { sb.append("c:").append(id).append(';'); } sb.append('C').append(signal).append(':').append(threadId); sendGdbPacket(conn, encode(sb.toString())); } else if (payload.charAt(0) == 'X') { int signal = Integer.parseInt(payload.substring(1, 3), 16); String data = payload.substring(3); String description = null; if (data.contains("description:")) { description = new String(fromHex(data.replaceAll(".*description:([0-9a-fA-F]+).*", "$1")), "UTF8").trim(); description = description.trim(); description = description.isEmpty() ? null : description; } String message = signal > 0 ? "The app crashed with signal " + signal : "The app crashed"; if (description != null) { message += ": " + description; } message += ". Check the device logs in Xcode (Window->Devices) for more info."; throw new RuntimeException(message); } else { throw new RuntimeException("Unexpected response " + "from debugserver: " + response); } } catch (InterruptedIOException e) { // Remember whether we were interrupted. kill() clears // the thread's interrupted state and we want to reset it // when we exit. wasInterrupted = Thread.currentThread().isInterrupted(); kill(conn); } } } finally { if (wasInterrupted) { Thread.currentThread().interrupt(); } } } private int forward(IDeviceConnection conn, String appPath) throws Exception { boolean wasInterrupted = false; Socket clientSocket = null; try(ServerSocket serverSocket = new ServerSocket(localPort)) { serverSocket.setReuseAddress(true); log("Waiting for GDB remote connection at http://127.0.0.1:" + localPort); clientSocket = serverSocket.accept(); clientSocket.setTcpNoDelay(true); log("GDB remote client connected"); } try (FileOutputStream fileOut = new FileOutputStream("/tmp/dbgout")){ final InputStream in = clientSocket.getInputStream(); final OutputStream out = clientSocket.getOutputStream(); byte[] buffer = new byte[10 * 4096]; GdbRemoteParser lldbParser = new GdbRemoteParser(); GdbRemoteParser debugServerParser = new GdbRemoteParser(); boolean nextPacketIsData = false; while (true) { try { // check if the client send us something and forward // it to the debug server. We may not get a full // command here, but we don't really care if(in.available() > 0) { int readBytes = in.read(buffer); int sent = 0; while(sent != readBytes) { sent += conn.send(buffer, sent, readBytes - sent); } List<byte[]> messages = lldbParser.parse(buffer, 0, readBytes); debugForward(fileOut, "lldb->debugserver: ", messages); for(byte[] m: messages) { if(m[1] == 'x') { nextPacketIsData = true; break; } } } // check if we've been interrupted if (killed || Thread.currentThread().isInterrupted()) { killed = true; throw new InterruptedIOException(); } // check if we got a reply from the debug server, wait // for 10 milliseconds try { int readBytes = conn.receive(buffer, 0, buffer.length, 1); if(readBytes > 0) { out.write(buffer, 0, readBytes); out.flush(); List<byte[]> messages = debugServerParser.parse(buffer, 0, readBytes); for(byte[] message: messages) { if (message[1] == 'W') { // The app exited. The number following W is the exit code. int exitCode = Integer.parseInt(new String(message, 2, message.length - 2 - 3, "ASCII"), 16); return exitCode; } else if (message[1] == 'O') { // Console output encoded as hex. if (!nextPacketIsData) { byte[] data = fromHex(message, 2, message.length - 2 - 3); if (appLauncherCallback != null) { data = appLauncherCallback.filterOutput(data); } stdout.write(data); } else { nextPacketIsData = false; } } } debugForward(fileOut, "debugserver->lldb: ", messages); } } catch(Exception e) { // nothing to do here, we simply didn't receive a message // unless we get an exception from libIMobileDevice which // means the device might be locked or crashed. if(e instanceof LibIMobileDeviceException) { throw new InterruptedIOException(e.getMessage()); } } } catch (InterruptedIOException e) { // Remember whether we were interrupted. kill() clears // the thread's interrupted state and we want to reset it // when we exit. wasInterrupted = Thread.currentThread().isInterrupted(); kill(conn); } } } finally { if(clientSocket != null) { clientSocket.close(); } if (wasInterrupted) { Thread.currentThread().interrupt(); } } } private void debugForward(OutputStream fileOut, String prefix, List<byte[]> messages) throws IOException { if(!debug) { return; } for(byte[] message: messages) { String msgStr = null; if(message.length > 256) { msgStr = "(" + message.length + ") " + new String(message, 0, 256, "ASCII"); } else { msgStr = new String(message, "ASCII"); } String msg = prefix + msgStr; fileOut.write(msg.getBytes("ASCII")); fileOut.write('\n'); System.out.println(msg); } } private void installInternal() throws Exception { try (LockdowndClient lockdowndClient = new LockdowndClient(device, getClass().getSimpleName(), true)) { final LibIMobileDeviceException[] ex = new LibIMobileDeviceException[1]; final CountDownLatch countDownLatch = new CountDownLatch(1); LockdowndServiceDescriptor instproxyService = lockdowndClient.startService(InstallationProxyClient.SERVICE_NAME); try (InstallationProxyClient instClient = new InstallationProxyClient(device, instproxyService)) { instClient.upgrade("/PublicStaging/" + localAppPath.getName(), new Options().packageType(localAppPath.isDirectory() ? PackageType.Developer : null), new StatusCallback() { @Override public void progress(String status, int percentComplete) { if (installStatusCallback != null) { installStatusCallback.progress(status, percentComplete); } else { log("[%3d%%] %s", 50 + percentComplete / 2, status); } } @Override public void success() { try { if (installStatusCallback != null) { installStatusCallback.success(); } else { log("[100%%] Installation complete"); } } finally { countDownLatch.countDown(); } } @Override public void error(String message) { try { ex[0] = new LibIMobileDeviceException(message); if (installStatusCallback != null) { installStatusCallback.error(message); } else { log("Error: %s", message); } } finally { countDownLatch.countDown(); } } }); countDownLatch.await(); } if (ex[0] != null) { throw ex[0]; } } } private void uploadInternal() throws Exception { try (LockdowndClient lockdowndClient = new LockdowndClient(device, getClass().getSimpleName(), true)) { LockdowndServiceDescriptor afcService = lockdowndClient.startService(AfcClient.SERVICE_NAME); try (AfcClient afcClient = new AfcClient(device, afcService)) { afcClient.upload(localAppPath, "/PublicStaging", new UploadProgressCallback() { public void progress(File path, int percentComplete) { if (uploadProgressCallback != null) { uploadProgressCallback.progress(path, percentComplete); } else { log("[%3d%%] Uploading %s", percentComplete / 2, path); } } public void success() { if (uploadProgressCallback != null) { uploadProgressCallback.success(); } } public void error(String message) { if (uploadProgressCallback != null) { uploadProgressCallback.error(message); } else { log("Error: %s", message); } } }); } } } public int launch() throws IOException { try { return launchInternal(); } catch (IOException e) { throw e; } catch (RuntimeException e) { throw e; } catch (Exception e) { throw new RuntimeException(e); } finally { if (closeOutOnExit) { try { stdout.close(); } catch (Throwable t) { // Ignore } } } } private static void printUsageAndExit() { System.err.println(AppLauncher.class.getName() + " ..."); System.err.println(" -appid the id (CFBundleIdentifier) of the app to launch."); System.err.println(" -b path to app bundle directory or IPA containing the app to launch."); System.err.println(" -udid id of the device to launch on. If not specified the first device will be used."); System.err.println(" -debug enable debug output."); System.err.println(" -f port forwards the debug server connection to the local port after the app has launched"); System.err.println(" -env name=value\n" + " adds an environment variable with the specified name and value."); System.err.println(" -args ... the rest of the command line will be passed on as args to the app."); System.exit(0); } public static void main(String[] args) throws Exception { String appId = null; File localAppPath = null; String[] arguments = new String[0]; Map<String, String> env = new HashMap<>(); boolean debug = false; String deviceId = null; int forwardPort = -1; int i = 0; loop: while (i < args.length) { switch (args[i++]) { case "-h": case "-help": printUsageAndExit(); break; case "-appid": appId = args[i++]; break; case "-b": localAppPath = new File(args[i++]); break; case "-f": forwardPort = Integer.parseInt(args[i++]); break; case "-udid": deviceId = args[i++]; break; case "-env": String[] parts = args[i++].split("=", 2); env.put(parts[0], parts[1]); break; case "-debug": debug = true; break; case "-args": arguments = Arrays.copyOfRange(args, i, args.length); break loop; } } if (appId == null && localAppPath == null) { printUsageAndExit(); } if (deviceId == null) { String[] udids = IDevice.listUdids(); if (udids.length == 0) { System.err.println("No device connected"); return; } if (udids.length > 1) { System.err.println("More than 1 device connected (" + Arrays.asList(udids) + "). Using " + udids[0]); } deviceId = udids[0]; } IDevice device = new IDevice(deviceId); AppLauncher launcher = null; if (localAppPath != null) { launcher = new AppLauncher(device, localAppPath); } else { launcher = new AppLauncher(device, appId); } System.exit(launcher .args(arguments) .env(env) .debug(debug) .forward(forwardPort) .launch()); } }