| #!/usr/bin/env python |
| # Copyright 2015 The Chromium Authors. All rights reserved. |
| # Use of this source code is governed by a BSD-style license that can be |
| # found in the LICENSE file. |
| |
| from skypy.skyserver import SkyServer |
| import argparse |
| import hashlib |
| import json |
| import logging |
| import os |
| import pipes |
| import platform |
| import re |
| import signal |
| import subprocess |
| import sys |
| import tempfile |
| import time |
| import urlparse |
| |
| SKY_TOOLS_DIR = os.path.dirname(os.path.abspath(__file__)) |
| SKY_ROOT = os.path.dirname(SKY_TOOLS_DIR) |
| SRC_ROOT = os.path.dirname(SKY_ROOT) |
| |
| GDB_PORT = 8888 |
| SKY_SERVER_PORT = 9888 |
| OBSERVATORY_PORT = 8181 |
| DEFAULT_URL = "https://domokit.github.io/home.dart" |
| APK_NAME = 'SkyShell.apk' |
| ADB_PATH = os.path.join(SRC_ROOT, |
| 'third_party/android_tools/sdk/platform-tools/adb') |
| ANDROID_PACKAGE = "org.domokit.sky.shell" |
| ANDROID_COMPONENT = '%s/%s.SkyActivity' % (ANDROID_PACKAGE, ANDROID_PACKAGE) |
| SHA1_PATH = '/sdcard/%s/%s.sha1' % (ANDROID_PACKAGE, APK_NAME) |
| |
| PID_FILE_PATH = "/tmp/shelldb.pids" |
| PID_FILE_KEYS = frozenset([ |
| 'remote_sky_server_port', |
| 'sky_server_pid', |
| 'sky_server_port', |
| 'sky_server_root', |
| 'build_dir', |
| 'sky_shell_pid', |
| 'remote_gdbserver_port', |
| ]) |
| |
| SYSTEM_LIBS_ROOT_PATH = '/tmp/device_libs' |
| |
| # This 'strict dictionary' approach is useful for catching typos. |
| class Pids(object): |
| def __init__(self, known_keys, contents=None): |
| self._known_keys = known_keys |
| self._dict = contents if contents is not None else {} |
| |
| def __len__(self): |
| return len(self._dict) |
| |
| def get(self, key, default=None): |
| assert key in self._known_keys, '%s not in known_keys' % key |
| return self._dict.get(key, default) |
| |
| def __getitem__(self, key): |
| assert key in self._known_keys, '%s not in known_keys' % key |
| return self._dict[key] |
| |
| def __setitem__(self, key, value): |
| assert key in self._known_keys, '%s not in known_keys' % key |
| self._dict[key] = value |
| |
| def __delitem__(self, key): |
| assert key in self._known_keys, '%s not in known_keys' % key |
| del self._dict[key] |
| |
| def __iter__(self): |
| return iter(self._dict) |
| |
| def __contains__(self, key): |
| assert key in self._known_keys, '%s not in allowed_keys' % key |
| return key in self._dict |
| |
| def clear(self): |
| self._dict = {} |
| |
| def pop(self, key, default=None): |
| assert key in self._known_keys, '%s not in allowed_keys' % key |
| return self._dict.pop(key, default) |
| |
| @classmethod |
| def read_from(cls, path, known_keys): |
| contents = {} |
| try: |
| with open(path, 'r') as pid_file: |
| contents = json.load(pid_file) |
| except: |
| if os.path.exists(path): |
| logging.warn('Failed to read pid file: %s' % path) |
| return cls(known_keys, contents) |
| |
| def write_to(self, path): |
| try: |
| with open(path, 'w') as pid_file: |
| json.dump(self._dict, pid_file, indent=2, sort_keys=True) |
| except: |
| logging.warn('Failed to write pid file: %s' % path) |
| |
| |
| # A free function for possible future sharing with a 'load' command. |
| def _url_from_args(args, pids): |
| if urlparse.urlparse(args.url_or_path).scheme: |
| return args.url_or_path |
| # The load happens on the remote device, use the remote port. |
| remote_sky_server_port = pids.get('remote_sky_server_port', |
| pids['sky_server_port']) |
| return SkyServer.url_for_path(remote_sky_server_port, |
| pids['sky_server_root'], args.url_or_path) |
| |
| |
| def dev_packages_root(build_dir): |
| return os.path.join(build_dir, 'gen', 'dart-pkg', 'packages') |
| |
| |
| class SetBuildDir(object): |
| def add_subparser(self, subparsers): |
| start_parser = subparsers.add_parser('set_build_dir', |
| help='force the build_dir to a particular value without starting Sky') |
| start_parser.add_argument('build_dir', type=str) |
| start_parser.set_defaults(func=self.run) |
| |
| def run(self, args, pids): |
| pids['build_dir'] = os.path.abspath(args.build_dir) |
| |
| |
| class StartSky(object): |
| def add_subparser(self, subparsers): |
| start_parser = subparsers.add_parser('start', |
| help='launch SkyShell.apk on the device') |
| start_parser.add_argument('build_dir', type=str) |
| start_parser.add_argument('--gdb', action="store_true") |
| start_parser.add_argument('url_or_path', nargs='?', type=str, |
| default=DEFAULT_URL) |
| start_parser.add_argument('--no_install', action="store_false", |
| default=True, dest="install", |
| help="Don't install SkyShell.apk before starting") |
| start_parser.set_defaults(func=self.run) |
| |
| def _server_root_for_url(self, url_or_path): |
| path = os.path.abspath(url_or_path) |
| if os.path.commonprefix([path, SRC_ROOT]) == SRC_ROOT: |
| server_root = SRC_ROOT |
| else: |
| server_root = os.path.dirname(path) |
| logging.warn( |
| '%s is outside of mojo root, using %s as server root' % |
| (path, server_root)) |
| return server_root |
| |
| def _sky_server_for_args(self, args, packages_root): |
| server_root = self._server_root_for_url(args.url_or_path) |
| sky_server = SkyServer(SKY_SERVER_PORT, server_root, packages_root) |
| return sky_server |
| |
| def _find_remote_pid_for_package(self, package): |
| ps_output = subprocess.check_output([ADB_PATH, 'shell', 'ps']) |
| for line in ps_output.split('\n'): |
| fields = line.split() |
| if fields and fields[-1] == package: |
| return fields[1] |
| return None |
| |
| def _find_install_location_for_package(self, package): |
| pm_command = [ADB_PATH, 'shell', 'pm', 'path', package] |
| pm_output = subprocess.check_output(pm_command) |
| # e.g. package:/data/app/org.chromium.mojo.shell-1/base.apk |
| return pm_output.split(':')[-1] |
| |
| def run(self, args, pids): |
| apk_path = os.path.join(args.build_dir, 'apks', APK_NAME) |
| if not os.path.exists(apk_path): |
| print "'%s' does not exist?" % apk_path |
| return 2 |
| |
| StopSky().run(args, pids) |
| |
| packages_root = dev_packages_root(args.build_dir) |
| sky_server = self._sky_server_for_args(args, packages_root) |
| pids['sky_server_pid'] = sky_server.start() |
| pids['sky_server_port'] = sky_server.port |
| pids['sky_server_root'] = sky_server.root |
| |
| pids['build_dir'] = os.path.abspath(args.build_dir) |
| |
| if args.install: |
| # We might need to install a new APK, so check SHA1 |
| source_sha1 = hashlib.sha1(open(apk_path, 'rb').read()).hexdigest() |
| dest_sha1 = subprocess.check_output([ADB_PATH, 'shell', 'cat', SHA1_PATH]) |
| use_existing_apk = False |
| if source_sha1 == dest_sha1: |
| # Make sure that the APK didn't get uninstalled somehow |
| use_existing_apk = subprocess.check_output([ |
| ADB_PATH, 'shell', 'pm', 'list', 'packages', ANDROID_PACKAGE |
| ]) |
| else: |
| # User is telling us not to bother installing an APK |
| use_existing_apk = True |
| |
| if use_existing_apk: |
| # APK is already on the device, we only need to stop it |
| subprocess.check_call([ |
| ADB_PATH, 'shell', 'am', 'force-stop', ANDROID_PACKAGE |
| ]) |
| else: |
| # Slow path, need to upload a new APK to the device |
| # -r to replace an existing apk, -d to allow version downgrade. |
| subprocess.check_call([ADB_PATH, 'install', '-r', '-d', apk_path]) |
| # record the SHA1 of the APK we just pushed |
| with tempfile.NamedTemporaryFile() as fp: |
| fp.write(source_sha1) |
| fp.seek(0) |
| subprocess.check_call([ADB_PATH, 'push', fp.name, SHA1_PATH]) |
| |
| # Set up port forwarding for observatory |
| port_string = 'tcp:%s' % OBSERVATORY_PORT |
| subprocess.check_call([ |
| ADB_PATH, 'forward', port_string, port_string |
| ]) |
| |
| port_string = 'tcp:%s' % sky_server.port |
| subprocess.check_call([ |
| ADB_PATH, 'reverse', port_string, port_string |
| ]) |
| pids['remote_sky_server_port'] = sky_server.port |
| |
| subprocess.check_call([ADB_PATH, 'shell', |
| 'am', 'start', |
| '-a', 'android.intent.action.VIEW', |
| '-d', _url_from_args(args, pids), |
| ANDROID_COMPONENT]) |
| |
| if not args.gdb: |
| return |
| |
| # TODO(eseidel): am start -W does not seem to work? |
| pid_tries = 0 |
| while True: |
| pid = self._find_remote_pid_for_package(ANDROID_PACKAGE) |
| if pid or pid_tries > 3: |
| break |
| logging.debug('No pid for %s yet, waiting' % ANDROID_PACKAGE) |
| time.sleep(5) |
| pid_tries += 1 |
| |
| if not pid: |
| logging.error('Failed to find pid on device!') |
| return |
| |
| pids['sky_shell_pid'] = pid |
| |
| # We push our own copy of gdbserver with the package since |
| # the default gdbserver is a different version from our gdb. |
| package_path = \ |
| self._find_install_location_for_package(ANDROID_PACKAGE) |
| gdb_server_path = os.path.join( |
| os.path.dirname(package_path), 'lib/arm/gdbserver') |
| gdbserver_cmd = [ |
| ADB_PATH, 'shell', |
| gdb_server_path, '--attach', |
| ':%d' % GDB_PORT, |
| str(pid) |
| ] |
| print ' '.join(map(pipes.quote, gdbserver_cmd)) |
| subprocess.Popen(gdbserver_cmd) |
| |
| port_string = 'tcp:%d' % GDB_PORT |
| subprocess.check_call([ |
| ADB_PATH, 'forward', port_string, port_string |
| ]) |
| pids['remote_gdbserver_port'] = GDB_PORT |
| |
| |
| class GDBAttach(object): |
| def add_subparser(self, subparsers): |
| start_parser = subparsers.add_parser('gdb_attach', |
| help='attach to gdbserver running on device') |
| start_parser.set_defaults(func=self.run) |
| |
| def _pull_system_libraries(self, pids, system_libs_root): |
| # Pull down the system libraries this pid has already mapped in. |
| # TODO(eseidel): This does not handle dynamic loads. |
| library_cacher_path = os.path.join( |
| SKY_TOOLS_DIR, 'android_library_cacher.py') |
| subprocess.call([ |
| library_cacher_path, system_libs_root, pids['sky_shell_pid'] |
| ]) |
| |
| # TODO(eseidel): adb_gdb does, this, unclear why solib-absolute-prefix |
| # doesn't make this explicit listing not necessary? |
| return subprocess.check_output([ |
| 'find', system_libs_root, |
| '-mindepth', '1', |
| '-maxdepth', '4', |
| '-type', 'd', |
| ]).strip().split('\n') |
| |
| def run(self, args, pids): |
| symbol_search_paths = [ |
| pids['build_dir'], |
| ] |
| gdb_path = '/usr/bin/gdb' |
| |
| eval_commands = [ |
| 'directory %s' % SRC_ROOT, |
| # TODO(eseidel): What file do I point it at? The apk? |
| #'file %s' % self.paths.mojo_shell_path, |
| 'target remote localhost:%s' % GDB_PORT, |
| ] |
| |
| # TODO(iansf): Fix undefined behavior when you have more than one device attached. |
| device_id = subprocess.check_output([ADB_PATH, 'get-serialno']).strip() |
| device_libs_path = os.path.join(SYSTEM_LIBS_ROOT_PATH, device_id) |
| |
| system_lib_dirs = self._pull_system_libraries(pids, device_libs_path) |
| eval_commands.append('set solib-absolute-prefix %s' % device_libs_path) |
| |
| symbol_search_paths = system_lib_dirs + symbol_search_paths |
| |
| # TODO(eseidel): We need to look up the toolchain somehow? |
| if platform.system() == 'Darwin': |
| gdb_path = os.path.join(SRC_ROOT, 'third_party/android_tools/ndk/' |
| 'toolchains/arm-linux-androideabi-4.9/prebuilt/darwin-x86_64/' |
| 'bin/arm-linux-androideabi-gdb') |
| else: |
| gdb_path = os.path.join(SRC_ROOT, 'third_party/android_tools/ndk/' |
| 'toolchains/arm-linux-androideabi-4.9/prebuilt/linux-x86_64/' |
| 'bin/arm-linux-androideabi-gdb') |
| |
| # Set solib-search-path after letting android modify symbol_search_paths |
| eval_commands.append( |
| 'set solib-search-path %s' % ':'.join(symbol_search_paths)) |
| |
| exec_command = [gdb_path] |
| for command in eval_commands: |
| exec_command += ['--eval-command', command] |
| |
| print " ".join(exec_command) |
| |
| # Write out our pid file before we exec ourselves. |
| pids.write_to(PID_FILE_PATH) |
| |
| # Exec gdb directly to avoid python intercepting symbols, etc. |
| os.execv(exec_command[0], exec_command) |
| |
| |
| |
| class StopSky(object): |
| def add_subparser(self, subparsers): |
| stop_parser = subparsers.add_parser('stop', |
| help=('kill all running SkyShell.apk processes')) |
| stop_parser.set_defaults(func=self.run) |
| |
| def _kill_if_exists(self, pids, key, name): |
| pid = pids.pop(key, None) |
| if not pid: |
| logging.info('No pid for %s, nothing to do.' % name) |
| return |
| logging.info('Killing %s (%d).' % (name, pid)) |
| try: |
| os.kill(pid, signal.SIGTERM) |
| except OSError: |
| logging.info('%s (%d) already gone.' % (name, pid)) |
| |
| def _adb_reverse_remove(self, port): |
| port_string = 'tcp:%s' % port |
| subprocess.call([ADB_PATH, 'reverse', '--remove', port_string]) |
| |
| def _adb_forward_remove(self, port): |
| port_string = 'tcp:%s' % port |
| subprocess.call([ADB_PATH, 'forward', '--remove', port_string]) |
| |
| def run(self, args, pids): |
| self._kill_if_exists(pids, 'sky_server_pid', 'sky_server') |
| |
| if 'remote_sky_server_port' in pids: |
| self._adb_reverse_remove(pids['remote_sky_server_port']) |
| |
| if 'remote_gdbserver_port' in pids: |
| self._adb_forward_remove(pids['remote_gdbserver_port']) |
| |
| subprocess.call([ |
| ADB_PATH, 'shell', 'am', 'force-stop', ANDROID_PACKAGE]) |
| |
| pids.clear() |
| |
| |
| class StartTracing(object): |
| def add_subparser(self, subparsers): |
| start_tracing_parser = subparsers.add_parser('start_tracing', |
| help=('start tracing a running sky instance')) |
| start_tracing_parser.set_defaults(func=self.run) |
| |
| def run(self, args, pids): |
| subprocess.check_output([ADB_PATH, 'shell', |
| 'am', 'broadcast', |
| '-a', 'org.domokit.sky.shell.TRACING_START']) |
| |
| |
| TRACE_COMPLETE_REGEXP = re.compile('Trace complete') |
| TRACE_FILE_REGEXP = re.compile(r'Saving trace to (?P<path>\S+)') |
| |
| class StopTracing(object): |
| def add_subparser(self, subparsers): |
| stop_tracing_parser = subparsers.add_parser('stop_tracing', |
| help=('stop tracing a running sky instance')) |
| stop_tracing_parser.set_defaults(func=self.run) |
| |
| def run(self, args, pids): |
| subprocess.check_output([ADB_PATH, 'logcat', '-c']) |
| subprocess.check_output([ADB_PATH, 'shell', |
| 'am', 'broadcast', |
| '-a', 'org.domokit.sky.shell.TRACING_STOP']) |
| device_path = None |
| is_complete = False |
| while not is_complete: |
| time.sleep(0.2) |
| log = subprocess.check_output([ADB_PATH, 'logcat', '-d']) |
| if device_path is None: |
| result = TRACE_FILE_REGEXP.search(log) |
| if result: |
| device_path = result.group('path') |
| is_complete = TRACE_COMPLETE_REGEXP.search(log) is not None |
| |
| print 'Downloading trace %s ...' % os.path.basename(device_path) |
| |
| if device_path: |
| subprocess.check_output([ADB_PATH, 'pull', device_path]) |
| subprocess.check_output([ADB_PATH, 'shell', 'rm', device_path]) |
| |
| |
| class SkyShellRunner(object): |
| def main(self): |
| logging.basicConfig(level=logging.WARNING) |
| |
| parser = argparse.ArgumentParser(description='Sky Shell Runner') |
| subparsers = parser.add_subparsers(help='sub-command help') |
| |
| commands = [ |
| SetBuildDir(), |
| StartSky(), |
| StopSky(), |
| GDBAttach(), |
| StartTracing(), |
| StopTracing(), |
| ] |
| |
| for command in commands: |
| command.add_subparser(subparsers) |
| |
| args = parser.parse_args() |
| pids = Pids.read_from(PID_FILE_PATH, PID_FILE_KEYS) |
| exit_code = args.func(args, pids) |
| # We could do this with an at-exit handler instead? |
| pids.write_to(PID_FILE_PATH) |
| sys.exit(exit_code) |
| |
| |
| if __name__ == '__main__': |
| SkyShellRunner().main() |