blob: 6f063640bafc146dec0915e0a991a121caac84c3 [file] [log] [blame]
#!/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()