All Downloads are FREE. Search and download functionalities are using the official Maven repository.

org.robovm.libimobiledevice.util.AppLauncher Maven / Gradle / Ivy

The newest version!
/*
 * 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 .
 */
package org.robovm.libimobiledevice.util;

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;
import org.robovm.libimobiledevice.AfcClient;
import org.robovm.libimobiledevice.AfcClient.UploadProgressCallback;
import org.robovm.libimobiledevice.DebugServerClient;
import org.robovm.libimobiledevice.IDevice;
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.binding.MobileImageMounterError;
import org.robovm.libimobiledevice.util.AppLauncherCallback.AppLauncherInfo;
import org.robovm.libimobiledevice.util.Lambdas.CheckedConsumer;
import org.robovm.libimobiledevice.util.Lambdas.CheckedFunction;
import org.robovm.libimobiledevice.util.Lambdas.CheckedSupplier;

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.net.ServerSocket;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
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 static org.robovm.libimobiledevice.binding.LibIMobileDeviceConstants.DEBUGSERVER_SECURE_SERVICE_NAME;
import static org.robovm.libimobiledevice.binding.LibIMobileDeviceConstants.DEBUGSERVER_SERVICE_NAME;

/**
 * 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 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 String deviceUdid;
    private IDevice resolvedDevice;
    private final String appId;
    private final File localAppPath;
    private boolean installed = false;
    private List args = new ArrayList<>();
    private Map env = new HashMap();
    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 = 20;
    private int secondsBetweenLaunchOnLockedRetries = 1;
    
    /**
     * Creates a new {@link AppLauncher} which will launch an already installed
     * app with the specified id.
     * 
     * @param deviceUdid the device's UDID to connect to (or null to auto pick one).
     * @param appId the id (CFBundleIdentifier) of the app to run.
     */
    public AppLauncher(String deviceUdid, String appId) {
        this(deviceUdid, 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 deviceUdid the device's UDID to connect to (or null to auto pick one).
     * @param localAppPath the IPA file of app bundle dir containing the app to 
     *        install and launch.
     */
    public AppLauncher(String deviceUdid, File localAppPath) throws IOException {
        this(deviceUdid, getAppId(localAppPath), localAppPath);
    }

    private AppLauncher(String deviceUdid, String appId, File localAppPath) {
        if (appId == null) {
            throw new NullPointerException("appId");
        }
        this.deviceUdid = deviceUdid != null && !deviceUdid.isEmpty() ? deviceUdid : null;
        this.appId = appId;
        this.localAppPath = localAppPath;
    }

    private IDevice waitForDevice(String deviceUdid) throws Exception{
        final int retries = launchOnLockedRetries;
        int retriesLeft = retries;
        int secondsBetweenRetries = secondsBetweenLaunchOnLockedRetries;

        while (true) {
            String[] udids = IDevice.listUdids();
            if (udids.length == 1 && (deviceUdid == null || deviceUdid.equals(udids[0]))) {
                // single device and it's a match
                return new IDevice(udids[0]);
            } else if (udids.length > 1 && deviceUdid != null && Arrays.asList(udids).contains(deviceUdid)) {
                // multiple devices connected but specified is there
                return new IDevice(deviceUdid);
            }

            String message;
            if (udids.length == 0) {
                message = "No devices connected";
            } else if (deviceUdid != null) {
                message = String.format("Required %s is not connected (%s)", deviceUdid, Arrays.asList(udids));
            } else {
                message = String.format("More than 1 device connected (%s)", Arrays.asList(udids));
            }

            if (retriesLeft > 0) {
                retriesLeft -= 1;
                log("Waiting for device: %s. (retry %d of %d)...", message, (retries - retriesLeft), retries);
                Thread.sleep(secondsBetweenRetries * 1000L);
            } else throw new LibIMobileDeviceException(message);
        }
    }

    /**
     * Looks for connected device
     */
    private IDevice findDevice() throws Exception{
        if (resolvedDevice == null)
            resolvedDevice = waitForDevice(deviceUdid);

        return resolvedDevice;
    }

    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 true or false.
     */
    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 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 true 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;
        bytes = s.getBytes(StandardCharsets.UTF_8);
        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(DebugServerClient client, String packet) throws IOException {
        debugGdb("Sending packet: " + packet);
        byte[] data = packet.getBytes(StandardCharsets.US_ASCII);
        while (true) {
            int sentBytes = client.send(data, 0, data.length);
            if (sentBytes == data.length) {
                break;
            }
            data = Arrays.copyOfRange(data, sentBytes, data.length);
        }
    }

    private String receiveGdbPacket(DebugServerClient client) throws IOException, TimeoutException {
        return receiveGdbPacket(client, Integer.MAX_VALUE);
    }

    private String receiveGdbPacket(DebugServerClient client, 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 = client.receive(buffer, 0, buffer.length, 10);
            if (receivedBytes > 0) {
                bufferedResponses.append(new String(buffer, 0, receivedBytes, StandardCharsets.US_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(DebugServerClient client) throws IOException {
        if (bufferedResponses.length() > 0) {
            char c = bufferedResponses.charAt(0);
            bufferedResponses.delete(0, 1);
            return c == '+';
        }

        byte[] buffer = new byte[1];
        client.receive(buffer, 0, buffer.length, RECEIVE_TIMEOUT);
        debugGdb("Received ack: " + (char) buffer[0]);
        return buffer[0] == '+';
    }

    private void sendReceivePacket(DebugServerClient client, String packet,
                                   String expectedResponse, boolean ackMode) throws IOException, TimeoutException {

        sendGdbPacket(client, packet);
        if (ackMode) {
            receiveGdbAck(client);
        }
        String response = decode(receiveGdbPacket(client, 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(DebugServerClient client) throws IOException, TimeoutException {
        // We're killed. Try to shutdown nicely.
        killed = false;
        Thread.interrupted();
        debugGdb("Sending break");
        client.send(BREAK, 0, BREAK.length);
        receiveGdbPacket(client, RECEIVE_TIMEOUT);
        sendGdbPacket(client, 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(lockdowndClient.getDevice(), 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) {
            Retrying lockdownRetrying = new Retrying<>(() -> new LockdowndClient(findDevice(), getClass().getSimpleName(), true));
            try (lockdownRetrying) {
                // get lockdown client, retry if password protected
                LockdowndClient lockdowndClient = lockdownRetrying.perform((client) -> client);
                install(lockdowndClient);
            } catch (IOException | RuntimeException e) {
                throw e;
            } catch (Exception e) {
                throw new RuntimeException();
            }
        }
    }

    private void install(LockdowndClient lockdowndClient) throws Exception {
        if (!installed) {
            uploadInternal(lockdowndClient);
            if (uploadProgressCallback == null) {
                log("[ 50%%] Upload done. Installing app...");
            }
            installInternal(lockdowndClient);
            installed = true;
        }
    }

    /**
     * mounts developer image, first checks if its required
     * @return version of developer image that was mounted
     */
    private Version mountDeveloperImageIfRequired(LockdowndClient lockdowndClient, Version deviceVersion) throws Exception {
        Retrying retrying = new Retrying<>(() -> {
            LockdowndServiceDescriptor mimService = lockdowndClient.startService(MobileImageMounterClient.SERVICE_NAME);
            return new MobileImageMounterClient(lockdowndClient.getDevice(), mimService);
        });
        try (retrying) {
            // check if already mounted

            log("Checking if developer disk image requires to be mounted.");
            NSDictionary result = retrying.perform((mimClient) -> {
                return mimClient.lookupImage(null);
            });
            NSArray imageSignature = (NSArray) result.objectForKey("ImageSignature");
            if (imageSignature != null && imageSignature.count() > 0) {
                // already mounted
                log("Developer disk image is already mounted.");
                return null;
            }

            File deviceSupport = DeveloperImageResolver.getDeviceSupportPath();
            log("Looking up developer disk image for iOS version %s in %s", deviceVersion, deviceSupport);
            DeveloperImageResolver.Response devImageResp = DeveloperImageResolver.findDeveloperImage(deviceSupport, deviceVersion);
            byte[] devImageSigBytes = Files.readAllBytes(devImageResp.signature.toPath());

            log("Copying developer disk image %s to device", devImageResp.dmg);
            retrying.perform((mimClient) -> {
                mimClient.uploadImage(devImageResp.dmg, null, devImageSigBytes);
            });

            log("Mounting developer disk image");
            result = retrying.perform((mimClient) -> {
                return 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 " + devImageResp.dmg.getAbsolutePath() + " on the device.");
            }

            // FIXME: seems to be a delay required between mounting image and ability to start service
            Thread.sleep(1000);

            // verify that image was mounted
            result = retrying.perform((mimClient) -> {
                return mimClient.lookupImage(null);
            });
            if (result.objectForKey("ImageSignature") == null) {
                throw new LibIMobileDeviceException("Developer disk image mounting failed: status not mounted!");
            }
            return devImageResp.version;
        }
    }

    private int launchInternal() throws Exception {
        String appPath = null;

        Retrying lockdownRetrying = new Retrying<>(() -> new LockdowndClient(findDevice(), getClass().getSimpleName(), true));
        try (lockdownRetrying) {
            // get lockdown client, retry if password protected
            LockdowndClient lockdowndClient = lockdownRetrying.perform((client) -> client);

            // install if required
            install(lockdowndClient);

            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
            Version deviceVersion = Version.parse(productVersion);

            // check if development mode is enabled on ios16+ devices
            if (deviceVersion.getMajor() >= 16) {
                NSNumber developerModeStatus = (NSNumber) lockdowndClient.getValue("com.apple.security.mac.amfi", "DeveloperModeStatus");
                if (!developerModeStatus.boolValue()) {
                    String msg = "You have to enable Developer Mode on the given device!";
                    log(msg);
                    throw new RuntimeException(msg);
                }
            }

            // mount dev image if required
            Version mountedDevImage = mountDeveloperImageIfRequired(lockdowndClient, deviceVersion);

            // start debug server service
            LockdowndServiceDescriptor debugServerServiceDescriptor;
            {
                log("Starting DebugServerService.");

                // check if device is locked here before starting debug server otherwise it will fail without
                // ability to catch and retry
                try (Retrying retrying = new Retrying<>(() -> null)) {
                    retrying.perform((ignored) -> {
                        NSNumber status = (NSNumber) lockdowndClient.getValue(null, "PasswordProtected");
                        if (status !=  null && status.boolValue()) {
                            throw new LibIMobileDeviceException(LockdowndError.LOCKDOWN_E_PASSWORD_PROTECTED.swigValue(),
                                    LockdowndError.LOCKDOWN_E_PASSWORD_PROTECTED.name());
                        }
                    });
                }

                // starting from developer image version 13.6 there is secure version of debug server service
                // if dev image wasn't mounted in this session its version is not known then using
                // secure only for ios14+
                String serviceName;
                if (deviceVersion.getMajor() >= 14 || (mountedDevImage != null && mountedDevImage.compareTo(new Version(13, 6)) >= 0)) {
                    serviceName = DEBUGSERVER_SECURE_SERVICE_NAME;
                } else {
                    serviceName = DEBUGSERVER_SERVICE_NAME;
                }
                debugServerServiceDescriptor = lockdowndClient.startService(serviceName);
            }

            // app is ready to launch
            if(appLauncherCallback != null) {
                appLauncherCallback.setAppLaunchInfo(new AppLauncherInfo(lockdowndClient.getDevice(), appPath, productVersion, buildVersion));
            }

            // start debug service
            try (DebugServerClient client = new DebugServerClient(lockdowndClient.getDevice(), debugServerServiceDescriptor)) {
                log("Debug server port: " + debugServerServiceDescriptor.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);
                }

                log("Remote app path: " + appPath);
                log("Launching app...");

                // just pipe stdout if no port forwarding should be done
                // otherwise perform port forwarding and stdout piping
                if(localPort == -1) {
                    return pipeStdOut(client, appPath);
                } else {
                    return forward(client, appPath);
                }
            }
        }
    }
    
    private int pipeStdOut(DebugServerClient client, 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(client, "+");
        sendReceivePacket(client, encode("QStartNoAckMode"), "OK", true);
        sendGdbPacket(client, "+");

        // Disable buffered IO. Xcode does it so we do it too.
        sendReceivePacket(client, encode("QEnvironment:NSUnbufferedIO=YES"), "OK", false);
        // Set environment variables
        for (Entry entry : env.entrySet()) {
            String cmd = String.format("QEnvironment:%s=%s", entry.getKey(), entry.getValue());
            sendReceivePacket(client, encode(cmd), "OK", false);
        }
        // Tell the debuserver to send threads:xxx,yyy,... in stop replies
        sendReceivePacket(client, encode("QListThreadsInStopReply"), "OK", false);
        // Initialize argv with the app path and args
        sendReceivePacket(client, encode("A" + encodeArgs(appPath)), "OK", false);
        // Make sure the launch was successful
        sendReceivePacket(client, encode("qLaunchSuccess"), "OK", false);
        // Continue
        sendGdbPacket(client, encode("c"));

        boolean wasInterrupted = false;
        try {
            while (true) {
                try {
                    String response = receiveGdbPacket(client);
                    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 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(client, 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")), StandardCharsets.UTF_8).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(client);
                    // FIXME: don't wait to graceful response as recent libmobiledevice returns SSL_ERROR
                    return -1;
                }
            }
        } finally {
            if (wasInterrupted) {
                Thread.currentThread().interrupt();
            }
        }
    }

    private int forward(DebugServerClient client, 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 += client.send(buffer, sent, readBytes - sent);
                        }
                        List 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 = client.receive(buffer, 0, buffer.length, 1);
                        if(readBytes > 0) {
                            out.write(buffer, 0, readBytes);
                            out.flush();

                            List 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, StandardCharsets.US_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(client);
                    // FIXME: don't wait to graceful response as recent libmobiledevice returns SSL_ERROR
                    return -1;
                }
            }
        } finally {
            clientSocket.close();
            if (wasInterrupted) {
                Thread.currentThread().interrupt();
            }
        }
    }

    private void debugForward(OutputStream fileOut, String prefix, List 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, StandardCharsets.US_ASCII);
            } else {
                msgStr = new String(message, StandardCharsets.US_ASCII);
            }
            String msg = prefix + msgStr;
            fileOut.write(msg.getBytes(StandardCharsets.US_ASCII));
            fileOut.write('\n');
            System.out.println(msg);
        }
    }

    private void installInternal(LockdowndClient lockdowndClient) throws Exception {
        final LibIMobileDeviceException[] ex = new LibIMobileDeviceException[1];
        final CountDownLatch countDownLatch = new CountDownLatch(1);
        Retrying retrying = new Retrying<>(() -> {
            LockdowndServiceDescriptor instproxyService = lockdowndClient.startService(InstallationProxyClient.SERVICE_NAME);
            return new InstallationProxyClient(lockdowndClient.getDevice(), instproxyService);
        });
        try (retrying) {
            retrying.perform((instClient) -> {
                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(LockdowndClient lockdowndClient) throws Exception {
        Retrying retrying = new Retrying<>(() -> {
            LockdowndServiceDescriptor afcService = lockdowndClient.startService(AfcClient.SERVICE_NAME);
            return new AfcClient(lockdowndClient.getDevice(), afcService);
        });
        try (retrying) {
            retrying.perform((afcClient) -> {
                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);
                        }
                    }
                });
            });
        }
    }

    /**
     * Helper class that performs retry if skipable errors happen such as Device is locked
     * clientConstructor -- lambda that construct client that is passed into perform function
     */

    private class Retrying implements AutoCloseable {

        final private int retries;
        final private int secondsBetweenRetries;
        private int retriesLeft;
        private Client client;
        private final CheckedSupplier clientConstructor;

        public Retrying(int retries, int secondsBetweenRetries, CheckedSupplier clientConstructor) {
            this.retries = retries;
            this.secondsBetweenRetries = secondsBetweenRetries;
            this.retriesLeft = retries;
            this.clientConstructor = clientConstructor;
        }

        public Retrying(CheckedSupplier clientConstructor) {
            this(launchOnLockedRetries, secondsBetweenLaunchOnLockedRetries, clientConstructor);
        }

        private void perform(CheckedConsumer action) throws Exception {
            perform((client) -> {
                action.accept(client);
                return null;
            });
        }

        private  R perform(CheckedFunction action) throws Exception {
            while (true) {
                try {
                    if (client == null)
                        client = clientConstructor.get();
                    R r = action.apply(client);
                    // reset retries
                    this.retriesLeft = retries;
                    return r;
                } catch (LibIMobileDeviceException e) {
                    // if error had happened client to be re-created on next retry if it is not fatal
                    if (client != null) {
                        try {
                            client.close();;
                        } catch (Exception ignored){
                        }
                        client = null;
                    }

                    String message = null;
                    boolean fatal = true;
                    // if there is no retries left threat it as fatal
                    if (retriesLeft > 0) {
                        retriesLeft -= 1;
                        // check if it can be skipped
                        if (e.getErrorCode() == LockdowndError.LOCKDOWN_E_USER_DENIED_PAIRING.swigValue()) {
                            message = "Device is not paired with your computer, unlock it and choose to trust this computer when prompted.";
                        } else if (e.getErrorCode() == LockdowndError.LOCKDOWN_E_PAIRING_DIALOG_RESPONSE_PENDING.swigValue()) {
                            message = "Pairing in progress. Please choose to trust this computer.";
                            fatal = false;
                        } else if (e.getErrorCode() == LockdowndError.LOCKDOWN_E_PASSWORD_PROTECTED.swigValue() ||
                            e.getErrorCode() == MobileImageMounterError.MOBILE_IMAGE_MOUNTER_E_DEVICE_LOCKED.swigValue()) {
                            message = "Device is locked. Please unlock to proceed.";
                            fatal = false;
                        }
                    }

                    if (message != null) {
                        if (!fatal) log(message + " (retry %d of %d)...", (retries - retriesLeft), retries);
                        else log(message);
                    }

                    if (fatal)
                        throw e;

                    Thread.sleep(secondsBetweenRetries * 1000L);
                }
            }
        }

        @Override
        public void close() throws Exception {
            if (client != null)
                client.close();
        }
    }

    public int launch() throws IOException {
        try {
            return launchInternal();
        } catch (IOException | 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 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();
        }
        
        AppLauncher launcher;
        if (localAppPath != null) {
            launcher = new AppLauncher(deviceId, localAppPath);
        } else {
            launcher = new AppLauncher(deviceId, appId);
        }
        
        System.exit(launcher
                .args(arguments)
                .env(env)
                .debug(debug)
                .forward(forwardPort)
                .launch());
    }
}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy