From 96c5d075aa0a50282a4b269a08177b6c07d90416 Mon Sep 17 00:00:00 2001 From: Ian Fischer Date: Thu, 20 Aug 2015 12:20:08 -0700 Subject: [PATCH] =?UTF-8?q?Make=20sky=5Ftool=20install=20to=20ios=20device?= =?UTF-8?q?s=20and=20simulators=20if=20available,=20and=20add=20a=20?= =?UTF-8?q?=E2=80=98listen=E2=80=99=20command=20that=20will=20listen=20to?= =?UTF-8?q?=20filesystem=20changes=20in=20the=20current=20directory=20and?= =?UTF-8?q?=20update=20running=20versions=20of=20the=20app.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Also refactors ios_sim.py to be part of sky_tool. The current skyx file that ‘listen’ generates does not contain material design icons, so icons will be missing. --- packages/flutter/lib/sky_tool | 440 +++++++++++++++++++++++++++++++++- 1 file changed, 432 insertions(+), 8 deletions(-) diff --git a/packages/flutter/lib/sky_tool b/packages/flutter/lib/sky_tool index e4cd15e9dc..eec989f780 100755 --- a/packages/flutter/lib/sky_tool +++ b/packages/flutter/lib/sky_tool @@ -5,6 +5,7 @@ import argparse import atexit +import errno import json import logging import os @@ -14,10 +15,10 @@ import signal import socket import subprocess import sys +import tempfile import time import urlparse -# TODO(eseidel): This should be BIN_DIR. PACKAGES_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) SKY_ENGINE_DIR = os.path.join(PACKAGES_DIR, 'sky_engine') APK_DIR = os.path.join(os.path.realpath(SKY_ENGINE_DIR), os.pardir, 'apks') @@ -29,6 +30,9 @@ APK_NAME = 'SkyShell.apk' ANDROID_PACKAGE = "org.domokit.sky.shell" ANDROID_COMPONENT = '%s/%s.SkyActivity' % (ANDROID_PACKAGE, ANDROID_PACKAGE) +SKY_SHELL_APP_ID = 'com.google.SkyShell' +IOS_APP_NAME = 'SkyShell.app' + # FIXME: Do we need to look in $DART_SDK? DART_PATH = 'dart' PUB_PATH = 'pub' @@ -41,6 +45,22 @@ PID_FILE_KEYS = frozenset([ 'sky_server_root', ]) +IOS_SIM_PATH = [ + '/Applications/iOS Simulator.app/Contents/MacOS/iOS Simulator' +] + +SIMCTL_PATH = [ + '/usr/bin/env', + 'xcrun', + 'simctl', +] + +PLIST_BUDDY_PATH = [ + '/usr/bin/env', + 'xcrun', + 'PlistBuddy', +] + def _port_in_use(port): sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) @@ -128,7 +148,6 @@ class StartSky(object): start_parser.add_argument('--install', action='store_true') start_parser.add_argument('--poke', action='store_true') start_parser.add_argument('--checked', action='store_true') - start_parser.add_argument('--build-path', type=str) start_parser.add_argument('project_or_path', nargs='?', type=str, default='.') start_parser.set_defaults(func=self.run) @@ -179,15 +198,26 @@ class StartSky(object): "exist to locate %s." \ % (os.path.basename(__file__), APK_NAME)) return 2 - if args.build_path is not None: - apk_path = os.path.join(args.build_path, 'apks', APK_NAME) + if args.local_build: + apk_path = os.path.join(os.path.normpath(args.sky_src_path), args.android_debug_build_path, 'apks', APK_NAME) else: apk_path = os.path.join(APK_DIR, APK_NAME) if not os.path.exists(apk_path): logging.error("'%s' does not exist?" % apk_path) return 2 - subprocess.check_call([ADB_PATH, 'install', '-r', apk_path]) + cmd = [ADB_PATH, 'install', '-r', apk_path] + subprocess.check_call(cmd) + + # Install on connected iOS device + if IOSDevice.is_connected() and args.local_build: + app_path = os.path.join(args.sky_src_path, args.ios_debug_build_path, IOS_APP_NAME) + IOSDevice.install_app(app_path) + + # Install on iOS simulator if it's running + if IOSSimulator.is_booted() and args.local_build: + app_path = os.path.join(args.sky_src_path, args.ios_sim_debug_build_path, IOS_APP_NAME) + IOSSimulator.fork_install_app(app_path) # Set up port forwarding for observatory observatory_port_string = 'tcp:%s' % OBSERVATORY_PORT @@ -254,6 +284,355 @@ class StopSky(object): pids.clear() +class IOSDevice(object): + _has_ios_deploy = None + @classmethod + def has_ios_deploy(cls): + if cls._has_ios_deploy is not None: + return cls._has_ios_deploy + cmd = [ + 'which', + 'ios-deploy' + ] + out = subprocess.check_output(cmd) + match = re.search(r'ios-deploy', out) + cls._has_ios_deploy = match is not None + return cls._has_ios_deploy + + _is_connected = False + @classmethod + def is_connected(cls): + if not cls.has_ios_deploy(): + return False + if cls._is_connected: + return True + cmd = [ + 'ios-deploy', + '--detect', + '--timeout', + '1' + ] + out = subprocess.check_output(cmd) + match = re.search(r'\[\.\.\.\.\] Found [^\)]*\) connected', out) + cls._is_connected = match is not None + return cls._is_connected + + @classmethod + def install_app(cls, ios_app_path): + if not cls.has_ios_deploy(): + return + cmd = [ + 'ios-deploy', + '--justlaunch', + '--timeout', + '10', # Smaller timeouts cause it to exit before having launched the app + '--bundle', + ios_app_path + ] + subprocess.check_call(cmd) + + @classmethod + def copy_file(cls, bundle_id, local_path, device_path): + if not cls.has_ios_deploy(): + return + cmd = [ + 'ios-deploy', + '-t', + '1', + '--bundle_id', + bundle_id, + '--upload', + local_path, + '--to', + device_path + ] + subprocess.check_call(cmd) + + +class IOSSimulator(object): + @classmethod + def is_booted(cls): + return cls.get_simulator_device_id() is not None + + _device_id = None + @classmethod + def get_simulator_device_id(cls): + if cls._device_id is not None: + return cls._device_id + cmd = [ + 'xcrun', + 'simctl', + 'list', + 'devices', + ] + out = subprocess.check_output(cmd) + match = re.search(r'[^\(]+\(([^\)]+)\) \(Booted\)', out) + if match is not None and match.group(1) is not None: + cls._device_id = match.group(1) + return cls._device_id + else: + logging.warning('No running simulators found') + # TODO: Maybe start the simulator? + return None + if err is not None: + print(err) + exit(-1) + + _simulator_path = None + @classmethod + def get_simulator_path(cls): + if cls._simulator_path is not None: + return cls._simulator_path + home_dir = os.path.expanduser("~") + device_id = cls.get_simulator_device_id() + if device_id is None: + # TODO: Maybe start the simulator? + return None + cls._simulator_path = os.path.join(home_dir, 'Library', 'Developer', 'CoreSimulator', 'Devices', device_id) + return cls._simulator_path + + _simulator_app_id = None + @classmethod + def get_simulator_app_id(cls): + if cls._simulator_app_id is not None: + return cls._simulator_app_id + simulator_path = cls.get_simulator_path() + cmd = [ + 'find', + simulator_path + '/data/Containers/Data/Application', + '-name', + SKY_SHELL_APP_ID + ] + out = subprocess.check_output(cmd) + match = re.search(r'Data\/Application\/([^\/]+)\/Documents\/' + SKY_SHELL_APP_ID, out) + if match is not None and match.group(1) is not None: + cls._simulator_app_id = match.group(1) + return cls._simulator_app_id + else: + logging.warning(SKY_SHELL_APP_ID + ' is not installed on the simulator') + # TODO: Maybe install the app? + return None + if err is not None: + print(err) + exit(-1) + + _simulator_app_documents_dir = None + @classmethod + def get_simulator_app_documents_dir(cls): + if cls._simulator_app_documents_dir is not None: + return cls._simulator_app_documents_dir + if not cls.is_booted(): + return None + simulator_path = cls.get_simulator_path() + simulator_app_id = cls.get_simulator_app_id() + if simulator_app_id is None: + return None + cls._simulator_app_documents_dir = os.path.join(simulator_path, 'data', 'Containers', 'Data', 'Application', simulator_app_id, 'Documents') + return cls._simulator_app_documents_dir + + @classmethod + def fork_install_app(cls, ios_app_path): + cmd = [ + os.path.abspath(__file__), + 'ios_sim', + '-p', + # This path manipulation is to work around an issue where simctl fails to correctly parse + # paths that start with ../ + ios_app_path, + 'launch' + ] + subprocess.check_call(cmd) + + def get_application_identifier(self, path): + identifier = subprocess.check_output( PLIST_BUDDY_PATH + [ + '-c', + 'Print CFBundleIdentifier', + '%s/Info.plist' % path, + ]) + return identifier.strip() + + def is_simulator_booted(self): + devices = subprocess.check_output( SIMCTL_PATH + [ 'list', 'devices' ]).strip().split('\n') + for device in devices: + if re.search(r'\(Booted\)', device): + return True + return False + + # Launch whatever simulator the user last used, rather than try to guess which of their simulators they might want to use + def boot_simulator(self, args, pids): + if self.is_simulator_booted(): + return + # Use Popen here because launching the simulator from the command line in this manner doesn't return, so we can't check the result. + if args.ios_sim_path: + subprocess.Popen(args.ios_sim_path) + else: + subprocess.Popen(IOS_SIM_PATH) + while not is_simulator_booted(): + print('Waiting for iOS Simulator to boot...') + time.sleep(0.5) + + def install_app(self, args, pids): + self.boot_simulator(args, pids) + cmd = SIMCTL_PATH + [ + 'install', + 'booted', + args.path, + ] + return subprocess.check_call(cmd) + + def install_launch_and_wait(self, args, pids, wait): + res = self.install_app(args, pids) + if res != 0: + return res + identifier = self.get_application_identifier(args.path) + launch_args = [ 'launch' ] + if wait: + launch_args += [ '-w' ] + launch_args += [ + 'booted', + identifier, + '-target', + args.target, + '-server', + args.server + ] + return subprocess.check_output( SIMCTL_PATH + launch_args ).strip() + + def launch_app(self, args, pids): + self.install_launch_and_wait(args, pids, False) + + def debug_app(self, args, pids): + launch_res = self.install_launch_and_wait(args, pids, True) + launch_pid = re.search('.*: (\d+)', launch_res).group(1) + return os.system(' '.join([ + '/usr/bin/env', + 'xcrun', + 'lldb', + # TODO(iansf): get this working again + # '-s', + # os.path.join(os.path.dirname(__file__), 'lldb_start_commands.txt'), + '-p', + launch_pid, + ])) + + def add_subparser(self, subparsers): + simulator_parser = subparsers.add_parser('ios_sim', + help='A script that launches an' + ' application in the simulator and attaches' + ' the debugger to it.') + simulator_parser.add_argument('-p', dest='path', required=True, + help='Path to the simulator application.') + simulator_parser.add_argument('-t', dest='target', required=False, + default='examples/demo_launcher/lib/main.dart', + help='Sky server-relative path to the Sky app to run.') + simulator_parser.add_argument('-s', dest='server', required=False, + default='localhost:8080', + help='Sky server address.') + simulator_parser.add_argument('--ios_sim_path', dest='ios_sim_path', + help='Path to your iOS Simulator executable. ' + 'Not normally required.') + + subparsers = simulator_parser.add_subparsers() + launch_parser = subparsers.add_parser('launch', help='Launch app') + launch_parser.set_defaults(func=self.launch_app) + install_parser = subparsers.add_parser('install', help='Install app') + install_parser.set_defaults(func=self.install_app) + debug_parser = subparsers.add_parser('debug', help='Debug app') + debug_parser.set_defaults(func=self.debug_app) + + def run(self, args, pids): + return args.func(args) + + +class StartListening(object): + def add_subparser(self, subparsers): + listen_parser = subparsers.add_parser('listen', + help=('Listen for changes to files and reload the running app on all connected devices')) + listen_parser.set_defaults(func=self.run) + + def run(self, args, pids): + cmd = [ + 'which', + 'fswatch' + ] + out = subprocess.check_output(cmd) + match = re.search(r'fswatch', out) + if match is None: + logging.error('"listen" command is only useful if you have installed fswatch. Run "brew install fswatch" to install it with homebrew.') + return + + tempdir = None + currdir = None + while True: + # Watch filesystem for changes + cmd = [ + 'fswatch', + '-r', + '-v', + '-1', + '.' + ] + subprocess.check_call(cmd) + + logging.info('Updating running Sky apps...') + + # Restart the app on Android. Android does not currently restart using skyx files. + cmd = [ + sys.executable, + os.path.abspath(__file__), + 'start', + '--poke' + ] + subprocess.check_call(cmd) + + if not args.local_build: + # Currently sending to iOS only works if you are building Sky locally + # since we aren't shipping the sky_snapshot binary yet. + continue + + if tempdir is None: + tempdir = tempfile.mkdtemp() + currdir = os.getcwd() + + # Build the snapshot + sky_snapshot_path = os.path.join(args.sky_src_path, args.ios_sim_debug_build_path, 'clang_x64', 'sky_snapshot') + cmd = [ + sky_snapshot_path, + '--package-root=packages', + '--snapshot=' + os.path.join(tempdir, 'snapshot_blob.bin'), + os.path.join('lib', 'main.dart') + ] + subprocess.check_call(cmd) + + os.chdir(tempdir) + # Turn the snapshot into an app.skyx file + cmd = [ + 'zip', + '-r', + 'app.skyx', + 'snapshot_blob.bin', + 'action', + 'content', + 'navigation' + ] + subprocess.check_call(cmd) + os.chdir(currdir) + + # Copy the app.skyx to the running simulator + simulator_app_documents_dir = IOSSimulator.get_simulator_app_documents_dir() + if simulator_app_documents_dir is not None: + cmd = [ + 'cp', + os.path.join(tempdir, 'app.skyx'), + simulator_app_documents_dir + ] + subprocess.check_call(cmd) + + # Copy the app.skyx to the attached iOS device + if IOSDevice.is_connected(): + IOSDevice.copy_file(SKY_SHELL_APP_ID, os.path.join(tempdir, 'app.skyx'), 'Documents/app.skyx') + + class StartTracing(object): def add_subparser(self, subparsers): start_tracing_parser = subparsers.add_parser('start_tracing', @@ -382,13 +761,58 @@ class SkyShellRunner(object): if not self._check_for_dart(): sys.exit(2) - parser = argparse.ArgumentParser(description='Sky Demo Runner') + parser = argparse.ArgumentParser(description='Sky App Runner') + parser.add_argument('--local-build', dest='local_build', action='store_true', + help='Set this if you are building Sky locally and want to use those build products. ' + 'When set, attempts to automaticaly determine sky-src-path if sky-src-path is ' + 'not set. Not normally required.') + parser.add_argument('--sky-src-path', dest='sky_src_path', + help='Path to your Sky src directory, if you are building Sky locally. ' + 'Ignored if local-build is not set. Not normally required.') + parser.add_argument('--android-debug-build-path', dest='android_debug_build_path', + help='Path to your Android Debug out directory, if you are building Sky locally. ' + 'This path is relative to sky-src-path. Not normally required.', + default='out/android_Debug/') + parser.add_argument('--ios-debug-build-path', dest='ios_debug_build_path', + help='Path to your iOS Debug out directory, if you are building Sky locally. ' + 'This path is relative to sky-src-path. Not normally required.', + default='out/ios_Debug/') + parser.add_argument('--ios-sim-debug-build-path', dest='ios_sim_debug_build_path', + help='Path to your iOS Simulator Debug out directory, if you are building Sky locally. ' + 'This path is relative to sky-src-path. Not normally required.', + default='out/ios_sim_Debug/') + subparsers = parser.add_subparsers(help='sub-command help') - for command in [StartSky(), StopSky(), StartTracing(), StopTracing()]: + for command in [StartSky(), StopSky(), StartListening(), StartTracing(), StopTracing(), IOSSimulator()]: command.add_subparser(subparsers) args = parser.parse_args() + if args.local_build and args.sky_src_path is None: + real_sky_path = os.path.realpath(os.path.join(PACKAGES_DIR, 'sky')) + match = re.match(r'pub.dartlang.org/sky', real_sky_path) + if match is not None: + args.local_build = False + else: + sky_src_path = os.path.dirname( + os.path.dirname( + os.path.dirname( + os.path.dirname(real_sky_path)))) + if sky_src_path == '/' or sky_src_path == '': + args.local_build = False + else: + args.sky_src_path = sky_src_path + + if not args.local_build: + logging.warning('Unable to detect a valid sky install. Disabling local-build flag.\n' + 'The recommended way to use a local build of Sky is to add the following\n' + 'to your pubspec.yaml file and then run pub get again:\n' + 'dependency_overrides:\n' + ' material_design_icons:\n' + ' path: /path/to/sky_engine/src/sky/packages/material_design_icons\n' + ' sky:\n' + ' path: /path/to/sky_engine/src/sky/packages/sky\n') + pids = Pids.read_from(PID_FILE_PATH, PID_FILE_KEYS) atexit.register(pids.write_to, PID_FILE_PATH) exit_code = 0 @@ -402,4 +826,4 @@ class SkyShellRunner(object): if __name__ == '__main__': - SkyShellRunner().main() + sys.exit(SkyShellRunner().main())