Merge pull request #169 from devoncarew/daemon
add a persistent daemon/server mode to sky_tools
This commit is contained in:
@@ -11,6 +11,7 @@ import 'package:stack_trace/stack_trace.dart';
|
||||
|
||||
import 'src/commands/build.dart';
|
||||
import 'src/commands/cache.dart';
|
||||
import 'src/commands/daemon.dart';
|
||||
import 'src/commands/flutter_command_runner.dart';
|
||||
import 'src/commands/init.dart';
|
||||
import 'src/commands/install.dart';
|
||||
@@ -28,7 +29,7 @@ import 'src/process.dart';
|
||||
/// This function is intended to be used from the [flutter] command line tool.
|
||||
Future main(List<String> args) async {
|
||||
// This level can be adjusted by users through the `--verbose` option.
|
||||
Logger.root.level = Level.SEVERE;
|
||||
Logger.root.level = Level.WARNING;
|
||||
Logger.root.onRecord.listen((LogRecord record) {
|
||||
if (record.level >= Level.WARNING) {
|
||||
stderr.writeln(record.message);
|
||||
@@ -44,6 +45,7 @@ Future main(List<String> args) async {
|
||||
FlutterCommandRunner runner = new FlutterCommandRunner()
|
||||
..addCommand(new BuildCommand())
|
||||
..addCommand(new CacheCommand())
|
||||
..addCommand(new DaemonCommand())
|
||||
..addCommand(new InitCommand())
|
||||
..addCommand(new InstallCommand())
|
||||
..addCommand(new ListCommand())
|
||||
|
||||
214
packages/flutter_tools/lib/src/commands/daemon.dart
Normal file
214
packages/flutter_tools/lib/src/commands/daemon.dart
Normal file
@@ -0,0 +1,214 @@
|
||||
// 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.
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
import 'flutter_command.dart';
|
||||
import 'start.dart';
|
||||
import 'stop.dart';
|
||||
|
||||
const String protocolVersion = '0.0.1';
|
||||
|
||||
/// A @domain annotation.
|
||||
const String domain = 'domain';
|
||||
|
||||
/// A domain @command annotation.
|
||||
const String command = 'command';
|
||||
|
||||
final Logger _logging = new Logger('sky_tools.daemon');
|
||||
|
||||
// TODO: Create a `device` domain in order to list devices and fire events when
|
||||
// devices are added or removed.
|
||||
|
||||
// TODO: Is this the best name? Server? Daemon?
|
||||
|
||||
/// A server process command. This command will start up a long-lived server.
|
||||
/// It reads JSON-RPC based commands from stdin, executes them, and returns
|
||||
/// JSON-RPC based responses and events to stdout.
|
||||
///
|
||||
/// It can be shutdown with a `daemon.shutdown` command (or by killing the
|
||||
/// process).
|
||||
class DaemonCommand extends FlutterCommand {
|
||||
final String name = 'daemon';
|
||||
final String description =
|
||||
'Run a persistent, JSON-RPC based server to communicate with devices.';
|
||||
final String usageFooter =
|
||||
'\nThis command is intended to be used by tooling environments that need '
|
||||
'a programatic interface into launching Flutter applications.';
|
||||
|
||||
@override
|
||||
Future<int> runInProject() async {
|
||||
print('Starting device daemon...');
|
||||
|
||||
Stream<Map> commandStream = stdin
|
||||
.transform(UTF8.decoder)
|
||||
.transform(const LineSplitter())
|
||||
.where((String line) => line.startsWith('[{') && line.endsWith('}]'))
|
||||
.map((String line) {
|
||||
line = line.substring(1, line.length - 1);
|
||||
return JSON.decode(line);
|
||||
});
|
||||
|
||||
await downloadApplicationPackagesAndConnectToDevices();
|
||||
|
||||
Daemon daemon = new Daemon(commandStream, (Map command) {
|
||||
stdout.writeln('[${JSON.encode(command)}]');
|
||||
}, daemonCommand: this);
|
||||
|
||||
return daemon.onExit;
|
||||
}
|
||||
}
|
||||
|
||||
typedef void DispatchComand(Map command);
|
||||
|
||||
typedef Future<dynamic> CommandHandler(dynamic args);
|
||||
|
||||
class Daemon {
|
||||
final DispatchComand sendCommand;
|
||||
final DaemonCommand daemonCommand;
|
||||
|
||||
final Completer<int> _onExitCompleter = new Completer();
|
||||
final Map<String, Domain> _domains = {};
|
||||
|
||||
Daemon(Stream<Map> commandStream, this.sendCommand, {this.daemonCommand}) {
|
||||
// Set up domains.
|
||||
_registerDomain(new DaemonDomain(this));
|
||||
_registerDomain(new AppDomain(this));
|
||||
|
||||
// Start listening.
|
||||
commandStream.listen(
|
||||
(Map command) => _handleCommand(command),
|
||||
onDone: () => _onExitCompleter.complete(0)
|
||||
);
|
||||
}
|
||||
|
||||
void _registerDomain(Domain domain) {
|
||||
_domains[domain.name] = domain;
|
||||
}
|
||||
|
||||
Future<int> get onExit => _onExitCompleter.future;
|
||||
|
||||
void _handleCommand(Map command) {
|
||||
// {id, event, params}
|
||||
var id = command['id'];
|
||||
|
||||
if (id == null) {
|
||||
_logging.severe('no id for command: ${command}');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
String event = command['event'];
|
||||
if (event.indexOf('.') == -1)
|
||||
throw 'command not understood: ${event}';
|
||||
|
||||
String prefix = event.substring(0, event.indexOf('.'));
|
||||
String name = event.substring(event.indexOf('.') + 1);
|
||||
if (_domains[prefix] == null)
|
||||
throw 'no domain for command: ${command}';
|
||||
|
||||
_domains[prefix].handleEvent(name, id, command['params']);
|
||||
} catch (error, trace) {
|
||||
_send({'id': id, 'error': _toJsonable(error)});
|
||||
_logging.warning('error handling ${command['event']}', error, trace);
|
||||
}
|
||||
}
|
||||
|
||||
void _send(Map map) => sendCommand(map);
|
||||
|
||||
void shutdown() {
|
||||
if (!_onExitCompleter.isCompleted)
|
||||
_onExitCompleter.complete(0);
|
||||
}
|
||||
}
|
||||
|
||||
abstract class Domain {
|
||||
final Daemon daemon;
|
||||
final String name;
|
||||
final Map<String, CommandHandler> _handlers = {};
|
||||
|
||||
Domain(this.daemon, this.name);
|
||||
|
||||
void registerHandler(String name, CommandHandler handler) {
|
||||
_handlers[name] = handler;
|
||||
}
|
||||
|
||||
String toString() => name;
|
||||
|
||||
void handleEvent(String name, dynamic id, dynamic args) {
|
||||
new Future.sync(() {
|
||||
if (_handlers.containsKey(name))
|
||||
return _handlers[name](args);
|
||||
throw 'command not understood: ${name}';
|
||||
}).then((result) {
|
||||
if (result == null) {
|
||||
_send({'id': id});
|
||||
} else {
|
||||
_send({'id': id, 'result': _toJsonable(result)});
|
||||
}
|
||||
}).catchError((error, trace) {
|
||||
_send({'id': id, 'error': _toJsonable(error)});
|
||||
_logging.warning('error handling ${name}', error, trace);
|
||||
});
|
||||
}
|
||||
|
||||
void _send(Map map) => daemon._send(map);
|
||||
}
|
||||
|
||||
/// This domain responds to methods like [version] and [shutdown].
|
||||
@domain
|
||||
class DaemonDomain extends Domain {
|
||||
DaemonDomain(Daemon daemon) : super(daemon, 'daemon') {
|
||||
registerHandler('version', version);
|
||||
registerHandler('shutdown', shutdown);
|
||||
}
|
||||
|
||||
@command
|
||||
Future<dynamic> version(dynamic args) {
|
||||
return new Future.value(protocolVersion);
|
||||
}
|
||||
|
||||
@command
|
||||
Future<dynamic> shutdown(dynamic args) {
|
||||
Timer.run(() => daemon.shutdown());
|
||||
return new Future.value();
|
||||
}
|
||||
}
|
||||
|
||||
/// This domain responds to methods like [start] and [stopAll].
|
||||
///
|
||||
/// It'll be extended to fire events for when applications start, stop, and
|
||||
/// log data.
|
||||
@domain
|
||||
class AppDomain extends Domain {
|
||||
AppDomain(Daemon daemon) : super(daemon, 'app') {
|
||||
registerHandler('start', start);
|
||||
registerHandler('stopAll', stopAll);
|
||||
}
|
||||
|
||||
@command
|
||||
Future<dynamic> start(dynamic args) {
|
||||
// TODO: Add the ability to pass args: target, http, checked
|
||||
StartCommand startComand = new StartCommand();
|
||||
startComand.inheritFromParent(daemon.daemonCommand);
|
||||
return startComand.runInProject().then((_) => null);
|
||||
}
|
||||
|
||||
@command
|
||||
Future<bool> stopAll(dynamic args) {
|
||||
StopCommand stopCommand = new StopCommand();
|
||||
stopCommand.inheritFromParent(daemon.daemonCommand);
|
||||
return stopCommand.stop();
|
||||
}
|
||||
}
|
||||
|
||||
dynamic _toJsonable(dynamic obj) {
|
||||
if (obj is String || obj is int || obj is bool || obj is Map || obj is List || obj == null)
|
||||
return obj;
|
||||
return '${obj}';
|
||||
}
|
||||
@@ -886,8 +886,10 @@ class AndroidDevice extends Device {
|
||||
'-v',
|
||||
'tag', // Only log the tag and the message
|
||||
'-s',
|
||||
'sky',
|
||||
'chromium',
|
||||
'sky:V',
|
||||
'chromium:D',
|
||||
'ActivityManager:W',
|
||||
'*:F',
|
||||
], prefix: 'android: ');
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
// found in the LICENSE file.
|
||||
|
||||
import 'android_device_test.dart' as android_device_test;
|
||||
import 'daemon_test.dart' as daemon_test;
|
||||
import 'init_test.dart' as init_test;
|
||||
import 'install_test.dart' as install_test;
|
||||
import 'listen_test.dart' as listen_test;
|
||||
@@ -15,6 +16,7 @@ import 'trace_test.dart' as trace_test;
|
||||
|
||||
main() {
|
||||
android_device_test.defineTests();
|
||||
daemon_test.defineTests();
|
||||
init_test.defineTests();
|
||||
install_test.defineTests();
|
||||
listen_test.defineTests();
|
||||
|
||||
80
packages/flutter_tools/test/daemon_test.dart
Normal file
80
packages/flutter_tools/test/daemon_test.dart
Normal file
@@ -0,0 +1,80 @@
|
||||
// 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.
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:mockito/mockito.dart';
|
||||
import 'package:sky_tools/src/commands/daemon.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
import 'src/mocks.dart';
|
||||
|
||||
main() => defineTests();
|
||||
|
||||
defineTests() {
|
||||
group('daemon', () {
|
||||
Daemon daemon;
|
||||
|
||||
tearDown(() {
|
||||
if (daemon != null)
|
||||
return daemon.shutdown();
|
||||
});
|
||||
|
||||
test('daemon.version', () async {
|
||||
StreamController<Map> commands = new StreamController();
|
||||
StreamController<Map> responses = new StreamController();
|
||||
daemon = new Daemon(
|
||||
commands.stream,
|
||||
(Map result) => responses.add(result)
|
||||
);
|
||||
commands.add({'id': 0, 'event': 'daemon.version'});
|
||||
Map response = await responses.stream.first;
|
||||
expect(response['id'], 0);
|
||||
expect(response['result'], isNotEmpty);
|
||||
expect(response['result'] is String, true);
|
||||
});
|
||||
|
||||
test('daemon.shutdown', () async {
|
||||
StreamController<Map> commands = new StreamController();
|
||||
StreamController<Map> responses = new StreamController();
|
||||
daemon = new Daemon(
|
||||
commands.stream,
|
||||
(Map result) => responses.add(result)
|
||||
);
|
||||
commands.add({'id': 0, 'event': 'daemon.shutdown'});
|
||||
return daemon.onExit.then((int code) {
|
||||
expect(code, 0);
|
||||
});
|
||||
});
|
||||
|
||||
test('daemon.stopAll', () async {
|
||||
DaemonCommand command = new DaemonCommand();
|
||||
applyMocksToCommand(command);
|
||||
|
||||
StreamController<Map> commands = new StreamController();
|
||||
StreamController<Map> responses = new StreamController();
|
||||
daemon = new Daemon(
|
||||
commands.stream,
|
||||
(Map result) => responses.add(result),
|
||||
daemonCommand: command
|
||||
);
|
||||
|
||||
MockDeviceStore mockDevices = command.devices;
|
||||
|
||||
when(mockDevices.android.isConnected()).thenReturn(true);
|
||||
when(mockDevices.android.stopApp(any)).thenReturn(true);
|
||||
|
||||
when(mockDevices.iOS.isConnected()).thenReturn(false);
|
||||
when(mockDevices.iOS.stopApp(any)).thenReturn(false);
|
||||
|
||||
when(mockDevices.iOSSimulator.isConnected()).thenReturn(false);
|
||||
when(mockDevices.iOSSimulator.stopApp(any)).thenReturn(false);
|
||||
|
||||
commands.add({'id': 0, 'event': 'app.stopAll'});
|
||||
Map response = await responses.stream.first;
|
||||
expect(response['id'], 0);
|
||||
expect(response['result'], true);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -13,7 +13,7 @@ import 'package:test/test.dart';
|
||||
main() => defineTests();
|
||||
|
||||
defineTests() {
|
||||
group('', () {
|
||||
group('init', () {
|
||||
Directory temp;
|
||||
|
||||
setUp(() {
|
||||
@@ -28,7 +28,7 @@ defineTests() {
|
||||
// covered on the linux one.
|
||||
if (!Platform.isWindows) {
|
||||
// Verify that we create a project that is well-formed.
|
||||
test('init flutter-simple', () async {
|
||||
test('flutter-simple', () async {
|
||||
InitCommand command = new InitCommand();
|
||||
CommandRunner runner = new CommandRunner('test_flutter', '')
|
||||
..addCommand(command);
|
||||
|
||||
@@ -36,7 +36,6 @@ defineTests() {
|
||||
// Instead, cause the test to run the echo command.
|
||||
when(mockDevices.iOSSimulator.xcrunPath).thenReturn(mockCommand);
|
||||
|
||||
|
||||
CommandRunner runner = new CommandRunner('test_flutter', '')
|
||||
..addCommand(command);
|
||||
runner.run(['list']).then((int code) => expect(code, equals(0)));
|
||||
|
||||
49
packages/flutter_tools/tool/daemon_client.dart
Normal file
49
packages/flutter_tools/tool/daemon_client.dart
Normal file
@@ -0,0 +1,49 @@
|
||||
// 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.
|
||||
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
Process daemon;
|
||||
|
||||
main() async {
|
||||
daemon = await Process.start('dart', ['bin/sky_tools.dart', 'daemon']);
|
||||
print('daemon process started, pid: ${daemon.pid}');
|
||||
|
||||
daemon.stdout
|
||||
.transform(UTF8.decoder)
|
||||
.transform(const LineSplitter())
|
||||
.listen((String line) => print('<== ${line}'));
|
||||
daemon.stderr.listen((data) => stderr.add(data));
|
||||
|
||||
stdout.write('> ');
|
||||
stdin.transform(UTF8.decoder).transform(const LineSplitter()).listen((String line) {
|
||||
if (line == 'version' || line == 'v') {
|
||||
_send({'event': 'daemon.version'});
|
||||
} else if (line == 'shutdown' || line == 'q') {
|
||||
_send({'event': 'daemon.shutdown'});
|
||||
} else if (line == 'start') {
|
||||
_send({'event': 'app.start'});
|
||||
} else if (line == 'stopAll') {
|
||||
_send({'event': 'app.stopAll'});
|
||||
} else {
|
||||
print('command not understood: ${line}');
|
||||
}
|
||||
stdout.write('> ');
|
||||
});
|
||||
|
||||
daemon.exitCode.then((int code) {
|
||||
print('daemon exiting (${code})');
|
||||
exit(code);
|
||||
});
|
||||
}
|
||||
|
||||
int id = 0;
|
||||
|
||||
void _send(Map map) {
|
||||
map['id'] = id++;
|
||||
String str = '[${JSON.encode(map)}]';
|
||||
daemon.stdin.writeln(str);
|
||||
print('==> ${str}');
|
||||
}
|
||||
Reference in New Issue
Block a user