| #!/usr/bin/env python |
| # Copyright 2014 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. |
| |
| import skypy.paths |
| from skypy.skyserver import SkyServer |
| import argparse |
| import json |
| import logging |
| import os |
| import pipes |
| import requests |
| import signal |
| import subprocess |
| import sys |
| import time |
| import urlparse |
| |
| SRC_ROOT = skypy.paths.Paths('ignored').src_root |
| sys.path.insert(0, os.path.join(SRC_ROOT, 'build', 'android')) |
| from pylib import android_commands |
| from pylib import constants |
| from pylib import forwarder |
| |
| |
| SUPPORTED_MIME_TYPES = [ |
| 'text/html', |
| 'text/sky', |
| 'text/plain', |
| ] |
| |
| DEFAULT_SKY_COMMAND_PORT = 7777 |
| GDB_PORT = 8888 |
| SKY_SERVER_PORT = 9999 |
| PID_FILE_PATH = "/tmp/skydb.pids" |
| DEFAULT_URL = "https://raw.githubusercontent.com/domokit/mojo/master/sky/examples/home.sky" |
| |
| |
| # FIXME: Move this into mopy.config |
| def gn_args_from_build_dir(build_dir): |
| gn_cmd = [ |
| 'gn', 'args', |
| build_dir, |
| '--list', '--short' |
| ] |
| config = {} |
| for line in subprocess.check_output(gn_cmd).strip().split('\n'): |
| # FIXME: This doesn't handle = in values. |
| key, value = line.split(' = ') |
| config[key] = value |
| return config |
| |
| |
| class SkyDebugger(object): |
| def __init__(self): |
| self.pids = {} |
| self.paths = None |
| |
| 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 _in_chromoting(self): |
| return os.environ.get('CHROME_REMOTE_DESKTOP_SESSION', False) |
| |
| def _wrap_for_android(self, shell_args): |
| build_dir_url = SkyServer.url_for_path( |
| self.pids['remote_sky_server_port'], |
| self.pids['sky_server_root'], |
| self.pids['build_dir']) |
| shell_args += ['--origin=%s' % build_dir_url] |
| |
| # am shell --esa: (someone shoot me now) |
| # [--esa <EXTRA_KEY> <EXTRA_STRING_VALUE>[,<EXTRA_STRING_VALUE...]] |
| # (to embed a comma into a string escape it using "\,") |
| escaped_args = map(lambda arg: arg.replace(',', '\\,'), shell_args) |
| return [ |
| 'adb', 'shell', |
| 'am', 'start', |
| '-W', |
| '-S', |
| '-a', 'android.intent.action.VIEW', |
| '-n', 'org.chromium.mojo.shell/.MojoShellActivity', |
| # FIXME: This quoting is very error-prone. Perhaps we should read |
| # our args from a file instead? |
| '--esa', 'parameters', ','.join(escaped_args), |
| ] |
| |
| def _build_mojo_shell_command(self, args): |
| content_handlers = ['%s,%s' % (mime_type, 'mojo:sky_viewer') |
| for mime_type in SUPPORTED_MIME_TYPES] |
| |
| remote_command_port = self.pids.get('remote_sky_command_port', self.pids['sky_command_port']) |
| |
| shell_args = [ |
| '--v=1', |
| '--content-handlers=%s' % ','.join(content_handlers), |
| '--url-mappings=mojo:window_manager=mojo:sky_debugger', |
| '--args-for=mojo:sky_debugger_prompt %d' % remote_command_port, |
| 'mojo:window_manager', |
| ] |
| # FIXME: This probably is wrong for android? |
| if args.use_osmesa: |
| shell_args.append('--args-for=mojo:native_viewport_service --use-osmesa') |
| |
| if 'remote_sky_server_port' in self.pids: |
| shell_command = self._wrap_for_android(shell_args) |
| else: |
| shell_command = [self.paths.mojo_shell_path] + shell_args |
| |
| return shell_command |
| |
| def _connect_to_device(self): |
| device = android_commands.AndroidCommands( |
| android_commands.GetAttachedDevices()[0]) |
| device.EnableAdbRoot() |
| return device |
| |
| def sky_server_for_args(self, args): |
| # FIXME: This is a hack. sky_server should just take a build_dir |
| # not a magical "configuration" name. |
| configuration = os.path.basename(os.path.normpath(args.build_dir)) |
| server_root = self._server_root_for_url(args.url_or_path) |
| sky_server = SkyServer(self.paths, SKY_SERVER_PORT, |
| configuration, server_root) |
| return sky_server |
| |
| def _create_paths_for_build_dir(self, build_dir): |
| # skypy.paths.Paths takes a root-relative build_dir argument. :( |
| abs_build_dir = os.path.abspath(build_dir) |
| root_relative_build_dir = os.path.relpath(abs_build_dir, SRC_ROOT) |
| return skypy.paths.Paths(root_relative_build_dir) |
| |
| def start_command(self, args): |
| # FIXME: Lame that we use self for a command-specific variable. |
| self.paths = self._create_paths_for_build_dir(args.build_dir) |
| |
| self.stop_command(None) # Quit any existing process. |
| self.pids = {} # Clear out our pid file. |
| |
| # FIXME: This is probably not the right way to compute is_android |
| # from the build directory? |
| gn_args = gn_args_from_build_dir(args.build_dir) |
| is_android = 'android_sdk_version' in gn_args |
| |
| sky_server = self.sky_server_for_args(args) |
| self.pids['sky_server_pid'] = sky_server.start() |
| self.pids['sky_server_port'] = sky_server.port |
| self.pids['sky_server_root'] = sky_server.root |
| |
| self.pids['build_dir'] = args.build_dir |
| self.pids['sky_command_port'] = args.command_port |
| |
| if is_android: |
| # Pray to the build/android gods in their misspelled tongue. |
| constants.SetOutputDirectort(args.build_dir) |
| |
| device = self._connect_to_device() |
| self.pids['device_serial'] = device.GetDevice() |
| |
| forwarder.Forwarder.Map([(0, sky_server.port)], device) |
| device_http_port = forwarder.Forwarder.DevicePortForHostPort( |
| sky_server.port) |
| self.pids['remote_sky_server_port'] = device_http_port |
| |
| port_string = 'tcp:%s' % args.command_port |
| subprocess.check_call([ |
| 'adb', 'forward', port_string, port_string |
| ]) |
| self.pids['remote_sky_command_port'] = args.command_port |
| |
| shell_command = self._build_mojo_shell_command(args) |
| |
| # On android we can't launch inside gdb, but rather have to attach. |
| if args.gdb and not is_android: |
| shell_command = ['gdbserver', ':%s' % GDB_PORT] + shell_command |
| |
| print ' '.join(map(pipes.quote, shell_command)) |
| self.pids['mojo_shell_pid'] = subprocess.Popen(shell_command).pid |
| |
| if args.gdb and is_android: |
| gdbserver_cmd = ['gdbserver', '--attach', ':%s' % GDB_PORT] |
| self.pids['remote_gdbserver_pid'] = subprocess.Popen(shell_command).pid |
| |
| port_string = 'tcp:%s' % GDB_PORT |
| subprocess.check_call([ |
| 'adb', 'forward', port_string, port_string |
| ]) |
| self.pids['remote_gdbserver_port'] = GDB_PORT |
| |
| if not args.gdb: |
| if not self._wait_for_sky_command_port(): |
| logging.error('Failed to start sky') |
| self.stop_command(None) |
| else: |
| self.load_command(args) |
| else: |
| print 'No load issued, connect with gdb first and then run load.' |
| |
| def _kill_if_exists(self, key, name): |
| pid = self.pids.pop(key, None) |
| if not pid: |
| logging.info('No pid for %s, nothing to do.' % name) |
| return |
| logging.info('Killing %s (%s).' % (name, pid)) |
| try: |
| os.kill(pid, signal.SIGTERM) |
| except OSError: |
| logging.info('%s (%s) already gone.' % (name, pid)) |
| |
| def stop_command(self, args): |
| # TODO(eseidel): mojo_shell crashes when attempting graceful shutdown. |
| # self._send_command_to_sky('/quit') |
| self._kill_if_exists('mojo_shell_pid', 'mojo_shell') |
| |
| self._kill_if_exists('sky_server_pid', 'sky_server') |
| # We could be much more surgical here: |
| if 'remote_sky_server_port' in self.pids: |
| device = android_commands.AndroidCommands( |
| self.pids['device_serial']) |
| forwarder.Forwarder.UnmapAllDevicePorts(device) |
| |
| if 'remote_sky_command_port' in self.pids: |
| # adb forward --remove takes the *host* port, not the remote port. |
| port_string = 'tcp:%s' % self.pids['sky_command_port'] |
| subprocess.call(['adb', 'forward', '--remove', port_string]) |
| |
| if 'remote_gdbserver_port' in self.pids: |
| port_string = 'tcp:%s' % self.pids['remote_gdbserver_port'] |
| subprocess.call(['adb', 'forward', '--remove', port_string]) |
| |
| def load_command(self, args): |
| if not urlparse.urlparse(args.url_or_path).scheme: |
| # The load happens on the remote device, use the remote port. |
| remote_sky_server_port = self.pids.get('remote_sky_server_port', |
| self.pids['sky_server_port']) |
| url = SkyServer.url_for_path(remote_sky_server_port, |
| self.pids['sky_server_root'], args.url_or_path) |
| else: |
| url = args.url_or_path |
| self._send_command_to_sky('/load', url) |
| |
| def _command_base_url(self): |
| return 'http://localhost:%s' % self.pids['sky_command_port'] |
| |
| def _send_command_to_sky(self, command_path, payload=None): |
| url = 'http://localhost:%s%s' % ( |
| self.pids['sky_command_port'], command_path) |
| if payload: |
| response = requests.post(url, payload) |
| else: |
| response = requests.get(url) |
| print response.text |
| |
| # FIXME: These could be made into a context object with __enter__/__exit__. |
| def _load_pid_file(self, path): |
| try: |
| with open(path, 'r') as pid_file: |
| return json.load(pid_file) |
| except: |
| if os.path.exists(path): |
| logging.warn('Failed to read pid file: %s' % path) |
| return {} |
| |
| def _write_pid_file(self, path, pids): |
| try: |
| with open(path, 'w') as pid_file: |
| json.dump(pids, pid_file, indent=2, sort_keys=True) |
| except: |
| logging.warn('Failed to write pid file: %s' % path) |
| |
| def _add_basic_command(self, subparsers, name, url_path, help_text): |
| parser = subparsers.add_parser(name, help=help_text) |
| command = lambda args: self._send_command_to_sky(url_path) |
| parser.set_defaults(func=command) |
| |
| def _wait_for_sky_command_port(self): |
| tries = 0 |
| while True: |
| try: |
| self._send_command_to_sky('/') |
| return True |
| except: |
| tries += 1 |
| if tries == 3: |
| logging.warn('Still waiting for sky on port %s' % |
| self.pids['sky_command_port']) |
| if tries > 10: |
| return False |
| time.sleep(1) |
| |
| def logcat_command(self, args): |
| TAGS = [ |
| 'AndroidHandler', |
| 'MojoMain', |
| 'MojoShellActivity', |
| 'MojoShellApplication', |
| 'chromium', |
| ] |
| subprocess.call(['adb', 'logcat', '-s'] + TAGS) |
| |
| def gdb_attach_command(self, args): |
| self.paths = self._create_paths_for_build_dir(self.pids['build_dir']) |
| gdb_command = [ |
| '/usr/bin/gdb', self.paths.mojo_shell_path, |
| '--eval-command', 'target remote localhost:%s' % GDB_PORT |
| ] |
| print " ".join(gdb_command) |
| # We don't want python listenting for signals or anything, so exec |
| # gdb and let it take the entire process. |
| os.execv(gdb_command[0], gdb_command) |
| |
| def main(self): |
| logging.basicConfig(level=logging.INFO) |
| logging.getLogger("requests").setLevel(logging.WARNING) |
| |
| self.pids = self._load_pid_file(PID_FILE_PATH) |
| |
| parser = argparse.ArgumentParser(description='Sky launcher/debugger') |
| subparsers = parser.add_subparsers(help='sub-command help') |
| |
| start_parser = subparsers.add_parser('start', |
| help='launch a new mojo_shell with sky') |
| start_parser.add_argument('--gdb', action='store_true') |
| start_parser.add_argument('--command-port', type=int, |
| default=DEFAULT_SKY_COMMAND_PORT) |
| start_parser.add_argument('--use-osmesa', action='store_true', |
| default=self._in_chromoting()) |
| start_parser.add_argument('build_dir', type=str) |
| start_parser.add_argument('url_or_path', nargs='?', type=str, |
| default=DEFAULT_URL) |
| start_parser.add_argument('--show-command', action='store_true', |
| help='Display the shell command and exit') |
| start_parser.set_defaults(func=self.start_command) |
| |
| stop_parser = subparsers.add_parser('stop', |
| help=('stop sky (as listed in %s)' % PID_FILE_PATH)) |
| stop_parser.set_defaults(func=self.stop_command) |
| |
| logcat_parser = subparsers.add_parser('logcat', |
| help=('dump sky-related logs from device')) |
| logcat_parser.set_defaults(func=self.logcat_command) |
| |
| gdb_attach_parser = subparsers.add_parser('gdb_attach', |
| help='launch gdb and attach to gdbserver launched from start --gdb') |
| gdb_attach_parser.set_defaults(func=self.gdb_attach_command) |
| |
| self._add_basic_command(subparsers, 'trace', '/trace', |
| 'toggle tracing') |
| self._add_basic_command(subparsers, 'reload', '/reload', |
| 'reload the current page') |
| self._add_basic_command(subparsers, 'inspect', '/inspect', |
| 'stop the running sky instance') |
| |
| load_parser = subparsers.add_parser('load', |
| help='load a new page in the currently running sky') |
| load_parser.add_argument('url_or_path', type=str) |
| load_parser.set_defaults(func=self.load_command) |
| |
| args = parser.parse_args() |
| args.func(args) |
| |
| self._write_pid_file(PID_FILE_PATH, self.pids) |
| |
| |
| if __name__ == '__main__': |
| SkyDebugger().main() |