diff --git a/packages/flutter_tools/README.md b/packages/flutter_tools/README.md new file mode 100644 index 00000000000..9c41e2fc709 --- /dev/null +++ b/packages/flutter_tools/README.md @@ -0,0 +1,52 @@ +# tools + +[![Build Status](https://travis-ci.org/flutter/tools.svg)](https://travis-ci.org/flutter/tools) +[![Build status](https://ci.appveyor.com/api/projects/status/fpokp26jprqddfms/branch/master?svg=true)](https://ci.appveyor.com/project/devoncarew/tools/branch/master) +[![pub package](https://img.shields.io/pub/v/sky_tools.svg)](https://pub.dartlang.org/packages/sky_tools) + +Tools for building Flutter applications. + +## Installing + +To install, run: + + pub global activate sky_tools + +or, depend on this package in your pubspec: + +```yaml +dev_dependencies: + sky_tools: any +``` + +## Running sky_tools + +Run `sky_tools` (or `pub global run sky_tools`) to see a list of available +commands: + +- `init` to create a new project + +Then, run a `sky_tools` command: + + sky_tools init --out my_sky_project + +## Running sky_tools:sky_server + +To serve the current directory using `sky_server`: + + pub run sky_tools:sky_server [-v] PORT + +## Running sky_tools:build_sky_apk + +``` +usage: pub run sky_tools:build_sky_apk + +-h, --help + --android-sdk + --skyx +``` + +## Filing Issues + +Please file reports on the +[GitHub Issue Tracker](https://github.com/flutter/tools/issues). diff --git a/packages/flutter_tools/bin/build_sky_apk.dart b/packages/flutter_tools/bin/build_sky_apk.dart new file mode 100644 index 00000000000..b0f50867035 --- /dev/null +++ b/packages/flutter_tools/bin/build_sky_apk.dart @@ -0,0 +1,126 @@ +// 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:io'; + +import 'package:args/args.dart'; + +const String kBuildToolsVersion = '22.0.1'; +const String kAndroidPlatformVersion = '22'; + +const String kKeystoreKeyName = "chromiumdebugkey"; +const String kKeystorePassword = "chromium"; + +class AssetBuilder { + final Directory outDir; + + Directory _assetDir; + + AssetBuilder(this.outDir) { + _assetDir = new Directory('${outDir.path}/assets'); + _assetDir.createSync(recursive: true); + } + + void add(File asset, String assetName) { + asset.copySync('${_assetDir.path}/$assetName'); + } + + Directory get directory => _assetDir; +} + +class ApkBuilder { + final String androidSDK; + + File _androidJar; + File _aapt; + File _zipalign; + String _jarsigner; + + ApkBuilder(this.androidSDK) { + _androidJar = new File('$androidSDK/platforms/android-$kAndroidPlatformVersion/android.jar'); + + String buildTools = '$androidSDK/build-tools/$kBuildToolsVersion'; + _aapt = new File('$buildTools/aapt'); + _zipalign = new File('$buildTools/zipalign'); + _jarsigner = 'jarsigner'; + } + + void package(File androidManifest, Directory assets, File outputApk) { + _run(_aapt.path, [ + 'package', + '-M', androidManifest.path, + '-A', assets.path, + '-I', _androidJar.path, + '-F', outputApk.path, + ]); + } + + void add(Directory base, String resource, File outputApk) { + _run(_aapt.path, [ + 'add', '-f', outputApk.absolute.path, resource, + ], workingDirectory: base.path); + } + + void sign(File keystore, String keystorePassword, String keyName, File outputApk) { + _run(_jarsigner, [ + '-keystore', keystore.path, + '-storepass', keystorePassword, + outputApk.path, + keyName, + ]); + } + + void align(File unalignedApk, File outputApk) { + _run(_zipalign.path, ['4', unalignedApk.path, outputApk.path]); + } + + void _run(String command, List args, { String workingDirectory }) { + ProcessResult result = Process.runSync( + command, args, workingDirectory: workingDirectory); + if (result.exitCode == 0) + return; + stdout.write(result.stdout); + stderr.write(result.stderr); + } +} + +main(List argv) async { + ArgParser parser = new ArgParser(); + parser.addFlag('help', abbr: 'h', negatable: false); + parser.addOption('android-sdk'); + parser.addOption('skyx'); + + ArgResults args = parser.parse(argv); + + if (args['help']) { + print('usage: pub run sky_tools:build_sky_apk '); + print(''); + print(parser.usage); + return; + } + + Directory artifacts = new Directory('artifacts'); + File keystore = new File('${artifacts.path}/chromium-debug.keystore'); + File androidManifest = new File('${artifacts.path}/AndroidManifest.xml'); + File icuData = new File('${artifacts.path}/assets/icudtl.dat'); + File appSkyx = new File(args['skyx']); + + Directory outDir = new Directory('out'); + outDir.createSync(recursive: true); + + AssetBuilder assetBuilder = new AssetBuilder(outDir); + assetBuilder.add(icuData, 'icudtl.dat'); + assetBuilder.add(appSkyx, 'app.skyx'); + + ApkBuilder builder = new ApkBuilder(args['android-sdk']); + + File unalignedApk = new File('${outDir.path}/Example.apk.unaligned'); + File finalApk = new File('${outDir.path}/Example.apk'); + + builder.package(androidManifest, assetBuilder.directory, unalignedApk); + builder.add(artifacts, 'classes.dex', unalignedApk); + builder.add(artifacts, 'lib/armeabi-v7a/libsky_shell.so', unalignedApk); + builder.sign(keystore, kKeystorePassword, kKeystoreKeyName, unalignedApk); + builder.align(unalignedApk, finalApk); +} diff --git a/packages/flutter_tools/bin/sky_server.dart b/packages/flutter_tools/bin/sky_server.dart new file mode 100644 index 00000000000..04bb6e960c1 --- /dev/null +++ b/packages/flutter_tools/bin/sky_server.dart @@ -0,0 +1,85 @@ +// 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:io'; + +import 'package:args/args.dart'; +import 'package:shelf/shelf.dart'; +import 'package:shelf/shelf_io.dart' as io; +import 'package:shelf_route/shelf_route.dart' as shelf_route; +import 'package:shelf_static/shelf_static.dart'; + +void printUsage(parser) { + print('Usage: sky_server [-v] PORT'); + print(parser.usage); +} + +void addRoute(var router, String route, String path) { + router.add( + route, + ['GET', 'HEAD'], + createStaticHandler( + path, + serveFilesOutsidePath: true, + listDirectories: true + ), exactMatch: false + ); +} + +main(List argv) async { + ArgParser parser = new ArgParser(); + parser.addFlag('help', abbr: 'h', negatable: false, + help: 'Display this help message.'); + parser.addFlag('verbose', abbr: 'v', negatable: false, + help: 'Log requests to stdout.'); + parser.addOption('route', allowMultiple: true, splitCommas: false, + help: 'Adds a virtual directory to the root.'); + + ArgResults args = parser.parse(argv); + + if (args['help'] || args.rest.length != 1) { + printUsage(parser); + return; + } + + int port; + try { + port = int.parse(args.rest[0]); + } catch(e) { + printUsage(parser); + return; + } + + var router = shelf_route.router(); + + if (args['route'] != null) { + for (String arg in args['route']) { + List parsedArgs = arg.split(','); + addRoute(router, parsedArgs[0], parsedArgs[1]); + } + } + + addRoute(router, '/', Directory.current.path); + + var handler = router.handler; + + if (args['verbose']) + handler = const Pipeline().addMiddleware(logRequests()).addHandler(handler); + + HttpServer server; + try { + server = await io.serve(handler, InternetAddress.LOOPBACK_IP_V4, port); + print('Serving ${Directory.current.absolute.path} from ' + 'http://${server.address.address}:${server.port}.'); + } catch(e) { + print(e); + exit(1); + } + + server.defaultResponseHeaders + ..removeAll('x-content-type-options') + ..removeAll('x-frame-options') + ..removeAll('x-xss-protection') + ..add('cache-control', 'no-store'); +} diff --git a/packages/flutter_tools/bin/sky_test.dart b/packages/flutter_tools/bin/sky_test.dart new file mode 100644 index 00000000000..a5e08f626a4 --- /dev/null +++ b/packages/flutter_tools/bin/sky_test.dart @@ -0,0 +1,11 @@ +// 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 'package:sky_tools/src/test/loader.dart' as loader; +import 'package:test/src/executable.dart' as executable; + +main(List args) { + loader.installHook(); + return executable.main(args); +} diff --git a/packages/flutter_tools/bin/sky_tools.dart b/packages/flutter_tools/bin/sky_tools.dart new file mode 100644 index 00000000000..99338de2532 --- /dev/null +++ b/packages/flutter_tools/bin/sky_tools.dart @@ -0,0 +1,7 @@ +// 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 'package:sky_tools/executable.dart' as executable; + +main(List args) => executable.main(args); diff --git a/packages/flutter_tools/lib/executable.dart b/packages/flutter_tools/lib/executable.dart new file mode 100644 index 00000000000..276ec02294c --- /dev/null +++ b/packages/flutter_tools/lib/executable.dart @@ -0,0 +1,77 @@ +// 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:io'; + +import 'package:args/command_runner.dart'; +import 'package:logging/logging.dart'; +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'; +import 'src/commands/list.dart'; +import 'src/commands/listen.dart'; +import 'src/commands/logs.dart'; +import 'src/commands/run_mojo.dart'; +import 'src/commands/start.dart'; +import 'src/commands/stop.dart'; +import 'src/commands/trace.dart'; +import 'src/process.dart'; + +/// Main entry point for commands. +/// +/// This function is intended to be used from the [flutter] command line tool. +Future main(List args) async { + // This level can be adjusted by users through the `--verbose` option. + Logger.root.level = Level.WARNING; + Logger.root.onRecord.listen((LogRecord record) { + if (record.level >= Level.WARNING) { + stderr.writeln(record.message); + } else { + print(record.message); + } + if (record.error != null) + stderr.writeln(record.error); + if (record.stackTrace != null) + stderr.writeln(record.stackTrace); + }); + + FlutterCommandRunner runner = new FlutterCommandRunner() + ..addCommand(new BuildCommand()) + ..addCommand(new CacheCommand()) + ..addCommand(new DaemonCommand()) + ..addCommand(new InitCommand()) + ..addCommand(new InstallCommand()) + ..addCommand(new ListCommand()) + ..addCommand(new ListenCommand()) + ..addCommand(new LogsCommand()) + ..addCommand(new RunMojoCommand()) + ..addCommand(new StartCommand()) + ..addCommand(new StopCommand()) + ..addCommand(new TraceCommand()); + + return Chain.capture(() async { + dynamic result = await runner.run(args); + if (result is int) + exit(result); + }, onError: (error, Chain chain) { + if (error is UsageException) { + stderr.writeln(error); + // Argument error exit code. + exit(64); + } else if (error is ProcessExit) { + // We've caught an exit code. + exit(error.exitCode); + } else { + stderr.writeln(error); + Logger.root.log(Level.SEVERE, '\nException:', null, chain.terse.toTrace()); + exit(1); + } + }); +} diff --git a/packages/flutter_tools/lib/src/application_package.dart b/packages/flutter_tools/lib/src/application_package.dart new file mode 100644 index 00000000000..f705622d57f --- /dev/null +++ b/packages/flutter_tools/lib/src/application_package.dart @@ -0,0 +1,120 @@ +// 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:logging/logging.dart'; +import 'package:path/path.dart' as path; + +import 'artifacts.dart'; +import 'build_configuration.dart'; + +final Logger _logging = new Logger('sky_tools.application_package'); + +abstract class ApplicationPackage { + /// Path to the actual apk or bundle. + final String localPath; + + /// Package ID from the Android Manifest or equivalent. + final String id; + + /// File name of the apk or bundle. + final String name; + + ApplicationPackage({ + String localPath, + this.id + }) : localPath = localPath, name = path.basename(localPath) { + assert(localPath != null); + assert(id != null); + } +} + +class AndroidApk extends ApplicationPackage { + static const String _defaultName = 'SkyShell.apk'; + static const String _defaultId = 'org.domokit.sky.shell'; + static const String _defaultLaunchActivity = '$_defaultId/$_defaultId.SkyActivity'; + + /// The path to the activity that should be launched. + /// Defaults to 'org.domokit.sky.shell/org.domokit.sky.shell.SkyActivity' + final String launchActivity; + + AndroidApk({ + String localPath, + String id: _defaultId, + this.launchActivity: _defaultLaunchActivity + }) : super(localPath: localPath, id: id) { + assert(launchActivity != null); + } +} + +class IOSApp extends ApplicationPackage { + static const String _defaultName = 'SkyShell.app'; + static const String _defaultId = 'com.google.SkyShell'; + + IOSApp({ + String localPath, + String id: _defaultId + }) : super(localPath: localPath, id: id); +} + +class ApplicationPackageStore { + final AndroidApk android; + final IOSApp iOS; + final IOSApp iOSSimulator; + + ApplicationPackageStore({ this.android, this.iOS, this.iOSSimulator }); + + ApplicationPackage getPackageForPlatform(TargetPlatform platform) { + switch (platform) { + case TargetPlatform.android: + return android; + case TargetPlatform.iOS: + return iOS; + case TargetPlatform.iOSSimulator: + return iOSSimulator; + case TargetPlatform.linux: + return null; + } + } + + static Future forConfigs(List configs) async { + AndroidApk android; + IOSApp iOS; + IOSApp iOSSimulator; + + for (BuildConfiguration config in configs) { + switch (config.targetPlatform) { + case TargetPlatform.android: + assert(android == null); + if (config.type != BuildType.prebuilt) { + String localPath = path.join(config.buildDir, 'apks', AndroidApk._defaultName); + android = new AndroidApk(localPath: localPath); + } else { + Artifact artifact = ArtifactStore.getArtifact( + type: ArtifactType.shell, targetPlatform: TargetPlatform.android); + android = new AndroidApk(localPath: await ArtifactStore.getPath(artifact)); + } + break; + + case TargetPlatform.iOS: + assert(iOS == null); + assert(config.type != BuildType.prebuilt); + iOS = new IOSApp(localPath: path.join(config.buildDir, IOSApp._defaultName)); + break; + + case TargetPlatform.iOSSimulator: + assert(iOSSimulator == null); + assert(config.type != BuildType.prebuilt); + iOSSimulator = new IOSApp(localPath: path.join(config.buildDir, IOSApp._defaultName)); + break; + + case TargetPlatform.linux: + break; + } + } + + return new ApplicationPackageStore(android: android, iOS: iOS, iOSSimulator: iOSSimulator); + } +} diff --git a/packages/flutter_tools/lib/src/artifacts.dart b/packages/flutter_tools/lib/src/artifacts.dart new file mode 100644 index 00000000000..bc2a8502e60 --- /dev/null +++ b/packages/flutter_tools/lib/src/artifacts.dart @@ -0,0 +1,236 @@ +// 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:io'; + +import 'package:logging/logging.dart'; +import 'package:path/path.dart' as path; + +import 'build_configuration.dart'; +import 'os_utils.dart'; + +final Logger _logging = new Logger('sky_tools.artifacts'); + +const String _kShellCategory = 'shell'; +const String _kViewerCategory = 'viewer'; + +String _getNameForHostPlatform(HostPlatform platform) { + switch (platform) { + case HostPlatform.linux: + return 'linux-x64'; + case HostPlatform.mac: + return 'darwin-x64'; + } +} + +String _getNameForTargetPlatform(TargetPlatform platform) { + switch (platform) { + case TargetPlatform.android: + return 'android-arm'; + case TargetPlatform.iOS: + return 'ios-arm'; + case TargetPlatform.iOSSimulator: + return 'ios-x64'; + case TargetPlatform.linux: + return 'linux-x64'; + } +} + +// Keep in sync with https://github.com/flutter/engine/blob/master/sky/tools/release_engine.py +// and https://github.com/flutter/buildbot/blob/master/travis/build.sh +String _getCloudStorageBaseUrl({String category, String platform, String revision}) { + if (platform == 'darwin-x64') { + // In the fullness of time, we'll have a consistent URL pattern for all of + // our artifacts, but, for the time being, Mac OS X artifacts are stored in a + // different cloud storage bucket. + return 'https://storage.googleapis.com/mojo_infra/flutter/${platform}/${revision}/'; + } + return 'https://storage.googleapis.com/mojo/sky/${category}/${platform}/${revision}/'; +} + +enum ArtifactType { + snapshot, + shell, + viewer, +} + +class Artifact { + const Artifact._({ + this.name, + this.fileName, + this.category, + this.type, + this.hostPlatform, + this.targetPlatform + }); + + final String name; + final String fileName; + final String category; // TODO(abarth): Remove categories. + final ArtifactType type; + final HostPlatform hostPlatform; + final TargetPlatform targetPlatform; + + String get platform { + if (targetPlatform != null) + return _getNameForTargetPlatform(targetPlatform); + if (hostPlatform != null) + return _getNameForHostPlatform(hostPlatform); + assert(false); + return null; + } + + String getUrl(String revision) { + return _getCloudStorageBaseUrl( + category: category, + platform: platform, + revision: revision + ) + fileName; + } + + // Whether the artifact needs to be marked as executable on disk. + bool get executable => type == ArtifactType.snapshot; +} + +class ArtifactStore { + static const List knownArtifacts = const [ + const Artifact._( + name: 'Sky Shell', + fileName: 'SkyShell.apk', + category: _kShellCategory, + type: ArtifactType.shell, + targetPlatform: TargetPlatform.android + ), + const Artifact._( + name: 'Sky Snapshot', + fileName: 'sky_snapshot', + category: _kShellCategory, + type: ArtifactType.snapshot, + hostPlatform: HostPlatform.linux + ), + const Artifact._( + name: 'Sky Snapshot', + fileName: 'sky_snapshot', + category: _kShellCategory, + type: ArtifactType.snapshot, + hostPlatform: HostPlatform.mac + ), + const Artifact._( + name: 'Sky Viewer', + fileName: 'sky_viewer.mojo', + category: _kViewerCategory, + type: ArtifactType.viewer, + targetPlatform: TargetPlatform.android + ), + const Artifact._( + name: 'Sky Viewer', + fileName: 'sky_viewer.mojo', + category: _kViewerCategory, + type: ArtifactType.viewer, + targetPlatform: TargetPlatform.linux + ), + ]; + + static Artifact getArtifact({ + ArtifactType type, + HostPlatform hostPlatform, + TargetPlatform targetPlatform + }) { + for (Artifact artifact in ArtifactStore.knownArtifacts) { + if (type != null && + type != artifact.type) + continue; + if (hostPlatform != null && + artifact.hostPlatform != null && + hostPlatform != artifact.hostPlatform) + continue; + if (targetPlatform != null && + artifact.targetPlatform != null && + targetPlatform != artifact.targetPlatform) + continue; + return artifact; + } + return null; + } + + static String packageRoot; + static String _engineRevision; + + static String get engineRevision { + if (_engineRevision == null) { + File revisionFile = new File(path.join(packageRoot, 'sky_engine', 'REVISION')); + if (revisionFile.existsSync()) + _engineRevision = revisionFile.readAsStringSync(); + } + return _engineRevision; + } + + static String getCloudStorageBaseUrl(String category, String platform) { + return _getCloudStorageBaseUrl( + category: category, + platform: platform, + revision: engineRevision + ); + } + + static Future _downloadFile(String url, File file) async { + _logging.info('Downloading $url to ${file.path}.'); + HttpClient httpClient = new HttpClient(); + HttpClientRequest request = await httpClient.getUrl(Uri.parse(url)); + HttpClientResponse response = await request.close(); + _logging.fine('Received response statusCode=${response.statusCode}'); + if (response.statusCode != 200) + throw new Exception(response.reasonPhrase); + IOSink sink = file.openWrite(); + await sink.addStream(response); + await sink.close(); + _logging.fine('Wrote file'); + } + + static Directory _getBaseCacheDir() { + Directory cacheDir = new Directory(path.join(packageRoot, 'sky_tools', 'cache')); + if (!cacheDir.existsSync()) + cacheDir.createSync(recursive: true); + return cacheDir; + } + + static Directory _getCacheDirForArtifact(Artifact artifact) { + Directory baseDir = _getBaseCacheDir(); + // For now, all downloaded artifacts are release mode host binaries so use + // a path that mirrors a local release build. + // TODO(jamesr): Add support for more configurations. + String config = 'Release'; + Directory artifactSpecificDir = new Directory(path.join( + baseDir.path, 'sky_engine', engineRevision, config, artifact.platform)); + if (!artifactSpecificDir.existsSync()) + artifactSpecificDir.createSync(recursive: true); + return artifactSpecificDir; + } + + static Future getPath(Artifact artifact) async { + Directory cacheDir = _getCacheDirForArtifact(artifact); + File cachedFile = new File(path.join(cacheDir.path, artifact.fileName)); + if (!cachedFile.existsSync()) { + print('Downloading ${artifact.name} from the cloud, one moment please...'); + await _downloadFile(artifact.getUrl(engineRevision), cachedFile); + if (artifact.executable) { + ProcessResult result = osUtils.makeExecutable(cachedFile); + if (result.exitCode != 0) + throw new Exception(result.stderr); + } + } + return cachedFile.path; + } + + static void clear() { + Directory cacheDir = _getBaseCacheDir(); + _logging.fine('Clearing cache directory ${cacheDir.path}'); + cacheDir.deleteSync(recursive: true); + } + + static Future populate() { + return Future.wait(knownArtifacts.map((artifact) => getPath(artifact))); + } +} diff --git a/packages/flutter_tools/lib/src/build_configuration.dart b/packages/flutter_tools/lib/src/build_configuration.dart new file mode 100644 index 00000000000..5fc03564951 --- /dev/null +++ b/packages/flutter_tools/lib/src/build_configuration.dart @@ -0,0 +1,57 @@ +// 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:io'; + +import 'package:logging/logging.dart'; +import 'package:path/path.dart' as path; + +final Logger _logging = new Logger('sky_tools.build_configuration'); + +enum BuildType { + prebuilt, + release, + debug, +} + +enum HostPlatform { + mac, + linux, +} + +enum TargetPlatform { + android, + iOS, + iOSSimulator, + linux, +} + +HostPlatform getCurrentHostPlatform() { + if (Platform.isMacOS) + return HostPlatform.mac; + if (Platform.isLinux) + return HostPlatform.linux; + _logging.warning('Unsupported host platform, defaulting to Linux'); + return HostPlatform.linux; +} + +class BuildConfiguration { + BuildConfiguration.prebuilt({ this.hostPlatform, this.targetPlatform }) + : type = BuildType.prebuilt, buildDir = null; + + BuildConfiguration.local({ + this.type, + this.hostPlatform, + this.targetPlatform, + String enginePath, + String buildPath + }) : buildDir = path.normalize(path.join(enginePath, buildPath)) { + assert(type == BuildType.debug || type == BuildType.release); + } + + final BuildType type; + final HostPlatform hostPlatform; + final TargetPlatform targetPlatform; + final String buildDir; +} diff --git a/packages/flutter_tools/lib/src/commands/build.dart b/packages/flutter_tools/lib/src/commands/build.dart new file mode 100644 index 00000000000..1d47dfc7d4a --- /dev/null +++ b/packages/flutter_tools/lib/src/commands/build.dart @@ -0,0 +1,198 @@ +// 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:io'; +import 'dart:typed_data'; + +import 'package:archive/archive.dart'; +import 'package:flx/bundle.dart'; +import 'package:flx/signing.dart'; +import 'package:yaml/yaml.dart'; + +import '../toolchain.dart'; +import 'flutter_command.dart'; + +const String _kSnapshotKey = 'snapshot_blob.bin'; +const List _kDensities = const ['drawable-xxhdpi']; +const List _kThemes = const ['white', 'black']; +const List _kSizes = const [24]; + +class _Asset { + final String base; + final String key; + + _Asset({ this.base, this.key }); +} + +Iterable<_Asset> _parseAssets(Map manifestDescriptor, String manifestPath) sync* { + if (manifestDescriptor == null || !manifestDescriptor.containsKey('assets')) + return; + String basePath = new File(manifestPath).parent.path; + for (String asset in manifestDescriptor['assets']) + yield new _Asset(base: basePath, key: asset); +} + +class _MaterialAsset { + final String name; + final String density; + final String theme; + final int size; + + _MaterialAsset(Map descriptor) + : name = descriptor['name'], + density = descriptor['density'], + theme = descriptor['theme'], + size = descriptor['size']; + + String get key { + List parts = name.split('/'); + String category = parts[0]; + String subtype = parts[1]; + return '$category/$density/ic_${subtype}_${theme}_${size}dp.png'; + } +} + +List _generateValues(Map assetDescriptor, String key, List defaults) { + if (assetDescriptor.containsKey(key)) + return [assetDescriptor[key]]; + return defaults; +} + +Iterable<_MaterialAsset> _generateMaterialAssets(Map assetDescriptor) sync* { + Map currentAssetDescriptor = new Map.from(assetDescriptor); + for (String density in _generateValues(assetDescriptor, 'density', _kDensities)) { + currentAssetDescriptor['density'] = density; + for (String theme in _generateValues(assetDescriptor, 'theme', _kThemes)) { + currentAssetDescriptor['theme'] = theme; + for (int size in _generateValues(assetDescriptor, 'size', _kSizes)) { + currentAssetDescriptor['size'] = size; + yield new _MaterialAsset(currentAssetDescriptor); + } + } + } +} + +Iterable<_MaterialAsset> _parseMaterialAssets(Map manifestDescriptor) sync* { + if (manifestDescriptor == null || !manifestDescriptor.containsKey('material-design-icons')) + return; + for (Map assetDescriptor in manifestDescriptor['material-design-icons']) { + for (_MaterialAsset asset in _generateMaterialAssets(assetDescriptor)) { + yield asset; + } + } +} + +dynamic _loadManifest(String manifestPath) { + if (manifestPath == null || !FileSystemEntity.isFileSync(manifestPath)) + return null; + String manifestDescriptor = new File(manifestPath).readAsStringSync(); + return loadYaml(manifestDescriptor); +} + +ArchiveFile _createFile(String key, String assetBase) { + File file = new File('${assetBase}/${key}'); + if (!file.existsSync()) + return null; + List content = file.readAsBytesSync(); + return new ArchiveFile.noCompress(key, content.length, content); +} + +ArchiveFile _createSnapshotFile(String snapshotPath) { + File file = new File(snapshotPath); + List content = file.readAsBytesSync(); + return new ArchiveFile(_kSnapshotKey, content.length, content); +} + +const String _kDefaultAssetBase = 'packages/material_design_icons/icons'; +const String _kDefaultMainPath = 'lib/main.dart'; +const String _kDefaultManifestPath = 'flutter.yaml'; +const String _kDefaultOutputPath = 'app.flx'; +const String _kDefaultSnapshotPath = 'snapshot_blob.bin'; +const String _kDefaultPrivateKeyPath = 'privatekey.der'; + +class BuildCommand extends FlutterCommand { + final String name = 'build'; + final String description = 'Create a Flutter app.'; + + BuildCommand() { + argParser.addFlag('precompiled', negatable: false); + argParser.addOption('asset-base', defaultsTo: _kDefaultAssetBase); + argParser.addOption('compiler'); + argParser.addOption('main', defaultsTo: _kDefaultMainPath); + argParser.addOption('manifest', defaultsTo: _kDefaultManifestPath); + argParser.addOption('private-key', defaultsTo: _kDefaultPrivateKeyPath); + argParser.addOption('output-file', abbr: 'o', defaultsTo: _kDefaultOutputPath); + argParser.addOption('snapshot', defaultsTo: _kDefaultSnapshotPath); + } + + @override + Future runInProject() async { + String compilerPath = argResults['compiler']; + + if (compilerPath == null) + await downloadToolchain(); + else + toolchain = new Toolchain(compiler: new Compiler(compilerPath)); + + return await build( + assetBase: argResults['asset-base'], + mainPath: argResults['main'], + manifestPath: argResults['manifest'], + outputPath: argResults['output-file'], + snapshotPath: argResults['snapshot'], + privateKeyPath: argResults['private-key'], + precompiledSnapshot: argResults['precompiled'] + ); + } + + Future build({ + String assetBase: _kDefaultAssetBase, + String mainPath: _kDefaultMainPath, + String manifestPath: _kDefaultManifestPath, + String outputPath: _kDefaultOutputPath, + String snapshotPath: _kDefaultSnapshotPath, + String privateKeyPath: _kDefaultPrivateKeyPath, + bool precompiledSnapshot: false + }) async { + Map manifestDescriptor = _loadManifest(manifestPath); + + Iterable<_Asset> assets = _parseAssets(manifestDescriptor, manifestPath); + Iterable<_MaterialAsset> materialAssets = _parseMaterialAssets(manifestDescriptor); + + Archive archive = new Archive(); + + if (!precompiledSnapshot) { + // In a precompiled snapshot, the instruction buffer contains script + // content equivalents + int result = await toolchain.compiler.compile(mainPath: mainPath, snapshotPath: snapshotPath); + if (result != 0) + return result; + + archive.addFile(_createSnapshotFile(snapshotPath)); + } + + for (_Asset asset in assets) + archive.addFile(_createFile(asset.key, asset.base)); + + for (_MaterialAsset asset in materialAssets) { + ArchiveFile file = _createFile(asset.key, assetBase); + if (file != null) + archive.addFile(file); + } + + await CipherParameters.get().seedRandom(); + + AsymmetricKeyPair keyPair = keyPairFromPrivateKeyFileSync(privateKeyPath); + Uint8List zipBytes = new Uint8List.fromList(new ZipEncoder().encode(archive)); + Bundle bundle = new Bundle.fromContent( + path: outputPath, + manifest: manifestDescriptor, + contentBytes: zipBytes, + keyPair: keyPair + ); + bundle.writeSync(); + return 0; + } +} diff --git a/packages/flutter_tools/lib/src/commands/cache.dart b/packages/flutter_tools/lib/src/commands/cache.dart new file mode 100644 index 00000000000..ac8d1858004 --- /dev/null +++ b/packages/flutter_tools/lib/src/commands/cache.dart @@ -0,0 +1,43 @@ +// 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:args/command_runner.dart'; +import 'package:logging/logging.dart'; + +import '../artifacts.dart'; + +final Logger _logging = new Logger('sky_tools.cache'); + +class CacheCommand extends Command { + final String name = 'cache'; + final String description = 'Manages sky_tools\' cache of binary artifacts.'; + CacheCommand() { + addSubcommand(new _ClearCommand()); + addSubcommand(new _PopulateCommand()); + } +} + +class _ClearCommand extends Command { + final String name = 'clear'; + final String description = 'Clears all artifacts from the cache.'; + + @override + Future run() async { + await ArtifactStore.clear(); + return 0; + } +} + +class _PopulateCommand extends Command { + final String name = 'populate'; + final String description = 'Populates the cache with all known artifacts.'; + + @override + Future run() async { + await ArtifactStore.populate(); + return 0; + } +} diff --git a/packages/flutter_tools/lib/src/commands/daemon.dart b/packages/flutter_tools/lib/src/commands/daemon.dart new file mode 100644 index 00000000000..78872bd3229 --- /dev/null +++ b/packages/flutter_tools/lib/src/commands/daemon.dart @@ -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 runInProject() async { + print('Starting device daemon...'); + + Stream 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 CommandHandler(dynamic args); + +class Daemon { + final DispatchComand sendCommand; + final DaemonCommand daemonCommand; + + final Completer _onExitCompleter = new Completer(); + final Map _domains = {}; + + Daemon(Stream 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 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 _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 version(dynamic args) { + return new Future.value(protocolVersion); + } + + @command + Future 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 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 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}'; +} diff --git a/packages/flutter_tools/lib/src/commands/flutter_command.dart b/packages/flutter_tools/lib/src/commands/flutter_command.dart new file mode 100644 index 00000000000..917ece301f2 --- /dev/null +++ b/packages/flutter_tools/lib/src/commands/flutter_command.dart @@ -0,0 +1,64 @@ +// 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:io'; + +import 'package:args/command_runner.dart'; + +import '../application_package.dart'; +import '../device.dart'; +import '../toolchain.dart'; +import 'flutter_command_runner.dart'; + +abstract class FlutterCommand extends Command { + FlutterCommandRunner get runner => super.runner; + + /// Whether this command needs to be run from the root of a project. + bool get requiresProjectRoot => true; + + Future downloadApplicationPackages() async { + if (applicationPackages == null) + applicationPackages = await ApplicationPackageStore.forConfigs(runner.buildConfigurations); + } + + Future downloadToolchain() async { + if (toolchain == null) + toolchain = await Toolchain.forConfigs(runner.buildConfigurations); + } + + void connectToDevices() { + if (devices == null) + devices = new DeviceStore.forConfigs(runner.buildConfigurations); + } + + Future downloadApplicationPackagesAndConnectToDevices() async { + await downloadApplicationPackages(); + connectToDevices(); + } + + void inheritFromParent(FlutterCommand other) { + applicationPackages = other.applicationPackages; + toolchain = other.toolchain; + devices = other.devices; + } + + Future run() async { + if (requiresProjectRoot) { + if (!FileSystemEntity.isFileSync('pubspec.yaml')) { + stderr.writeln('No pubspec.yaml file found. ' + 'This command should be run from the root of a project.'); + return 1; + } + } + + return runInProject(); + } + + Future runInProject(); + + ApplicationPackageStore applicationPackages; + Toolchain toolchain; + DeviceStore devices; +} diff --git a/packages/flutter_tools/lib/src/commands/flutter_command_runner.dart b/packages/flutter_tools/lib/src/commands/flutter_command_runner.dart new file mode 100644 index 00000000000..8f0b3891c97 --- /dev/null +++ b/packages/flutter_tools/lib/src/commands/flutter_command_runner.dart @@ -0,0 +1,213 @@ +// 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:io'; + +import 'package:args/args.dart'; +import 'package:args/command_runner.dart'; +import 'package:logging/logging.dart'; +import 'package:path/path.dart' as path; + +import '../artifacts.dart'; +import '../build_configuration.dart'; +import '../process.dart'; + +final Logger _logging = new Logger('sky_tools.flutter_command_runner'); + +class FlutterCommandRunner extends CommandRunner { + FlutterCommandRunner() + : super('flutter', 'Manage your Flutter app development.') { + argParser.addFlag('verbose', + abbr: 'v', + negatable: false, + help: 'Noisy logging, including all shell commands executed.'); + argParser.addFlag('very-verbose', + negatable: false, + help: 'Very noisy logging, including the output of all ' + 'shell commands executed.'); + argParser.addOption('package-root', + help: 'Path to your packages directory.', defaultsTo: 'packages'); + + argParser.addSeparator('Local build selection options:'); + argParser.addFlag('debug', + negatable: false, + help: + 'Set this if you are building Flutter locally and want to use the debug build products. ' + 'When set, attempts to automaticaly determine engine-src-path if engine-src-path is ' + 'not set. Not normally required.'); + argParser.addFlag('release', + negatable: false, + help: + 'Set this if you are building Flutter locally and want to use the release build products. ' + 'When set, attempts to automaticaly determine engine-src-path if engine-src-path is ' + 'not set. Note that release is not compatible with the listen command ' + 'on iOS devices and simulators. Not normally required.'); + argParser.addFlag('local-build', + negatable: false, + help: + 'Automatically detect your engine src directory from an overridden Flutter package.' + 'Useful if you are building Flutter locally and are using a dependency_override for' + 'the Flutter package that points to your engine src directory.'); + argParser.addOption('engine-src-path', hide: true, + help: + 'Path to your engine src directory, if you are building Flutter locally. ' + 'Ignored if neither debug nor release is set. Not normally required.'); + argParser.addOption('android-debug-build-path', hide: true, + help: + 'Path to your Android Debug out directory, if you are building Flutter locally. ' + 'This path is relative to engine-src-path. Not normally required.', + defaultsTo: 'out/android_Debug/'); + argParser.addOption('android-release-build-path', hide: true, + help: + 'Path to your Android Release out directory, if you are building Flutter locally. ' + 'This path is relative to engine-src-path. Not normally required.', + defaultsTo: 'out/android_Release/'); + argParser.addOption('ios-debug-build-path', hide: true, + help: + 'Path to your iOS Debug out directory, if you are building Flutter locally. ' + 'This path is relative to engine-src-path. Not normally required.', + defaultsTo: 'out/ios_Debug/'); + argParser.addOption('ios-release-build-path', hide: true, + help: + 'Path to your iOS Release out directory, if you are building Flutter locally. ' + 'This path is relative to engine-src-path. Not normally required.', + defaultsTo: 'out/ios_Release/'); + argParser.addOption('ios-sim-debug-build-path', hide: true, + help: + 'Path to your iOS Simulator Debug out directory, if you are building Sky locally. ' + 'This path is relative to engine-src-path. Not normally required.', + defaultsTo: 'out/ios_sim_Debug/'); + argParser.addOption('ios-sim-release-build-path', hide: true, + help: + 'Path to your iOS Simulator Release out directory, if you are building Sky locally. ' + 'This path is relative to engine-src-path. Not normally required.', + defaultsTo: 'out/ios_sim_Release/'); + } + + List get buildConfigurations { + if (_buildConfigurations == null) + _buildConfigurations = _createBuildConfigurations(_globalResults); + return _buildConfigurations; + } + List _buildConfigurations; + + ArgResults _globalResults; + + Future runCommand(ArgResults globalResults) { + if (globalResults['verbose']) + Logger.root.level = Level.INFO; + + if (globalResults['very-verbose']) + Logger.root.level = Level.FINE; + + _globalResults = globalResults; + ArtifactStore.packageRoot = globalResults['package-root']; + + return super.runCommand(globalResults); + } + + List _createBuildConfigurations(ArgResults globalResults) { + if (!FileSystemEntity.isDirectorySync(ArtifactStore.packageRoot)) { + String message = '${ArtifactStore.packageRoot} is not a valid directory.'; + if (ArtifactStore.packageRoot == 'packages') { + if (FileSystemEntity.isFileSync('pubspec.yaml')) + message += '\nDid you run `pub get` in this directory?'; + else + message += '\nDid you run this command from the same directory as your pubspec.yaml file?'; + } + stderr.writeln(message); + throw new ProcessExit(2); + } + + String enginePath = globalResults['engine-src-path']; + bool isDebug = globalResults['debug']; + bool isRelease = globalResults['release']; + HostPlatform hostPlatform = getCurrentHostPlatform(); + + if (enginePath == null && globalResults['local-build']) { + Directory flutterDir = new Directory(path.join(globalResults['package-root'], 'flutter')); + String realFlutterPath = flutterDir.resolveSymbolicLinksSync(); + + enginePath = path.dirname(path.dirname(path.dirname(path.dirname(realFlutterPath)))); + bool dirExists = FileSystemEntity.isDirectorySync(path.join(enginePath, 'out')); + if (enginePath == '/' || enginePath.isEmpty || !dirExists) { + stderr.writeln('Unable to detect local build in $enginePath.\n' + 'Do you have a dependency override for the flutter package?'); + throw new ProcessExit(2); + } + } + + List configs = []; + + if (enginePath == null) { + configs.add(new BuildConfiguration.prebuilt( + hostPlatform: hostPlatform, targetPlatform: TargetPlatform.android)); + } else { + if (!FileSystemEntity.isDirectorySync(enginePath)) + _logging.warning('$enginePath is not a valid directory'); + + if (!isDebug && !isRelease) + isDebug = true; + + if (isDebug) { + configs.add(new BuildConfiguration.local( + type: BuildType.debug, + hostPlatform: hostPlatform, + targetPlatform: TargetPlatform.android, + enginePath: enginePath, + buildPath: globalResults['android-debug-build-path'] + )); + + if (Platform.isMacOS) { + configs.add(new BuildConfiguration.local( + type: BuildType.debug, + hostPlatform: hostPlatform, + targetPlatform: TargetPlatform.iOS, + enginePath: enginePath, + buildPath: globalResults['ios-debug-build-path'] + )); + + configs.add(new BuildConfiguration.local( + type: BuildType.debug, + hostPlatform: hostPlatform, + targetPlatform: TargetPlatform.iOSSimulator, + enginePath: enginePath, + buildPath: globalResults['ios-sim-debug-build-path'] + )); + } + } + + if (isRelease) { + configs.add(new BuildConfiguration.local( + type: BuildType.release, + hostPlatform: hostPlatform, + targetPlatform: TargetPlatform.android, + enginePath: enginePath, + buildPath: globalResults['android-release-build-path'] + )); + + if (Platform.isMacOS) { + configs.add(new BuildConfiguration.local( + type: BuildType.release, + hostPlatform: hostPlatform, + targetPlatform: TargetPlatform.iOS, + enginePath: enginePath, + buildPath: globalResults['ios-release-build-path'] + )); + + configs.add(new BuildConfiguration.local( + type: BuildType.release, + hostPlatform: hostPlatform, + targetPlatform: TargetPlatform.iOSSimulator, + enginePath: enginePath, + buildPath: globalResults['ios-sim-release-build-path'] + )); + } + } + } + + return configs; + } +} diff --git a/packages/flutter_tools/lib/src/commands/init.dart b/packages/flutter_tools/lib/src/commands/init.dart new file mode 100644 index 00000000000..756dc9562bb --- /dev/null +++ b/packages/flutter_tools/lib/src/commands/init.dart @@ -0,0 +1,170 @@ +// 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:io'; + +import 'package:args/command_runner.dart'; +import 'package:mustache4dart/mustache4dart.dart' as mustache; +import 'package:path/path.dart' as p; + +import '../process.dart'; + +class InitCommand extends Command { + final String name = 'init'; + final String description = 'Create a new Flutter project.'; + + InitCommand() { + argParser.addOption('out', abbr: 'o', help: 'The output directory.'); + argParser.addFlag('pub', + defaultsTo: true, + help: 'Whether to run pub after the project has been created.'); + } + + @override + Future run() async { + if (!argResults.wasParsed('out')) { + print('No option specified for the output directory.'); + print(argParser.usage); + return 2; + } + + // TODO: Confirm overwrite of an existing directory with the user. + Directory out = new Directory(argResults['out']); + + new FlutterSimpleTemplate().generateInto(out); + + print(''); + + String message = '''All done! To run your application: + + \$ cd ${out.path} + \$ flutter start --checked +'''; + + if (argResults['pub']) { + print("Running pub get..."); + Process process = await Process.start( + sdkBinaryName('pub'), ['get'], workingDirectory: out.path); + stdout.addStream(process.stdout); + stderr.addStream(process.stderr); + int code = await process.exitCode; + if (code != 0) return code; + } + + print(message); + return 0; + } +} + +abstract class Template { + final String name; + final String description; + + Map files = {}; + + Template(this.name, this.description); + + void generateInto(Directory dir) { + String dirPath = p.normalize(dir.absolute.path); + String projectName = _normalizeProjectName(p.basename(dirPath)); + print('Creating ${p.basename(projectName)}...'); + dir.createSync(recursive: true); + + files.forEach((String path, String contents) { + Map m = {'projectName': projectName, 'description': description}; + contents = mustache.render(contents, m); + path = path.replaceAll('/', Platform.pathSeparator); + File file = new File(p.join(dir.path, path)); + file.parent.createSync(); + file.writeAsStringSync(contents); + print(file.path); + }); + } + + String toString() => name; +} + +class FlutterSimpleTemplate extends Template { + FlutterSimpleTemplate() : super('flutter-simple', 'A minimal Flutter project.') { + files['.gitignore'] = _gitignore; + files['pubspec.yaml'] = _pubspec; + files['README.md'] = _readme; + files['lib/main.dart'] = _libMain; + } +} + +String _normalizeProjectName(String name) { + name = name.replaceAll('-', '_').replaceAll(' ', '_'); + // Strip any extension (like .dart). + if (name.contains('.')) { + name = name.substring(0, name.indexOf('.')); + } + return name; +} + +const String _gitignore = r''' +.DS_Store +.idea +.packages +.pub/ +build/ +packages +pubspec.lock +'''; + +const String _readme = r''' +# {{projectName}} + +{{description}} + +## Getting Started + +For help getting started with Flutter, view our online +[documentation](http://flutter.io/). +'''; + +const String _pubspec = r''' +name: {{projectName}} +description: {{description}} +dependencies: + flutter: ">=0.0.2 <0.1.0" +dev_dependencies: + sky_tools: any +'''; + +const String _libMain = r''' +import 'package:flutter/material.dart'; + +void main() { + runApp( + new MaterialApp( + title: "Flutter Demo", + routes: { + '/': (RouteArguments args) => new FlutterDemo() + } + ) + ); +} + +class FlutterDemo extends StatelessComponent { + Widget build(BuildContext context) { + return new Scaffold( + toolBar: new ToolBar( + center: new Text("Flutter Demo") + ), + body: new Material( + child: new Center( + child: new Text("Hello world!") + ) + ), + floatingActionButton: new FloatingActionButton( + child: new Icon( + icon: 'content/add' + ) + ) + ); + } +} +'''; diff --git a/packages/flutter_tools/lib/src/commands/install.dart b/packages/flutter_tools/lib/src/commands/install.dart new file mode 100644 index 00000000000..235243e1e49 --- /dev/null +++ b/packages/flutter_tools/lib/src/commands/install.dart @@ -0,0 +1,42 @@ +// 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 '../application_package.dart'; +import '../device.dart'; +import 'flutter_command.dart'; + +class InstallCommand extends FlutterCommand { + final String name = 'install'; + final String description = 'Install Flutter apps on attached devices.'; + + InstallCommand() { + argParser.addFlag('boot', + help: 'Boot the iOS Simulator if it isn\'t already running.'); + } + + @override + Future runInProject() async { + await downloadApplicationPackagesAndConnectToDevices(); + return install(boot: argResults['boot']) ? 0 : 2; + } + + bool install({ bool boot: false }) { + if (boot) + devices.iOSSimulator?.boot(); + + bool installedSomewhere = false; + + for (Device device in devices.all) { + ApplicationPackage package = applicationPackages.getPackageForPlatform(device.platform); + if (package == null || !device.isConnected() || device.isAppInstalled(package)) + continue; + if (device.installApp(package)) + installedSomewhere = true; + } + + return installedSomewhere; + } +} diff --git a/packages/flutter_tools/lib/src/commands/list.dart b/packages/flutter_tools/lib/src/commands/list.dart new file mode 100644 index 00000000000..498f0a5e47a --- /dev/null +++ b/packages/flutter_tools/lib/src/commands/list.dart @@ -0,0 +1,69 @@ +// 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:logging/logging.dart'; + +import '../device.dart'; +import 'flutter_command.dart'; + +final Logger _logging = new Logger('sky_tools.list'); + +class ListCommand extends FlutterCommand { + final String name = 'list'; + final String description = 'List all connected devices.'; + + ListCommand() { + argParser.addFlag('details', + abbr: 'd', + negatable: false, + help: 'Log additional details about attached devices.'); + } + + @override + Future runInProject() async { + connectToDevices(); + + bool details = argResults['details']; + + if (details) + print('Android Devices:'); + + for (AndroidDevice device in AndroidDevice.getAttachedDevices(devices.android)) { + if (details) { + print('${device.id}\t' + '${device.modelID}\t' + '${device.productID}\t' + '${device.deviceCodeName}'); + } else { + print(device.id); + } + } + + if (details) + print('iOS Devices:'); + + for (IOSDevice device in IOSDevice.getAttachedDevices(devices.iOS)) { + if (details) { + print('${device.id}\t${device.name}'); + } else { + print(device.id); + } + } + + if (details) { + print('iOS Simulators:'); + } + for (IOSSimulator device in IOSSimulator.getAttachedDevices(devices.iOSSimulator)) { + if (details) { + print('${device.id}\t${device.name}'); + } else { + print(device.id); + } + } + + return 0; + } +} diff --git a/packages/flutter_tools/lib/src/commands/listen.dart b/packages/flutter_tools/lib/src/commands/listen.dart new file mode 100644 index 00000000000..93bd19e6bf0 --- /dev/null +++ b/packages/flutter_tools/lib/src/commands/listen.dart @@ -0,0 +1,130 @@ +// 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:io'; + +import 'package:logging/logging.dart'; + +import '../application_package.dart'; +import '../device.dart'; +import '../process.dart'; +import 'build.dart'; +import 'flutter_command.dart'; + +final Logger _logging = new Logger('sky_tools.listen'); + +class ListenCommand extends FlutterCommand { + final String name = 'listen'; + final String description = 'Listen for changes to files and reload the running app on all connected devices.'; + List watchCommand; + + /// Only run once. Used for testing. + bool singleRun; + + ListenCommand({ this.singleRun: false }) { + argParser.addFlag('checked', + negatable: true, + defaultsTo: true, + help: 'Toggle Dart\'s checked mode.'); + argParser.addOption('target', + defaultsTo: '.', + abbr: 't', + help: 'Target app path or filename to start.'); + } + + static const String _localFlutterBundle = 'app.flx'; + static const String _remoteFlutterBundle = 'Documents/app.flx'; + + @override + Future runInProject() async { + await downloadApplicationPackagesAndConnectToDevices(); + await downloadToolchain(); + + if (argResults.rest.length > 0) { + watchCommand = _initWatchCommand(argResults.rest); + } else { + watchCommand = _initWatchCommand(['.']); + } + + while (true) { + _logging.info('Updating running Flutter apps...'); + + BuildCommand builder = new BuildCommand(); + builder.inheritFromParent(this); + builder.build(outputPath: _localFlutterBundle); + + for (Device device in devices.all) { + ApplicationPackage package = applicationPackages.getPackageForPlatform(device.platform); + if (package == null || !device.isConnected()) + continue; + if (device is AndroidDevice) { + await devices.android.startServer( + argResults['target'], true, argResults['checked'], package); + } else if (device is IOSDevice) { + device.pushFile(package, _localFlutterBundle, _remoteFlutterBundle); + } else if (device is IOSSimulator) { + // TODO(abarth): Move pushFile up to Device once Android supports + // pushing new bundles. + device.pushFile(package, _localFlutterBundle, _remoteFlutterBundle); + } else { + assert(false); + } + } + + if (singleRun || !watchDirectory()) + break; + } + + return 0; + } + + List _initWatchCommand(List directories) { + if (Platform.isMacOS) { + try { + runCheckedSync(['which', 'fswatch']); + } catch (e) { + _logging.severe('"listen" command is only useful if you have installed ' + 'fswatch on Mac. Run "brew install fswatch" to install it with ' + 'homebrew.'); + return null; + } + return ['fswatch', '-r', '-v', '-1']..addAll(directories); + } else if (Platform.isLinux) { + try { + runCheckedSync(['which', 'inotifywait']); + } catch (e) { + _logging.severe('"listen" command is only useful if you have installed ' + 'inotifywait on Linux. Run "apt-get install inotify-tools" or ' + 'equivalent to install it.'); + return null; + } + return [ + 'inotifywait', + '-r', + '-e', + // Only listen for events that matter, to avoid triggering constantly + // from the editor watching files + 'modify,close_write,move,create,delete', + ]..addAll(directories); + } else { + _logging.severe('"listen" command is only available on Mac and Linux.'); + } + return null; + } + + bool watchDirectory() { + if (watchCommand == null) + return false; + + try { + runCheckedSync(watchCommand); + } catch (e) { + _logging.warning('Watching directories failed.', e); + return false; + } + + return true; + } +} diff --git a/packages/flutter_tools/lib/src/commands/logs.dart b/packages/flutter_tools/lib/src/commands/logs.dart new file mode 100644 index 00000000000..992187f128b --- /dev/null +++ b/packages/flutter_tools/lib/src/commands/logs.dart @@ -0,0 +1,38 @@ +// 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:logging/logging.dart'; + +import '../device.dart'; +import 'flutter_command.dart'; + +final Logger _logging = new Logger('sky_tools.logs'); + +class LogsCommand extends FlutterCommand { + final String name = 'logs'; + final String description = 'Show logs for running Sky apps.'; + + LogsCommand() { + argParser.addFlag('clear', + negatable: false, + help: 'Clear log history before reading from logs (Android only).'); + } + + @override + Future runInProject() async { + connectToDevices(); + + bool clear = argResults['clear']; + + Iterable> results = devices.all.map( + (Device device) => device.logs(clear: clear)); + + for (Future result in results) + await result; + + return 0; + } +} diff --git a/packages/flutter_tools/lib/src/commands/run_mojo.dart b/packages/flutter_tools/lib/src/commands/run_mojo.dart new file mode 100644 index 00000000000..3cbd597dfdf --- /dev/null +++ b/packages/flutter_tools/lib/src/commands/run_mojo.dart @@ -0,0 +1,108 @@ +// 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:io'; + +import 'package:args/command_runner.dart'; +import 'package:logging/logging.dart'; +import 'package:path/path.dart' as path; + +import '../artifacts.dart'; +import '../build_configuration.dart'; +import '../process.dart'; + +final Logger _logging = new Logger('sky_tools.run_mojo'); + +enum _MojoConfig { Debug, Release } + +class RunMojoCommand extends Command { + final String name = 'run_mojo'; + final String description = 'Run a Flutter app in mojo.'; + + RunMojoCommand() { + argParser.addFlag('android', negatable: false, help: 'Run on an Android device'); + argParser.addFlag('checked', negatable: false, help: 'Run Flutter in checked mode'); + argParser.addFlag('mojo-debug', negatable: false, help: 'Use Debug build of mojo'); + argParser.addFlag('mojo-release', negatable: false, help: 'Use Release build of mojo (default)'); + + argParser.addOption('app', defaultsTo: 'app.flx'); + argParser.addOption('mojo-path', help: 'Path to directory containing mojo_shell and services for Linux and to mojo devtools from Android.'); + } + + // TODO(abarth): Why not use path.absolute? + String _makePathAbsolute(String relativePath) { + File file = new File(relativePath); + if (!file.existsSync()) { + throw new Exception("Path \"${relativePath}\" does not exist"); + } + return file.absolute.path; + } + + Future _runAndroid(String devtoolsPath, _MojoConfig mojoConfig, String appPath, List additionalArgs) { + String skyViewerUrl = ArtifactStore.getCloudStorageBaseUrl('viewer', 'android-arm'); + String command = _makePathAbsolute(devtoolsPath); + String appName = path.basename(appPath); + String appDir = path.dirname(appPath); + String buildFlag = mojoConfig == _MojoConfig.Debug ? '--debug' : '--release'; + List cmd = [ + command, + '--android', + buildFlag, + 'http://app/$appName', + '--map-origin=http://app/=$appDir', + '--map-origin=http://sky_viewer/=$skyViewerUrl', + '--url-mappings=mojo:sky_viewer=http://sky_viewer/sky_viewer.mojo', + ]; + if (_logging.level <= Level.INFO) { + cmd.add('--verbose'); + if (_logging.level <= Level.FINE) { + cmd.add('--verbose'); + } + } + cmd.addAll(additionalArgs); + return runCommandAndStreamOutput(cmd); + } + + Future _runLinux(String mojoPath, _MojoConfig mojoConfig, String appPath, List additionalArgs) async { + Artifact artifact = ArtifactStore.getArtifact(type: ArtifactType.viewer, targetPlatform: TargetPlatform.linux); + String viewerPath = _makePathAbsolute(await ArtifactStore.getPath(artifact)); + String mojoBuildType = mojoConfig == _MojoConfig.Debug ? 'Debug' : 'Release'; + String mojoShellPath = _makePathAbsolute(path.join(mojoPath, 'out', mojoBuildType, 'mojo_shell')); + List cmd = [ + mojoShellPath, + 'file://${appPath}', + '--url-mappings=mojo:sky_viewer=file://${viewerPath}' + ]; + cmd.addAll(additionalArgs); + return runCommandAndStreamOutput(cmd); + } + + @override + Future run() async { + if (argResults['mojo-path'] == null) { + _logging.severe('Must specify --mojo-path.'); + return 1; + } + + if (argResults['mojo-debug'] && argResults['mojo-release']) { + _logging.severe('Cannot specify both --mojo-debug and --mojo-release'); + return 1; + } + List args = []; + if (argResults['checked']) { + args.add('--args-for=mojo:sky_viewer --enable-checked-mode'); + } + String mojoPath = argResults['mojo-path']; + _MojoConfig mojoConfig = argResults['mojo-debug'] ? _MojoConfig.Debug : _MojoConfig.Release; + String appPath = _makePathAbsolute(argResults['app']); + + args.addAll(argResults.rest); + if (argResults['android']) { + return _runAndroid(mojoPath, mojoConfig, appPath, args); + } else { + return _runLinux(mojoPath, mojoConfig, appPath, args); + } + } +} diff --git a/packages/flutter_tools/lib/src/commands/start.dart b/packages/flutter_tools/lib/src/commands/start.dart new file mode 100644 index 00000000000..d8515933730 --- /dev/null +++ b/packages/flutter_tools/lib/src/commands/start.dart @@ -0,0 +1,112 @@ +// 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:io'; + +import 'package:logging/logging.dart'; +import 'package:path/path.dart' as path; + +import '../application_package.dart'; +import '../device.dart'; +import 'build.dart'; +import 'flutter_command.dart'; +import 'install.dart'; +import 'stop.dart'; + +final Logger _logging = new Logger('sky_tools.start'); +const String _localBundleName = 'app.flx'; +const String _localSnapshotName = 'snapshot_blob.bin'; + +class StartCommand extends FlutterCommand { + final String name = 'start'; + final String description = 'Start your Flutter app on attached devices.'; + + StartCommand() { + argParser.addFlag('poke', + negatable: false, + help: 'Restart the connection to the server (Android only).'); + argParser.addFlag('checked', + negatable: true, + defaultsTo: true, + help: 'Toggle Dart\'s checked mode.'); + argParser.addOption('target', + defaultsTo: '.', + abbr: 't', + help: 'Target app path or filename to start.'); + argParser.addFlag('http', + negatable: true, + help: 'Use a local HTTP server to serve your app to your device.'); + argParser.addFlag('boot', + help: 'Boot the iOS Simulator if it isn\'t already running.'); + } + + @override + Future runInProject() async { + await Future.wait([ + downloadToolchain(), + downloadApplicationPackagesAndConnectToDevices(), + ]); + + bool poke = argResults['poke']; + if (!poke) { + StopCommand stopper = new StopCommand(); + stopper.inheritFromParent(this); + stopper.stop(); + + // Only install if the user did not specify a poke + InstallCommand installer = new InstallCommand(); + installer.inheritFromParent(this); + installer.install(boot: argResults['boot']); + } + + bool startedSomething = false; + + for (Device device in devices.all) { + ApplicationPackage package = applicationPackages.getPackageForPlatform(device.platform); + if (package == null || !device.isConnected()) + continue; + if (device is AndroidDevice) { + String target = path.absolute(argResults['target']); + if (argResults['http']) { + if (await device.startServer(target, poke, argResults['checked'], package)) + startedSomething = true; + } else { + String mainPath = target; + if (FileSystemEntity.isDirectorySync(target)) + mainPath = path.join(target, 'lib', 'main.dart'); + BuildCommand builder = new BuildCommand(); + builder.inheritFromParent(this); + + Directory tempDir = await Directory.systemTemp.createTemp('flutter_tools'); + try { + String localBundlePath = path.join(tempDir.path, _localBundleName); + String localSnapshotPath = path.join(tempDir.path, _localSnapshotName); + await builder.build( + snapshotPath: localSnapshotPath, + outputPath: localBundlePath, + mainPath: mainPath); + if (device.startBundle(package, localBundlePath, poke, argResults['checked'])) + startedSomething = true; + } finally { + tempDir.deleteSync(recursive: true); + } + } + } else { + if (await device.startApp(package)) + startedSomething = true; + } + } + + if (!startedSomething) { + if (!devices.all.any((device) => device.isConnected())) { + _logging.severe('Unable to run application - no connected devices.'); + } else { + _logging.severe('Unable to run application.'); + } + } + + return startedSomething ? 0 : 2; + } +} diff --git a/packages/flutter_tools/lib/src/commands/stop.dart b/packages/flutter_tools/lib/src/commands/stop.dart new file mode 100644 index 00000000000..9117443594a --- /dev/null +++ b/packages/flutter_tools/lib/src/commands/stop.dart @@ -0,0 +1,38 @@ +// 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:logging/logging.dart'; + +import '../application_package.dart'; +import '../device.dart'; +import 'flutter_command.dart'; + +final Logger _logging = new Logger('sky_tools.stop'); + +class StopCommand extends FlutterCommand { + final String name = 'stop'; + final String description = 'Stop your Flutter app on all attached devices.'; + + @override + Future runInProject() async { + await downloadApplicationPackagesAndConnectToDevices(); + return await stop() ? 0 : 2; + } + + Future stop() async { + bool stoppedSomething = false; + + for (Device device in devices.all) { + ApplicationPackage package = applicationPackages.getPackageForPlatform(device.platform); + if (package == null || !device.isConnected()) + continue; + if (await device.stopApp(package)) + stoppedSomething = true; + } + + return stoppedSomething; + } +} diff --git a/packages/flutter_tools/lib/src/commands/trace.dart b/packages/flutter_tools/lib/src/commands/trace.dart new file mode 100644 index 00000000000..b30f570312b --- /dev/null +++ b/packages/flutter_tools/lib/src/commands/trace.dart @@ -0,0 +1,65 @@ +// 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:logging/logging.dart'; + +import '../application_package.dart'; +import '../device.dart'; +import 'flutter_command.dart'; + +final Logger _logging = new Logger('sky_tools.trace'); + +class TraceCommand extends FlutterCommand { + final String name = 'trace'; + final String description = 'Start and stop tracing a running Flutter app ' + '(Android only, requires root).\n' + 'To start a trace, wait, and then stop the trace, don\'t set any flags ' + 'except (optionally) duration.\n' + 'Otherwise, specify either start or stop to manually control the trace.'; + + TraceCommand() { + argParser.addFlag('start', negatable: false, help: 'Start tracing.'); + argParser.addFlag('stop', negatable: false, help: 'Stop tracing.'); + argParser.addOption('duration', + defaultsTo: '10', abbr: 'd', help: 'Duration in seconds to trace.'); + } + + @override + Future runInProject() async { + await downloadApplicationPackagesAndConnectToDevices(); + + if (!devices.android.isConnected()) { + _logging.warning('No device connected, so no trace was completed.'); + return 1; + } + + ApplicationPackage androidApp = applicationPackages.android; + + if ((!argResults['start'] && !argResults['stop']) || + (argResults['start'] && argResults['stop'])) { + // Setting neither flags or both flags means do both commands and wait + // duration seconds in between. + devices.android.startTracing(androidApp); + await new Future.delayed( + new Duration(seconds: int.parse(argResults['duration'])), + () => _stopTracing(devices.android, androidApp)); + } else if (argResults['stop']) { + _stopTracing(devices.android, androidApp); + } else { + devices.android.startTracing(androidApp); + } + return 0; + } + + void _stopTracing(AndroidDevice android, AndroidApk androidApp) { + String tracePath = android.stopTracing(androidApp); + if (tracePath == null) { + _logging.warning('No trace file saved.'); + } else { + print('Trace file saved to $tracePath'); + } + } +} diff --git a/packages/flutter_tools/lib/src/device.dart b/packages/flutter_tools/lib/src/device.dart new file mode 100644 index 00000000000..3dfe573bfb7 --- /dev/null +++ b/packages/flutter_tools/lib/src/device.dart @@ -0,0 +1,995 @@ +// 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 'dart:math'; + +import 'package:crypto/crypto.dart'; +import 'package:logging/logging.dart'; +import 'package:path/path.dart' as path; + +import 'application_package.dart'; +import 'build_configuration.dart'; +import 'os_utils.dart'; +import 'process.dart'; + +final Logger _logging = new Logger('sky_tools.device'); + +abstract class Device { + final String id; + static Map _deviceCache = {}; + + factory Device._unique(String className, [String id = null]) { + if (id == null) { + if (className == AndroidDevice.className) { + id = AndroidDevice.defaultDeviceID; + } else if (className == IOSDevice.className) { + id = IOSDevice.defaultDeviceID; + } else if (className == IOSSimulator.className) { + id = IOSSimulator.defaultDeviceID; + } else { + throw 'Attempted to create a Device of unknown type $className'; + } + } + + return _deviceCache.putIfAbsent(id, () { + if (className == AndroidDevice.className) { + final device = new AndroidDevice._(id); + _deviceCache[id] = device; + return device; + } else if (className == IOSDevice.className) { + final device = new IOSDevice._(id); + _deviceCache[id] = device; + return device; + } else if (className == IOSSimulator.className) { + final device = new IOSSimulator._(id); + _deviceCache[id] = device; + return device; + } else { + throw 'Attempted to create a Device of unknown type $className'; + } + }); + } + + Device._(this.id); + + /// Install an app package on the current device + bool installApp(ApplicationPackage app); + + /// Check if the device is currently connected + bool isConnected(); + + /// Check if the current version of the given app is already installed + bool isAppInstalled(ApplicationPackage app); + + TargetPlatform get platform; + + Future logs({bool clear: false}); + + /// Start an app package on the current device + Future startApp(ApplicationPackage app); + + /// Stop an app package on the current device + Future stopApp(ApplicationPackage app); +} + +class IOSDevice extends Device { + static const String className = 'IOSDevice'; + static final String defaultDeviceID = 'default_ios_id'; + + static const String _macInstructions = + 'To work with iOS devices, please install ideviceinstaller. ' + 'If you use homebrew, you can install it with ' + '"\$ brew install ideviceinstaller".'; + static const String _linuxInstructions = + 'To work with iOS devices, please install ideviceinstaller. ' + 'On Ubuntu or Debian, you can install it with ' + '"\$ apt-get install ideviceinstaller".'; + + String _installerPath; + String get installerPath => _installerPath; + + String _listerPath; + String get listerPath => _listerPath; + + String _informerPath; + String get informerPath => _informerPath; + + String _debuggerPath; + String get debuggerPath => _debuggerPath; + + String _loggerPath; + String get loggerPath => _loggerPath; + + String _pusherPath; + String get pusherPath => _pusherPath; + + String _name; + String get name => _name; + + factory IOSDevice({String id, String name}) { + IOSDevice device = new Device._unique(className, id); + device._name = name; + return device; + } + + IOSDevice._(String id) : super._(id) { + _installerPath = _checkForCommand('ideviceinstaller'); + _listerPath = _checkForCommand('idevice_id'); + _informerPath = _checkForCommand('ideviceinfo'); + _debuggerPath = _checkForCommand('idevicedebug'); + _loggerPath = _checkForCommand('idevicesyslog'); + _pusherPath = _checkForCommand( + 'ios-deploy', + 'To copy files to iOS devices, please install ios-deploy. ' + 'You can do this using homebrew as follows:\n' + '\$ brew tap flutter/flutter\n' + '\$ brew install ios-deploy', + 'Copying files to iOS devices is not currently supported on Linux.'); + } + + static List getAttachedDevices([IOSDevice mockIOS]) { + List devices = []; + for (String id in _getAttachedDeviceIDs(mockIOS)) { + String name = _getDeviceName(id, mockIOS); + devices.add(new IOSDevice(id: id, name: name)); + } + return devices; + } + + static Iterable _getAttachedDeviceIDs([IOSDevice mockIOS]) { + String listerPath = + (mockIOS != null) ? mockIOS.listerPath : _checkForCommand('idevice_id'); + String output; + try { + output = runSync([listerPath, '-l']); + } catch (e) { + return []; + } + return output.trim() + .split('\n') + .where((String s) => s != null && s.length > 0); + } + + static String _getDeviceName(String deviceID, [IOSDevice mockIOS]) { + String informerPath = (mockIOS != null) + ? mockIOS.informerPath + : _checkForCommand('ideviceinfo'); + return runSync([informerPath, '-k', 'DeviceName', '-u', deviceID]); + } + + static final Map _commandMap = {}; + static String _checkForCommand(String command, + [String macInstructions = _macInstructions, + String linuxInstructions = _linuxInstructions]) { + return _commandMap.putIfAbsent(command, () { + try { + command = runCheckedSync(['which', command]).trim(); + } catch (e) { + if (Platform.isMacOS) { + _logging.severe(macInstructions); + } else if (Platform.isLinux) { + _logging.severe(linuxInstructions); + } else { + _logging.severe('$command is not available on your platform.'); + } + } + return command; + }); + } + + @override + bool installApp(ApplicationPackage app) { + try { + if (id == defaultDeviceID) { + runCheckedSync([installerPath, '-i', app.localPath]); + } else { + runCheckedSync([installerPath, '-u', id, '-i', app.localPath]); + } + return true; + } catch (e) { + return false; + } + return false; + } + + @override + bool isConnected() { + Iterable ids = _getAttachedDeviceIDs(); + for (String id in ids) { + if (id == this.id || this.id == defaultDeviceID) { + return true; + } + } + return false; + } + + @override + bool isAppInstalled(ApplicationPackage app) { + try { + String apps = runCheckedSync([installerPath, '-l']); + if (new RegExp(app.id, multiLine: true).hasMatch(apps)) { + return true; + } + } catch (e) { + return false; + } + return false; + } + + @override + Future startApp(ApplicationPackage app) async { + if (!isAppInstalled(app)) { + return false; + } + // idevicedebug hangs forever after launching the app, so kill it after + // giving it plenty of time to send the launch command. + return runAndKill( + [debuggerPath, 'run', app.id], new Duration(seconds: 3)).then( + (_) { + return true; + }, onError: (e) { + _logging.info('Failure running $debuggerPath: ', e); + return false; + }); + } + + @override + Future stopApp(ApplicationPackage app) async { + // Currently we don't have a way to stop an app running on iOS. + return false; + } + + Future pushFile( + ApplicationPackage app, String localFile, String targetFile) async { + if (Platform.isMacOS) { + runSync([ + pusherPath, + '-t', + '1', + '--bundle_id', + app.id, + '--upload', + localFile, + '--to', + targetFile + ]); + return true; + } else { + // TODO(iansf): It may be possible to make this work on Linux. Since this + // functionality appears to be the only that prevents us from + // supporting iOS on Linux, it may be worth putting some time + // into investigating this. + // See https://bbs.archlinux.org/viewtopic.php?id=192655 + return false; + } + return false; + } + + @override + TargetPlatform get platform => TargetPlatform.iOS; + + /// Note that clear is not supported on iOS at this time. + Future logs({bool clear: false}) async { + if (!isConnected()) { + return 2; + } + return runCommandAndStreamOutput([loggerPath], + prefix: 'iOS dev: ', filter: new RegExp(r'.*SkyShell.*')); + } +} + +class IOSSimulator extends Device { + static const String className = 'IOSSimulator'; + static final String defaultDeviceID = 'default_ios_sim_id'; + + static const String _macInstructions = + 'To work with iOS devices, please install ideviceinstaller. ' + 'If you use homebrew, you can install it with ' + '"\$ brew install ideviceinstaller".'; + + static String _xcrunPath = path.join('/usr', 'bin', 'xcrun'); + + String _iOSSimPath; + String get iOSSimPath => _iOSSimPath; + + String get xcrunPath => _xcrunPath; + + String _name; + String get name => _name; + + factory IOSSimulator({String id, String name, String iOSSimulatorPath}) { + IOSSimulator device = new Device._unique(className, id); + device._name = name; + if (iOSSimulatorPath == null) { + iOSSimulatorPath = path.join('/Applications', 'iOS Simulator.app', + 'Contents', 'MacOS', 'iOS Simulator'); + } + device._iOSSimPath = iOSSimulatorPath; + return device; + } + + IOSSimulator._(String id) : super._(id) {} + + static String _getRunningSimulatorID([IOSSimulator mockIOS]) { + String xcrunPath = mockIOS != null ? mockIOS.xcrunPath : _xcrunPath; + String output = runCheckedSync([xcrunPath, 'simctl', 'list', 'devices']); + + Match match; + Iterable matches = new RegExp(r'[^\(]+\(([^\)]+)\) \(Booted\)', + multiLine: true).allMatches(output); + if (matches.length > 1) { + // More than one simulator is listed as booted, which is not allowed but + // sometimes happens erroneously. Kill them all because we don't know + // which one is actually running. + _logging.warning('Multiple running simulators were detected, ' + 'which is not supposed to happen.'); + for (Match m in matches) { + if (m.groupCount > 0) { + _logging.warning('Killing simulator ${m.group(1)}'); + runSync([xcrunPath, 'simctl', 'shutdown', m.group(1)]); + } + } + } else if (matches.length == 1) { + match = matches.first; + } + + if (match != null && match.groupCount > 0) { + return match.group(1); + } else { + _logging.info('No running simulators found'); + return null; + } + } + + String _getSimulatorPath() { + String deviceID = id == defaultDeviceID ? _getRunningSimulatorID() : id; + String homeDirectory = path.absolute(Platform.environment['HOME']); + if (deviceID == null) { + return null; + } + return path.join(homeDirectory, 'Library', 'Developer', 'CoreSimulator', + 'Devices', deviceID); + } + + String _getSimulatorAppHomeDirectory(ApplicationPackage app) { + String simulatorPath = _getSimulatorPath(); + if (simulatorPath == null) { + return null; + } + return path.join(simulatorPath, 'data'); + } + + static List getAttachedDevices([IOSSimulator mockIOS]) { + List devices = []; + String id = _getRunningSimulatorID(mockIOS); + if (id != null) { + // TODO(iansf): get the simulator's name + // String name = _getDeviceName(id, mockIOS); + devices.add(new IOSSimulator(id: id)); + } + return devices; + } + + Future boot() async { + if (!Platform.isMacOS) { + return false; + } + if (isConnected()) { + return true; + } + if (id == defaultDeviceID) { + runDetached([iOSSimPath]); + + Future checkConnection([int attempts = 20]) async { + if (attempts == 0) { + _logging.info('Timed out waiting for iOS Simulator $id to boot.'); + return false; + } + if (!isConnected()) { + _logging.info('Waiting for iOS Simulator $id to boot...'); + return new Future.delayed(new Duration(milliseconds: 500), + () => checkConnection(attempts - 1)); + } + return true; + } + return checkConnection(); + } else { + try { + runCheckedSync([xcrunPath, 'simctl', 'boot', id]); + } catch (e) { + _logging.warning('Unable to boot iOS Simulator $id: ', e); + return false; + } + } + return false; + } + + @override + bool installApp(ApplicationPackage app) { + if (!isConnected()) { + return false; + } + try { + if (id == defaultDeviceID) { + runCheckedSync([xcrunPath, 'simctl', 'install', 'booted', app.localPath]); + } else { + runCheckedSync([xcrunPath, 'simctl', 'install', id, app.localPath]); + } + return true; + } catch (e) { + return false; + } + } + + @override + bool isConnected() { + if (!Platform.isMacOS) { + return false; + } + String simulatorID = _getRunningSimulatorID(); + if (simulatorID == null) { + return false; + } else if (id == defaultDeviceID) { + return true; + } else { + return _getRunningSimulatorID() == id; + } + } + + @override + bool isAppInstalled(ApplicationPackage app) { + try { + String simulatorHomeDirectory = _getSimulatorAppHomeDirectory(app); + return FileSystemEntity.isDirectorySync(simulatorHomeDirectory); + } catch (e) { + return false; + } + } + + @override + Future startApp(ApplicationPackage app) async { + if (!isAppInstalled(app)) { + return false; + } + try { + if (id == defaultDeviceID) { + runCheckedSync( + [xcrunPath, 'simctl', 'launch', 'booted', app.id]); + } else { + runCheckedSync([xcrunPath, 'simctl', 'launch', id, app.id]); + } + return true; + } catch (e) { + return false; + } + } + + @override + Future stopApp(ApplicationPackage app) async { + // Currently we don't have a way to stop an app running on iOS. + return false; + } + + Future pushFile( + ApplicationPackage app, String localFile, String targetFile) async { + if (Platform.isMacOS) { + String simulatorHomeDirectory = _getSimulatorAppHomeDirectory(app); + runCheckedSync( + ['cp', localFile, path.join(simulatorHomeDirectory, targetFile)]); + return true; + } + return false; + } + + @override + TargetPlatform get platform => TargetPlatform.iOSSimulator; + + Future logs({bool clear: false}) async { + if (!isConnected()) { + return 2; + } + String homeDirectory = path.absolute(Platform.environment['HOME']); + String simulatorDeviceID = _getRunningSimulatorID(); + String logFilePath = path.join(homeDirectory, 'Library', 'Logs', + 'CoreSimulator', simulatorDeviceID, 'system.log'); + if (clear) { + runSync(['rm', logFilePath]); + } + return runCommandAndStreamOutput(['tail', '-f', logFilePath], + prefix: 'iOS sim: ', filter: new RegExp(r'.*SkyShell.*')); + } +} + +class AndroidDevice extends Device { + static const String _ADB_PATH = 'adb'; + static const int _observatoryPort = 8181; + static const int _serverPort = 9888; + + static const String className = 'AndroidDevice'; + static final String defaultDeviceID = 'default_android_device'; + + static const String _kFlutterServerStartMessage = 'Serving'; + static const Duration _kFlutterServerTimeout = const Duration(seconds: 3); + + String productID; + String modelID; + String deviceCodeName; + + String _adbPath; + String get adbPath => _adbPath; + bool _hasAdb = false; + bool _hasValidAndroid = false; + + factory AndroidDevice( + {String id: null, + String productID: null, + String modelID: null, + String deviceCodeName: null}) { + AndroidDevice device = new Device._unique(className, id); + device.productID = productID; + device.modelID = modelID; + device.deviceCodeName = deviceCodeName; + return device; + } + + /// mockAndroid argument is only to facilitate testing with mocks, so that + /// we don't have to rely on the test setup having adb available to it. + static List getAttachedDevices([AndroidDevice mockAndroid]) { + List devices = []; + String adbPath = + (mockAndroid != null) ? mockAndroid.adbPath : _getAdbPath(); + List output = + runSync([adbPath, 'devices', '-l']).trim().split('\n'); + RegExp deviceInfo = new RegExp( + r'^(\S+)\s+device\s+\S+\s+product:(\S+)\s+model:(\S+)\s+device:(\S+)$'); + // Skip first line, which is always 'List of devices attached'. + for (String line in output.skip(1)) { + Match match = deviceInfo.firstMatch(line); + if (match != null) { + String deviceID = match[1]; + String productID = match[2]; + String modelID = match[3]; + String deviceCodeName = match[4]; + + devices.add(new AndroidDevice( + id: deviceID, + productID: productID, + modelID: modelID, + deviceCodeName: deviceCodeName)); + } else { + _logging.warning('Unexpected failure parsing device information ' + 'from adb output:\n$line\n' + 'Please report a bug at http://flutter.io/'); + } + } + return devices; + } + + AndroidDevice._(id) : super._(id) { + _adbPath = _getAdbPath(); + _hasAdb = _checkForAdb(); + + // Checking for lollipop only needs to be done if we are starting an + // app, but it has an important side effect, which is to discard any + // progress messages if the adb server is restarted. + _hasValidAndroid = _checkForLollipopOrLater(); + + if (!_hasAdb || !_hasValidAndroid) { + _logging.warning('Unable to run on Android.'); + } + } + + static String _getAdbPath() { + if (Platform.environment.containsKey('ANDROID_HOME')) { + String androidHomeDir = Platform.environment['ANDROID_HOME']; + String adbPath1 = + path.join(androidHomeDir, 'sdk', 'platform-tools', 'adb'); + String adbPath2 = path.join(androidHomeDir, 'platform-tools', 'adb'); + if (FileSystemEntity.isFileSync(adbPath1)) { + return adbPath1; + } else if (FileSystemEntity.isFileSync(adbPath2)) { + return adbPath2; + } else { + _logging.info('"adb" not found at\n "$adbPath1" or\n "$adbPath2"\n' + + 'using default path "$_ADB_PATH"'); + return _ADB_PATH; + } + } else { + return _ADB_PATH; + } + } + + bool _isValidAdbVersion(String adbVersion) { + // Sample output: 'Android Debug Bridge version 1.0.31' + Match versionFields = + new RegExp(r'(\d+)\.(\d+)\.(\d+)').firstMatch(adbVersion); + if (versionFields != null) { + int majorVersion = int.parse(versionFields[1]); + int minorVersion = int.parse(versionFields[2]); + int patchVersion = int.parse(versionFields[3]); + if (majorVersion > 1) { + return true; + } + if (majorVersion == 1 && minorVersion > 0) { + return true; + } + if (majorVersion == 1 && minorVersion == 0 && patchVersion >= 32) { + return true; + } + return false; + } + _logging.warning( + 'Unrecognized adb version string $adbVersion. Skipping version check.'); + return true; + } + + bool _checkForAdb() { + try { + String adbVersion = runCheckedSync([adbPath, 'version']); + if (_isValidAdbVersion(adbVersion)) { + return true; + } + + String locatedAdbPath = runCheckedSync(['which', 'adb']); + _logging.severe('"$locatedAdbPath" is too old. ' + 'Please install version 1.0.32 or later.\n' + 'Try setting ANDROID_HOME to the path to your Android SDK install. ' + 'Android builds are unavailable.'); + } catch (e, stack) { + _logging.severe('"adb" not found in \$PATH. ' + 'Please install the Android SDK or set ANDROID_HOME ' + 'to the path of your Android SDK install.'); + _logging.info(e); + _logging.info(stack); + } + return false; + } + + bool _checkForLollipopOrLater() { + try { + // If the server is automatically restarted, then we get irrelevant + // output lines like this, which we want to ignore: + // adb server is out of date. killing.. + // * daemon started successfully * + runCheckedSync([adbPath, 'start-server']); + + String ready = runSync([adbPath, 'shell', 'echo', 'ready']); + if (ready.trim() != 'ready') { + _logging.info('Android device not found.'); + return false; + } + + // Sample output: '22' + String sdkVersion = + runCheckedSync([adbPath, 'shell', 'getprop', 'ro.build.version.sdk']) + .trimRight(); + + int sdkVersionParsed = + int.parse(sdkVersion, onError: (String source) => null); + if (sdkVersionParsed == null) { + _logging.severe('Unexpected response from getprop: "$sdkVersion"'); + return false; + } + if (sdkVersionParsed < 19) { + _logging.severe('Version "$sdkVersion" of the Android SDK is too old. ' + 'Please install Jelly Bean (version 19) or later.'); + return false; + } + return true; + } catch (e) { + _logging.severe('Unexpected failure from adb: ', e); + } + return false; + } + + String _getDeviceDataPath(ApplicationPackage app) { + return '/data/data/${app.id}'; + } + + String _getDeviceSha1Path(ApplicationPackage app) { + return '${_getDeviceDataPath(app)}/${app.name}.sha1'; + } + + String _getDeviceBundlePath(ApplicationPackage app) { + return '${_getDeviceDataPath(app)}/dev.flx'; + } + + String _getDeviceApkSha1(ApplicationPackage app) { + return runCheckedSync([adbPath, 'shell', 'cat', _getDeviceSha1Path(app)]); + } + + String _getSourceSha1(ApplicationPackage app) { + var sha1 = new SHA1(); + var file = new File(app.localPath); + sha1.add(file.readAsBytesSync()); + return CryptoUtils.bytesToHex(sha1.close()); + } + + /** + * Since Window's paths have backslashes, we need to convert those to forward slashes to make a valid URL + */ + String _convertToURL(String path) { + return path.replaceAll('\\', '/'); + } + + @override + bool isAppInstalled(ApplicationPackage app) { + if (!isConnected()) { + return false; + } + if (runCheckedSync([adbPath, 'shell', 'pm', 'path', app.id]) == + '') { + _logging.info( + 'TODO(iansf): move this log to the caller. ${app.name} is not on the device. Installing now...'); + return false; + } + if (_getDeviceApkSha1(app) != _getSourceSha1(app)) { + _logging.info( + 'TODO(iansf): move this log to the caller. ${app.name} is out of date. Installing now...'); + return false; + } + return true; + } + + @override + bool installApp(ApplicationPackage app) { + if (!isConnected()) { + _logging.info('Android device not connected. Not installing.'); + return false; + } + if (!FileSystemEntity.isFileSync(app.localPath)) { + _logging.severe('"${app.localPath}" does not exist.'); + return false; + } + + print('Installing ${app.name} on device.'); + runCheckedSync([adbPath, 'install', '-r', app.localPath]); + runCheckedSync([adbPath, 'shell', 'run-as', app.id, 'chmod', '777', _getDeviceDataPath(app)]); + runCheckedSync([adbPath, 'shell', 'echo', '-n', _getSourceSha1(app), '>', _getDeviceSha1Path(app)]); + return true; + } + + void _forwardObservatoryPort() { + // Set up port forwarding for observatory. + String portString = 'tcp:$_observatoryPort'; + runCheckedSync([adbPath, 'forward', portString, portString]); + } + + bool startBundle(AndroidApk apk, String bundlePath, bool poke, bool checked) { + if (!FileSystemEntity.isFileSync(bundlePath)) { + _logging.severe('Cannot find $bundlePath'); + return false; + } + + if (!poke) + _forwardObservatoryPort(); + + String deviceTmpPath = '/data/local/tmp/dev.flx'; + String deviceBundlePath = _getDeviceBundlePath(apk); + runCheckedSync([adbPath, 'push', bundlePath, deviceTmpPath]); + runCheckedSync([adbPath, 'shell', 'mv', deviceTmpPath, deviceBundlePath]); + List cmd = [ + adbPath, + 'shell', 'am', 'start', + '-a', 'android.intent.action.RUN', + '-d', deviceBundlePath, + ]; + if (checked) + cmd.addAll(['--ez', 'enable-checked-mode', 'true']); + cmd.add(apk.launchActivity); + runCheckedSync(cmd); + return true; + } + + Future startServer( + String target, bool poke, bool checked, AndroidApk apk) async { + String serverRoot = ''; + String mainDart = ''; + String missingMessage = ''; + if (FileSystemEntity.isDirectorySync(target)) { + serverRoot = target; + mainDart = path.join(serverRoot, 'lib', 'main.dart'); + missingMessage = 'Missing lib/main.dart in project: $serverRoot'; + } else { + serverRoot = Directory.current.path; + mainDart = target; + missingMessage = '$mainDart does not exist.'; + } + + if (!FileSystemEntity.isFileSync(mainDart)) { + _logging.severe(missingMessage); + return false; + } + + if (!poke) { + _forwardObservatoryPort(); + + // Actually start the server. + Process server = await Process.start( + sdkBinaryName('pub'), ['run', 'sky_tools:sky_server', _serverPort.toString()], + workingDirectory: serverRoot, + mode: ProcessStartMode.DETACHED_WITH_STDIO + ); + await server.stdout.transform(UTF8.decoder) + .firstWhere((String value) => value.startsWith(_kFlutterServerStartMessage)) + .timeout(_kFlutterServerTimeout); + + // Set up reverse port-forwarding so that the Android app can reach the + // server running on localhost. + String serverPortString = 'tcp:$_serverPort'; + runCheckedSync([adbPath, 'reverse', serverPortString, serverPortString]); + } + + String relativeDartMain = _convertToURL(path.relative(mainDart, from: serverRoot)); + String url = 'http://localhost:$_serverPort/$relativeDartMain'; + if (poke) + url += '?rand=${new Random().nextDouble()}'; + + // Actually launch the app on Android. + List cmd = [ + adbPath, + 'shell', 'am', 'start', + '-a', 'android.intent.action.VIEW', + '-d', url, + ]; + if (checked) + cmd.addAll(['--ez', 'enable-checked-mode', 'true']); + cmd.add(apk.launchActivity); + runCheckedSync(cmd); + return true; + } + + @override + Future startApp(ApplicationPackage app) async { + // Android currently has to be started with startServer(...). + assert(false); + return false; + } + + Future stopApp(ApplicationPackage app) async { + final AndroidApk apk = app; + + // Turn off reverse port forwarding + runSync([adbPath, 'reverse', '--remove', 'tcp:$_serverPort']); + // Stop the app + runSync([adbPath, 'shell', 'am', 'force-stop', apk.id]); + + // Kill the server + osUtils.killTcpPortListeners(_serverPort); + + return true; + } + + @override + TargetPlatform get platform => TargetPlatform.android; + + void clearLogs() { + runSync([adbPath, 'logcat', '-c']); + } + + Future logs({bool clear: false}) async { + if (!isConnected()) { + return 2; + } + + if (clear) { + clearLogs(); + } + + return runCommandAndStreamOutput([ + adbPath, + 'logcat', + '-v', + 'tag', // Only log the tag and the message + '-s', + 'sky:V', + 'chromium:D', + 'ActivityManager:W', + '*:F', + ], prefix: 'android: '); + } + + void startTracing(AndroidApk apk) { + runCheckedSync([ + adbPath, + 'shell', + 'am', + 'broadcast', + '-a', + '${apk.id}.TRACING_START' + ]); + } + + String stopTracing(AndroidApk apk) { + clearLogs(); + runCheckedSync([ + adbPath, + 'shell', + 'am', + 'broadcast', + '-a', + '${apk.id}.TRACING_STOP' + ]); + + RegExp traceRegExp = new RegExp(r'Saving trace to (\S+)', multiLine: true); + RegExp completeRegExp = new RegExp(r'Trace complete', multiLine: true); + + String tracePath = null; + bool isComplete = false; + while (!isComplete) { + String logs = runSync([adbPath, 'logcat', '-d']); + Match fileMatch = traceRegExp.firstMatch(logs); + if (fileMatch[1] != null) { + tracePath = fileMatch[1]; + } + isComplete = completeRegExp.hasMatch(logs); + } + + if (tracePath != null) { + runSync([adbPath, 'shell', 'run-as', apk.id, 'chmod', '777', tracePath]); + runSync([adbPath, 'pull', tracePath]); + runSync([adbPath, 'shell', 'rm', tracePath]); + return path.basename(tracePath); + } + _logging.warning('No trace file detected. ' + 'Did you remember to start the trace before stopping it?'); + return null; + } + + @override + bool isConnected() => _hasValidAndroid; +} + +class DeviceStore { + final AndroidDevice android; + final IOSDevice iOS; + final IOSSimulator iOSSimulator; + + List get all { + List result = []; + if (android != null) + result.add(android); + if (iOS != null) + result.add(iOS); + if (iOSSimulator != null) + result.add(iOSSimulator); + return result; + } + + DeviceStore({ + this.android, + this.iOS, + this.iOSSimulator + }); + + factory DeviceStore.forConfigs(List configs) { + AndroidDevice android; + IOSDevice iOS; + IOSSimulator iOSSimulator; + + for (BuildConfiguration config in configs) { + switch (config.targetPlatform) { + case TargetPlatform.android: + assert(android == null); + android = new AndroidDevice(); + break; + case TargetPlatform.iOS: + assert(iOS == null); + iOS = new IOSDevice(); + break; + case TargetPlatform.iOSSimulator: + assert(iOSSimulator == null); + iOSSimulator = new IOSSimulator(); + break; + case TargetPlatform.linux: + break; + } + } + + return new DeviceStore(android: android, iOS: iOS, iOSSimulator: iOSSimulator); + } +} diff --git a/packages/flutter_tools/lib/src/os_utils.dart b/packages/flutter_tools/lib/src/os_utils.dart new file mode 100644 index 00000000000..caf124d0a9c --- /dev/null +++ b/packages/flutter_tools/lib/src/os_utils.dart @@ -0,0 +1,91 @@ +// 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:io'; + +import 'package:logging/logging.dart'; + +import 'process.dart'; + +final OperatingSystemUtils osUtils = new OperatingSystemUtils._(); + +final Logger _logging = new Logger('sky_tools.os'); + +abstract class OperatingSystemUtils { + factory OperatingSystemUtils._() { + if (Platform.isWindows) { + return new _WindowsUtils(); + } else if (Platform.isMacOS) { + return new _MacUtils(); + } else { + return new _LinuxUtils(); + } + } + + /// Make the given file executable. This may be a no-op on some platforms. + ProcessResult makeExecutable(File file); + + /// A best-effort attempt to kill all listeners on the given TCP port. + void killTcpPortListeners(int tcpPort); +} + +abstract class _PosixUtils implements OperatingSystemUtils { + ProcessResult makeExecutable(File file) { + return Process.runSync('chmod', ['u+x', file.path]); + } +} + +class _WindowsUtils implements OperatingSystemUtils { + // This is a no-op. + ProcessResult makeExecutable(File file) { + return new ProcessResult(0, 0, null, null); + } + + void killTcpPortListeners(int tcpPort) { + // Get list of network processes and split on newline + List processes = runSync(['netstat.exe','-ano']).split("\r"); + + // List entries from netstat is formatted like so: + // TCP 192.168.2.11:50945 192.30.252.90:443 LISTENING 1304 + // This regexp is to find process where the the port exactly matches + RegExp pattern = new RegExp(':$tcpPort[ ]+'); + + // Split the columns by 1 or more spaces + RegExp columnPattern = new RegExp('[ ]+'); + processes.forEach((String process) { + if (process.contains(pattern)) { + // The last column is the Process ID + String processId = process.split(columnPattern).last; + // Force and Tree kill the process + _logging.info('kill $processId'); + runSync(['TaskKill.exe', '/F', '/T', '/PID', processId]); + } + }); + } +} + +class _MacUtils extends _PosixUtils { + void killTcpPortListeners(int tcpPort) { + String pids = runSync(['lsof', '-i', ':$tcpPort', '-t']).trim(); + if (pids.isNotEmpty) { + // Handle multiple returned pids. + for (String pidString in pids.split('\n')) { + // Killing a pid with a shell command from within dart is hard, so use a + // library command, but it's still nice to give the equivalent command + // when doing verbose logging. + _logging.info('kill $pidString'); + + int pid = int.parse(pidString, onError: (_) => null); + if (pid != null) + Process.killPid(pid); + } + } + } +} + +class _LinuxUtils extends _PosixUtils { + void killTcpPortListeners(int tcpPort) { + runSync(['fuser', '-k', '$tcpPort/tcp']); + } +} diff --git a/packages/flutter_tools/lib/src/process.dart b/packages/flutter_tools/lib/src/process.dart new file mode 100644 index 00000000000..2fd8a7a360d --- /dev/null +++ b/packages/flutter_tools/lib/src/process.dart @@ -0,0 +1,96 @@ +// 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'; + +final Logger _logging = new Logger('sky_tools.process'); + +/// This runs the command and streams stdout/stderr from the child process to +/// this process' stdout/stderr. +Future runCommandAndStreamOutput(List cmd, + {String prefix: '', RegExp filter}) async { + _logging.info(cmd.join(' ')); + Process proc = + await Process.start(cmd[0], cmd.getRange(1, cmd.length).toList()); + proc.stdout.transform(UTF8.decoder).listen((String data) { + List dataLines = data.trimRight().split('\n'); + if (filter != null) { + dataLines = dataLines.where((String s) => filter.hasMatch(s)).toList(); + } + if (dataLines.length > 0) { + stdout.write('$prefix${dataLines.join('\n$prefix')}\n'); + } + }); + proc.stderr.transform(UTF8.decoder).listen((String data) { + List dataLines = data.trimRight().split('\n'); + if (filter != null) { + dataLines = dataLines.where((String s) => filter.hasMatch(s)); + } + if (dataLines.length > 0) { + stderr.write('$prefix${dataLines.join('\n$prefix')}\n'); + } + }); + return proc.exitCode; +} + +Future runAndKill(List cmd, Duration timeout) async { + Future proc = runDetached(cmd); + return new Future.delayed(timeout, () async { + _logging.info('Intentionally killing ${cmd[0]}'); + Process.killPid((await proc).pid); + }); +} + +Future runDetached(List cmd) async { + _logging.info(cmd.join(' ')); + Future proc = Process.start( + cmd[0], cmd.getRange(1, cmd.length).toList(), + mode: ProcessStartMode.DETACHED); + return proc; +} + +/// Run cmd and return stdout. +/// Throws an error if cmd exits with a non-zero value. +String runCheckedSync(List cmd) => + _runWithLoggingSync(cmd, checked: true); + +/// Run cmd and return stdout. +String runSync(List cmd) => _runWithLoggingSync(cmd); + +/// Return the platform specific name for the given Dart SDK binary. So, `pub` +/// ==> `pub.bat`. +String sdkBinaryName(String name) { + return Platform.isWindows ? '${name}.bat' : name; +} + +String _runWithLoggingSync(List cmd, {bool checked: false}) { + _logging.info(cmd.join(' ')); + ProcessResult results = + Process.runSync(cmd[0], cmd.getRange(1, cmd.length).toList()); + if (results.exitCode != 0) { + String errorDescription = 'Error code ${results.exitCode} ' + 'returned when attempting to run command: ${cmd.join(' ')}'; + _logging.fine(errorDescription); + if (results.stderr.length > 0) { + _logging.info('Errors logged: ${results.stderr.trim()}'); + } + + if (checked) { + throw errorDescription; + } + } + _logging.fine(results.stdout.trim()); + return results.stdout; +} + +class ProcessExit implements Exception { + final int exitCode; + ProcessExit(this.exitCode); + String get message => 'ProcessExit: ${exitCode}'; + String toString() => message; +} diff --git a/packages/flutter_tools/lib/src/test/json_socket.dart b/packages/flutter_tools/lib/src/test/json_socket.dart new file mode 100644 index 00000000000..2edc71db8c5 --- /dev/null +++ b/packages/flutter_tools/lib/src/test/json_socket.dart @@ -0,0 +1,20 @@ +// 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'; + +class JSONSocket { + JSONSocket(WebSocket socket, this.unusualTermination) + : _socket = socket, stream = socket.map(JSON.decode).asBroadcastStream(); + + final WebSocket _socket; + final Stream stream; + final Future unusualTermination; + + void send(dynamic data) { + _socket.add(JSON.encode(data)); + } +} diff --git a/packages/flutter_tools/lib/src/test/loader.dart b/packages/flutter_tools/lib/src/test/loader.dart new file mode 100644 index 00000000000..0b6e090f9f7 --- /dev/null +++ b/packages/flutter_tools/lib/src/test/loader.dart @@ -0,0 +1,189 @@ +// 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:path/path.dart' as p; +import 'package:sky_tools/src/test/json_socket.dart'; +import 'package:sky_tools/src/test/remote_test.dart'; +import 'package:stack_trace/stack_trace.dart'; +import 'package:test/src/backend/group.dart'; +import 'package:test/src/backend/metadata.dart'; +import 'package:test/src/backend/test_platform.dart'; +import 'package:test/src/runner/configuration.dart'; +import 'package:test/src/runner/hack_load_vm_file_hook.dart' as hack; +import 'package:test/src/runner/load_exception.dart'; +import 'package:test/src/runner/runner_suite.dart'; +import 'package:test/src/runner/vm/environment.dart'; +import 'package:test/src/util/io.dart'; +import 'package:test/src/util/remote_exception.dart'; + +void installHook() { + hack.loadVMFileHook = _loadVMFile; +} + +final String _kSkyShell = Platform.environment['SKY_SHELL']; +const String _kHost = '127.0.0.1'; +const String _kPath = '/runner'; + +// Right now a bunch of our tests crash or assert after the tests have finished running. +// Mostly this is just because the test puts the framework in an inconsistent state with +// a scheduled microtask that verifies that state. Eventually we should fix all these +// problems but for now we'll just paper over them. +const bool kExpectAllTestsToCloseCleanly = false; + +class _ServerInfo { + final String url; + final Future socket; + final HttpServer server; + + _ServerInfo(this.server, this.url, this.socket); +} + +Future<_ServerInfo> _createServer() async { + HttpServer server = await HttpServer.bind(_kHost, 0); + Completer socket = new Completer(); + server.listen((HttpRequest request) { + if (request.uri.path == _kPath) + socket.complete(WebSocketTransformer.upgrade(request)); + }); + return new _ServerInfo(server, 'ws://$_kHost:${server.port}$_kPath', socket.future); +} + +Future _startProcess(String path, { String packageRoot }) { + assert(_kSkyShell != null); // Please provide the path to the shell in the SKY_SHELL environment variable. + return Process.start(_kSkyShell, [ + '--enable-checked-mode', + '--non-interactive', + '--package-root=$packageRoot', + path, + ]); +} + +Future _loadVMFile(String path, + Metadata metadata, + Configuration config) async { + String encodedMetadata = Uri.encodeComponent(JSON.encode( + metadata.serialize())); + _ServerInfo info = await _createServer(); + Directory tempDir = await Directory.systemTemp.createTemp( + 'dart_test_listener'); + File listenerFile = new File('${tempDir.path}/listener.dart'); + await listenerFile.create(); + await listenerFile.writeAsString(''' +import 'dart:convert'; + +import 'package:test/src/backend/metadata.dart'; +import 'package:sky_tools/src/test/remote_listener.dart'; + +import '${p.toUri(p.absolute(path))}' as test; + +void main() { + String server = Uri.decodeComponent('${Uri.encodeComponent(info.url)}'); + Metadata metadata = new Metadata.deserialize( + JSON.decode(Uri.decodeComponent('$encodedMetadata'))); + RemoteListener.start(server, metadata, () => test.main); +} +'''); + + Completer> completer = new Completer>(); + Completer deathCompleter = new Completer(); + + Process process = await _startProcess( + listenerFile.path, + packageRoot: p.absolute(config.packageRoot) + ); + + Future cleanupTempDirectory() async { + if (tempDir == null) + return; + Directory dirToDelete = tempDir; + tempDir = null; + await dirToDelete.delete(recursive: true); + } + + process.exitCode.then((int exitCode) async { + try { + info.server.close(force: true); + await cleanupTempDirectory(); + String output = ''; + if (exitCode < 0) { + // Abnormal termination (high bit of signed 8-bit exitCode is set) + switch (exitCode) { + case -0x0f: // ProcessSignal.SIGTERM + break; // we probably killed it ourselves + case -0x0b: // ProcessSignal.SIGSEGV + output += 'Segmentation fault in subprocess for: $path\n'; + break; + default: + output += 'Unexpected exit code $exitCode from subprocess for: $path\n'; + } + } + String stdout = await process.stdout.transform(UTF8.decoder).join('\n'); + String stderr = await process.stderr.transform(UTF8.decoder).join('\n'); + if (stdout != '') + output += '\nstdout:\n$stdout'; + if (stderr != '') + output += '\nstderr:\n$stderr'; + if (!completer.isCompleted) { + if (output == '') + output = 'No output.'; + completer.completeError( + new LoadException(path, output), + new Trace.current() + ); + } else { + if (kExpectAllTestsToCloseCleanly && output != '') + print('Unexpected failure after test claimed to pass:\n$output'); + } + deathCompleter.complete(output); + } catch (e) { + // Throwing inside this block causes all kinds of hard-to-debug issues + // like stack overflows and hangs. So catch everything just in case. + print("exception while handling subprocess termination: $e"); + } + }); + + JSONSocket socket = new JSONSocket(await info.socket, deathCompleter.future); + + await cleanupTempDirectory(); + + StreamSubscription subscription; + subscription = socket.stream.listen((response) { + if (response["type"] == "print") { + print(response["line"]); + } else if (response["type"] == "loadException") { + process.kill(ProcessSignal.SIGTERM); + completer.completeError( + new LoadException(path, response["message"]), + new Trace.current()); + } else if (response["type"] == "error") { + process.kill(ProcessSignal.SIGTERM); + AsyncError asyncError = RemoteException.deserialize(response["error"]); + completer.completeError( + new LoadException(path, asyncError.error), + asyncError.stackTrace); + } else { + assert(response["type"] == "success"); + subscription.cancel(); + completer.complete(response["tests"].map((test) { + var testMetadata = new Metadata.deserialize(test['metadata']); + return new RemoteTest(test['name'], testMetadata, socket, test['index']); + })); + } + }); + + Iterable entries = await completer.future; + + return new RunnerSuite( + const VMEnvironment(), + new Group.root(entries, metadata: metadata), + path: path, + platform: TestPlatform.vm, + os: currentOS, + onClose: () { process.kill(ProcessSignal.SIGTERM); } + ); +} diff --git a/packages/flutter_tools/lib/src/test/remote_listener.dart b/packages/flutter_tools/lib/src/test/remote_listener.dart new file mode 100644 index 00000000000..6f0e283c7ff --- /dev/null +++ b/packages/flutter_tools/lib/src/test/remote_listener.dart @@ -0,0 +1,164 @@ +// 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 'dart:isolate'; + +import 'package:stack_trace/stack_trace.dart'; +import 'package:test/src/backend/declarer.dart'; +import 'package:test/src/backend/live_test.dart'; +import 'package:test/src/backend/metadata.dart'; +import 'package:test/src/backend/operating_system.dart'; +import 'package:test/src/backend/suite.dart'; +import 'package:test/src/backend/test_platform.dart'; +import 'package:test/src/backend/test.dart'; +import 'package:test/src/util/remote_exception.dart'; + +final OperatingSystem currentOS = (() { + var name = Platform.operatingSystem; + var os = OperatingSystem.findByIoName(name); + if (os != null) return os; + + throw new UnsupportedError('Unsupported operating system "$name".'); +})(); + +typedef AsyncFunction(); + +class RemoteListener { + RemoteListener._(this._suite, this._socket); + + final Suite _suite; + final WebSocket _socket; + final Set _liveTests = new Set(); + + static Future start(String server, Metadata metadata, Function getMain()) async { + WebSocket socket = await WebSocket.connect(server); + // Capture any top-level errors (mostly lazy syntax errors, since other are + // caught below) and report them to the parent isolate. We set errors + // non-fatal because otherwise they'll be double-printed. + var errorPort = new ReceivePort(); + Isolate.current.setErrorsFatal(false); + Isolate.current.addErrorListener(errorPort.sendPort); + errorPort.listen((message) { + // Masquerade as an IsolateSpawnException because that's what this would + // be if the error had been detected statically. + var error = new IsolateSpawnException(message[0]); + var stackTrace = + message[1] == null ? new Trace([]) : new Trace.parse(message[1]); + socket.add(JSON.encode({ + "type": "error", + "error": RemoteException.serialize(error, stackTrace) + })); + }); + + var main; + try { + main = getMain(); + } on NoSuchMethodError catch (_) { + _sendLoadException(socket, "No top-level main() function defined."); + return; + } + + if (main is! Function) { + _sendLoadException(socket, "Top-level main getter is not a function."); + return; + } else if (main is! AsyncFunction) { + _sendLoadException( + socket, "Top-level main() function takes arguments."); + return; + } + + Declarer declarer = new Declarer(metadata); + try { + await runZoned(() => new Future.sync(main), zoneValues: { + #test.declarer: declarer + }, zoneSpecification: new ZoneSpecification(print: (_, __, ___, line) { + socket.add(JSON.encode({"type": "print", "line": line})); + })); + } catch (error, stackTrace) { + socket.add(JSON.encode({ + "type": "error", + "error": RemoteException.serialize(error, stackTrace) + })); + return; + } + + Suite suite = new Suite(declarer.build(), + platform: TestPlatform.vm, os: currentOS); + new RemoteListener._(suite, socket)._listen(); + } + + static void _sendLoadException(WebSocket socket, String message) { + socket.add(JSON.encode({"type": "loadException", "message": message})); + } + + void _send(data) { + _socket.add(JSON.encode(data)); + } + + void _listen() { + List tests = []; + for (var i = 0; i < _suite.group.entries.length; i++) { + // TODO(ianh): entries[] might return a Group instead of a Test. We don't + // currently support nested groups. + Test test = _suite.group.entries[i]; + tests.add({ + "name": test.name, + "metadata": test.metadata.serialize(), + "index": i, + }); + } + + _send({"type": "success", "tests": tests}); + _socket.listen(_handleCommand); + } + + void _handleCommand(String data) { + var message = JSON.decode(data); + if (message['command'] == 'run') { + // TODO(ianh): entries[] might return a Group instead of a Test. We don't + // currently support nested groups. + Test test = _suite.group.entries[message['index']]; + LiveTest liveTest = test.load(_suite); + _liveTests.add(liveTest); + + liveTest.onStateChange.listen((state) { + _send({ + "type": "state-change", + "status": state.status.name, + "result": state.result.name + }); + }); + + liveTest.onError.listen((asyncError) { + _send({ + "type": "error", + "error": RemoteException.serialize( + asyncError.error, + asyncError.stackTrace + ) + }); + }); + + liveTest.onPrint.listen((line) { + _send({"type": "print", "line": line}); + }); + + liveTest.run().then((_) { + _send({"type": "complete"}); + _liveTests.remove(liveTest); + }); + } else if (message['command'] == 'close') { + if (_liveTests.isNotEmpty) + print('closing with ${_liveTests.length} live tests'); + for (LiveTest liveTest in _liveTests) + liveTest.close(); + _liveTests.clear(); + } else { + print('remote_listener.dart: ignoring command "${message["command"]}" from test harness'); + } + } +} diff --git a/packages/flutter_tools/lib/src/test/remote_test.dart b/packages/flutter_tools/lib/src/test/remote_test.dart new file mode 100644 index 00000000000..d3abea34925 --- /dev/null +++ b/packages/flutter_tools/lib/src/test/remote_test.dart @@ -0,0 +1,95 @@ +// 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:stack_trace/stack_trace.dart'; +import 'package:test/src/backend/live_test.dart'; +import 'package:test/src/backend/live_test_controller.dart'; +import 'package:test/src/backend/metadata.dart'; +import 'package:test/src/backend/operating_system.dart'; +import 'package:test/src/backend/state.dart'; +import 'package:test/src/backend/suite.dart'; +import 'package:test/src/backend/test.dart'; +import 'package:test/src/backend/test_platform.dart'; +import 'package:test/src/util/remote_exception.dart'; + +import 'package:sky_tools/src/test/json_socket.dart'; + +class RemoteTest extends Test { + RemoteTest(this.name, this.metadata, this._socket, this._index); + + final String name; + final Metadata metadata; + final JSONSocket _socket; + final int _index; + + LiveTest load(Suite suite) { + LiveTestController controller; + StreamSubscription subscription; + + controller = new LiveTestController(suite, this, () async { + + controller.setState(const State(Status.running, Result.success)); + _socket.send({'command': 'run', 'index': _index}); + + subscription = _socket.stream.listen((message) { + if (message['type'] == 'error') { + AsyncError asyncError = RemoteException.deserialize(message['error']); + controller.addError(asyncError.error, asyncError.stackTrace); + } else if (message['type'] == 'state-change') { + controller.setState( + new State( + new Status.parse(message['status']), + new Result.parse(message['result']))); + } else if (message['type'] == 'print') { + controller.print(message['line']); + } else { + assert(message['type'] == 'complete'); + subscription.cancel(); + subscription = null; + controller.completer.complete(); + } + }); + + _socket.unusualTermination.then((String message) { + if (subscription != null) { + controller.print('Unexpected subprocess termination: $message'); + controller.addError(new Exception('Unexpected subprocess termination.'), new Trace.current()); + controller.setState(new State(Status.complete, Result.error)); + subscription.cancel(); + subscription = null; + controller.completer.complete(); + } + }); + + }, () async { + _socket.send({'command': 'close'}); + if (subscription != null) { + subscription.cancel(); + subscription = null; + } + }); + return controller.liveTest; + } + + Test change({String name, Metadata metadata}) { + if (name == name && metadata == this.metadata) return this; + if (name == null) name = this.name; + if (metadata == null) metadata = this.metadata; + return new RemoteTest(name, metadata, _socket, _index); + } + + // TODO(ianh): Implement this if we need it. + Test forPlatform(TestPlatform platform, {OperatingSystem os}) { + if (!metadata.testOn.evaluate(platform, os: os)) + return null; + return new RemoteTest( + name, + metadata.forPlatform(platform, os: os), + _socket, + _index + ); + } +} diff --git a/packages/flutter_tools/lib/src/toolchain.dart b/packages/flutter_tools/lib/src/toolchain.dart new file mode 100644 index 00000000000..34301ec9ae3 --- /dev/null +++ b/packages/flutter_tools/lib/src/toolchain.dart @@ -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:async'; + +import 'package:path/path.dart' as path; + +import 'artifacts.dart'; +import 'build_configuration.dart'; +import 'process.dart'; + +class Compiler { + Compiler(this._path); + + String _path; + + Future compile({ + String mainPath, + String snapshotPath + }) { + return runCommandAndStreamOutput([ + _path, + mainPath, + '--package-root=${ArtifactStore.packageRoot}', + '--snapshot=$snapshotPath' + ]); + } +} + +Future _getCompilerPath(BuildConfiguration config) async { + if (config.type != BuildType.prebuilt) + return path.join(config.buildDir, 'clang_x64', 'sky_snapshot'); + Artifact artifact = ArtifactStore.getArtifact( + type: ArtifactType.snapshot, hostPlatform: config.hostPlatform); + return await ArtifactStore.getPath(artifact); +} + +class Toolchain { + Toolchain({ this.compiler }); + + final Compiler compiler; + + static Future forConfigs(List configs) async { + // TODO(abarth): Shouldn't we consider all the configs? + String compilerPath = await _getCompilerPath(configs.first); + return new Toolchain(compiler: new Compiler(compilerPath)); + } +} diff --git a/packages/flutter_tools/pubspec.yaml b/packages/flutter_tools/pubspec.yaml new file mode 100644 index 00000000000..1ee0e876900 --- /dev/null +++ b/packages/flutter_tools/pubspec.yaml @@ -0,0 +1,38 @@ +name: sky_tools +version: 0.0.37 +description: Tools for building Flutter applications +homepage: http://flutter.io +author: Flutter Authors + +environment: + sdk: '>=1.12.0 <2.0.0' + +dependencies: + analyzer: ">=0.26.1+17" # see note below + archive: ^1.0.20 + args: ^0.13.0 + flx: ">=0.0.7 <0.1.0" + crypto: ^0.9.1 + mustache4dart: ^1.0.0 + path: ^1.3.0 + shelf_route: ^0.13.4 + shelf_static: ^0.2.3 + shelf: ^0.6.2 + stack_trace: ^1.4.0 + test: ^0.12.5 + yaml: ^2.1.3 + +# A note about 'analyzer': +# We don't actually depend on 'analyzer', but 'test' does. We aren't +# compatible with some older versions of 'analyzer'. We lie here, +# saying we do depend on it, so that we constrain the version that +# 'test' will get to a version that we'll probably be ok with. (This +# is why there's no upper bound on our dependency.) +# See also https://github.com/dart-lang/pub/issues/1356 + +dev_dependencies: + mockito: "^0.10.1" + +# Add the bin/sky_tools.dart script to the scripts pub installs. +executables: + sky_tools: diff --git a/packages/flutter_tools/test/all.dart b/packages/flutter_tools/test/all.dart new file mode 100644 index 00000000000..924f8485ffe --- /dev/null +++ b/packages/flutter_tools/test/all.dart @@ -0,0 +1,29 @@ +// 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 '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; +import 'list_test.dart' as list_test; +import 'logs_test.dart' as logs_test; +import 'os_utils_test.dart' as os_utils_test; +import 'start_test.dart' as start_test; +import 'stop_test.dart' as stop_test; +import 'trace_test.dart' as trace_test; + +main() { + android_device_test.defineTests(); + daemon_test.defineTests(); + init_test.defineTests(); + install_test.defineTests(); + listen_test.defineTests(); + list_test.defineTests(); + logs_test.defineTests(); + os_utils_test.defineTests(); + start_test.defineTests(); + stop_test.defineTests(); + trace_test.defineTests(); +} diff --git a/packages/flutter_tools/test/android_device_test.dart b/packages/flutter_tools/test/android_device_test.dart new file mode 100644 index 00000000000..9a153b6228d --- /dev/null +++ b/packages/flutter_tools/test/android_device_test.dart @@ -0,0 +1,30 @@ +// 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 'package:sky_tools/src/device.dart'; +import 'package:test/test.dart'; + +main() => defineTests(); + +defineTests() { + group('android_device', () { + test('uses the correct default ID', () { + AndroidDevice android = new AndroidDevice(); + expect(android.id, equals(AndroidDevice.defaultDeviceID)); + }); + + test('stores the requested id', () { + String deviceId = '1234'; + AndroidDevice android = new AndroidDevice(id: deviceId); + expect(android.id, equals(deviceId)); + }); + + test('correctly creates only one of each requested device id', () { + String deviceID = '1234'; + AndroidDevice a1 = new AndroidDevice(id: deviceID); + AndroidDevice a2 = new AndroidDevice(id: deviceID); + expect(a1, equals(a2)); + }); + }); +} diff --git a/packages/flutter_tools/test/daemon_test.dart b/packages/flutter_tools/test/daemon_test.dart new file mode 100644 index 00000000000..352bb71f91c --- /dev/null +++ b/packages/flutter_tools/test/daemon_test.dart @@ -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 commands = new StreamController(); + StreamController 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 commands = new StreamController(); + StreamController 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 commands = new StreamController(); + StreamController 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); + }); + }); +} diff --git a/packages/flutter_tools/test/init_test.dart b/packages/flutter_tools/test/init_test.dart new file mode 100644 index 00000000000..c14e4f8d4bf --- /dev/null +++ b/packages/flutter_tools/test/init_test.dart @@ -0,0 +1,53 @@ +// 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:io'; + +import 'package:args/command_runner.dart'; +import 'package:path/path.dart' as p; +import 'package:sky_tools/src/commands/init.dart'; +import 'package:sky_tools/src/process.dart'; +import 'package:test/test.dart'; + +main() => defineTests(); + +defineTests() { + group('init', () { + Directory temp; + + setUp(() { + temp = Directory.systemTemp.createTempSync('sky_tools'); + }); + + tearDown(() { + temp.deleteSync(recursive: true); + }); + + // This test consistently times out on our windows bot. The code is already + // covered on the linux one. + if (!Platform.isWindows) { + // Verify that we create a project that is well-formed. + test('flutter-simple', () async { + InitCommand command = new InitCommand(); + CommandRunner runner = new CommandRunner('test_flutter', '') + ..addCommand(command); + await runner.run(['init', '--out', temp.path]) + .then((int code) => expect(code, equals(0))); + + String path = p.join(temp.path, 'lib', 'main.dart'); + expect(new File(path).existsSync(), true); + ProcessResult exec = Process.runSync( + sdkBinaryName('dartanalyzer'), ['--fatal-warnings', path], + workingDirectory: temp.path); + if (exec.exitCode != 0) { + print(exec.stdout); + print(exec.stderr); + } + expect(exec.exitCode, 0); + }, + // This test can take a while due to network requests. + timeout: new Timeout(new Duration(minutes: 2))); + } + }); +} diff --git a/packages/flutter_tools/test/install_test.dart b/packages/flutter_tools/test/install_test.dart new file mode 100644 index 00000000000..d10c652746c --- /dev/null +++ b/packages/flutter_tools/test/install_test.dart @@ -0,0 +1,61 @@ +// 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 'package:args/command_runner.dart'; +import 'package:mockito/mockito.dart'; +import 'package:sky_tools/src/commands/install.dart'; +import 'package:test/test.dart'; + +import 'src/mocks.dart'; + +main() => defineTests(); + +defineTests() { + group('install', () { + test('returns 0 when Android is connected and ready for an install', () { + InstallCommand command = new InstallCommand(); + applyMocksToCommand(command); + MockDeviceStore mockDevices = command.devices; + + when(mockDevices.android.isConnected()).thenReturn(true); + when(mockDevices.android.isAppInstalled(any)).thenReturn(false); + when(mockDevices.android.installApp(any)).thenReturn(true); + + when(mockDevices.iOS.isConnected()).thenReturn(false); + when(mockDevices.iOS.isAppInstalled(any)).thenReturn(false); + when(mockDevices.iOS.installApp(any)).thenReturn(false); + + when(mockDevices.iOSSimulator.isConnected()).thenReturn(false); + when(mockDevices.iOSSimulator.isAppInstalled(any)).thenReturn(false); + when(mockDevices.iOSSimulator.installApp(any)).thenReturn(false); + + + CommandRunner runner = new CommandRunner('test_flutter', '') + ..addCommand(command); + runner.run(['install']).then((int code) => expect(code, equals(0))); + }); + + test('returns 0 when iOS is connected and ready for an install', () { + InstallCommand command = new InstallCommand(); + applyMocksToCommand(command); + MockDeviceStore mockDevices = command.devices; + + when(mockDevices.android.isConnected()).thenReturn(false); + when(mockDevices.android.isAppInstalled(any)).thenReturn(false); + when(mockDevices.android.installApp(any)).thenReturn(false); + + when(mockDevices.iOS.isConnected()).thenReturn(true); + when(mockDevices.iOS.isAppInstalled(any)).thenReturn(false); + when(mockDevices.iOS.installApp(any)).thenReturn(true); + + when(mockDevices.iOSSimulator.isConnected()).thenReturn(false); + when(mockDevices.iOSSimulator.isAppInstalled(any)).thenReturn(false); + when(mockDevices.iOSSimulator.installApp(any)).thenReturn(false); + + CommandRunner runner = new CommandRunner('test_flutter', '') + ..addCommand(command); + runner.run(['install']).then((int code) => expect(code, equals(0))); + }); + }); +} diff --git a/packages/flutter_tools/test/list_test.dart b/packages/flutter_tools/test/list_test.dart new file mode 100644 index 00000000000..c2fec2183a2 --- /dev/null +++ b/packages/flutter_tools/test/list_test.dart @@ -0,0 +1,44 @@ +// 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:io'; + +import 'package:args/command_runner.dart'; +import 'package:mockito/mockito.dart'; +import 'package:sky_tools/src/commands/list.dart'; +import 'package:test/test.dart'; + +import 'src/mocks.dart'; + +main() => defineTests(); + +defineTests() { + group('list', () { + test('returns 0 when called', () { + final String mockCommand = Platform.isWindows ? 'cmd /c echo' : 'echo'; + + ListCommand command = new ListCommand(); + applyMocksToCommand(command); + MockDeviceStore mockDevices = command.devices; + + // Avoid relying on adb being installed on the test system. + // Instead, cause the test to run the echo command. + when(mockDevices.android.adbPath).thenReturn(mockCommand); + + // Avoid relying on idevice* being installed on the test system. + // Instead, cause the test to run the echo command. + when(mockDevices.iOS.informerPath).thenReturn(mockCommand); + when(mockDevices.iOS.installerPath).thenReturn(mockCommand); + when(mockDevices.iOS.listerPath).thenReturn(mockCommand); + + // Avoid relying on xcrun being installed on the test system. + // 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))); + }); + }); +} diff --git a/packages/flutter_tools/test/listen_test.dart b/packages/flutter_tools/test/listen_test.dart new file mode 100644 index 00000000000..7899a9103d2 --- /dev/null +++ b/packages/flutter_tools/test/listen_test.dart @@ -0,0 +1,30 @@ +// 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 'package:args/command_runner.dart'; +import 'package:mockito/mockito.dart'; +import 'package:sky_tools/src/commands/listen.dart'; +import 'package:test/test.dart'; + +import 'src/mocks.dart'; + +main() => defineTests(); + +defineTests() { + group('listen', () { + test('returns 0 when no device is connected', () { + ListenCommand command = new ListenCommand(singleRun: true); + applyMocksToCommand(command); + MockDeviceStore mockDevices = command.devices; + + when(mockDevices.android.isConnected()).thenReturn(false); + when(mockDevices.iOS.isConnected()).thenReturn(false); + when(mockDevices.iOSSimulator.isConnected()).thenReturn(false); + + CommandRunner runner = new CommandRunner('test_flutter', '') + ..addCommand(command); + runner.run(['listen']).then((int code) => expect(code, equals(0))); + }); + }); +} diff --git a/packages/flutter_tools/test/logs_test.dart b/packages/flutter_tools/test/logs_test.dart new file mode 100644 index 00000000000..e43bf5cda80 --- /dev/null +++ b/packages/flutter_tools/test/logs_test.dart @@ -0,0 +1,30 @@ +// 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 'package:args/command_runner.dart'; +import 'package:mockito/mockito.dart'; +import 'package:sky_tools/src/commands/logs.dart'; +import 'package:test/test.dart'; + +import 'src/mocks.dart'; + +main() => defineTests(); + +defineTests() { + group('logs', () { + test('returns 0 when no device is connected', () { + LogsCommand command = new LogsCommand(); + applyMocksToCommand(command); + MockDeviceStore mockDevices = command.devices; + + when(mockDevices.android.isConnected()).thenReturn(false); + when(mockDevices.iOS.isConnected()).thenReturn(false); + when(mockDevices.iOSSimulator.isConnected()).thenReturn(false); + + CommandRunner runner = new CommandRunner('test_flutter', '') + ..addCommand(command); + runner.run(['logs']).then((int code) => expect(code, equals(0))); + }); + }); +} diff --git a/packages/flutter_tools/test/os_utils_test.dart b/packages/flutter_tools/test/os_utils_test.dart new file mode 100644 index 00000000000..999fc9cc5a8 --- /dev/null +++ b/packages/flutter_tools/test/os_utils_test.dart @@ -0,0 +1,66 @@ +// 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:io'; + +import 'package:sky_tools/src/os_utils.dart'; +import 'package:test/test.dart'; +import 'package:path/path.dart' as p; + +main() => defineTests(); + +defineTests() { + group('OperatingSystemUtils', () { + Directory temp; + + setUp(() { + temp = Directory.systemTemp.createTempSync('sky_tools'); + }); + + tearDown(() { + temp.deleteSync(recursive: true); + }); + + test('makeExecutable', () { + File file = new File(p.join(temp.path, 'foo.script')); + file.writeAsStringSync('hello world'); + osUtils.makeExecutable(file); + + // Skip this test on windows. + if (!Platform.isWindows) { + String mode = file.statSync().modeString(); + // rwxr--r-- + expect(mode.substring(0, 3), endsWith('x')); + } + }); + + /// Start a script listening on a port, try and kill that process. + test('killTcpPortListeners', () async { + final int port = 40170; + + File file = new File(p.join(temp.path, 'script.dart')); + file.writeAsStringSync(''' +import 'dart:io'; + +void main() async { + ServerSocket serverSocket = await ServerSocket.bind( + InternetAddress.LOOPBACK_IP_V4, ${port}); + // wait... + print('listening on port ${port}...'); +} +'''); + Process process = await Process.start('dart', [file.path]); + await process.stdout.first; + + osUtils.killTcpPortListeners(40170); + int exitCode = await process.exitCode; + expect(exitCode, isNot(equals(0))); + }); + + /// Try and kill with a port that no process is listening to. + test('killTcpPortListeners none', () { + osUtils.killTcpPortListeners(40171); + }); + }); +} diff --git a/packages/flutter_tools/test/src/mocks.dart b/packages/flutter_tools/test/src/mocks.dart new file mode 100644 index 00000000000..94466d49845 --- /dev/null +++ b/packages/flutter_tools/test/src/mocks.dart @@ -0,0 +1,61 @@ +// 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 'package:mockito/mockito.dart'; +import 'package:sky_tools/src/application_package.dart'; +import 'package:sky_tools/src/build_configuration.dart'; +import 'package:sky_tools/src/commands/flutter_command.dart'; +import 'package:sky_tools/src/device.dart'; +import 'package:sky_tools/src/toolchain.dart'; + +class MockApplicationPackageStore extends ApplicationPackageStore { + MockApplicationPackageStore() : super( + android: new AndroidApk(localPath: '/mock/path/to/android/SkyShell.apk'), + iOS: new IOSApp(localPath: '/mock/path/to/iOS/SkyShell.app'), + iOSSimulator: new IOSApp(localPath: '/mock/path/to/iOSSimulator/SkyShell.app')); +} + +class MockCompiler extends Mock implements Compiler { + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class MockToolchain extends Toolchain { + MockToolchain() : super(compiler: new MockCompiler()); +} + +class MockAndroidDevice extends Mock implements AndroidDevice { + TargetPlatform get platform => TargetPlatform.android; + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class MockIOSDevice extends Mock implements IOSDevice { + TargetPlatform get platform => TargetPlatform.iOS; + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class MockIOSSimulator extends Mock implements IOSSimulator { + TargetPlatform get platform => TargetPlatform.iOSSimulator; + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class MockDeviceStore extends DeviceStore { + MockDeviceStore() : super( + android: new MockAndroidDevice(), + iOS: new MockIOSDevice(), + iOSSimulator: new MockIOSSimulator()); +} + +void applyMocksToCommand(FlutterCommand command) { + command + ..applicationPackages = new MockApplicationPackageStore() + ..toolchain = new MockToolchain() + ..devices = new MockDeviceStore(); +} diff --git a/packages/flutter_tools/test/start_test.dart b/packages/flutter_tools/test/start_test.dart new file mode 100644 index 00000000000..f3868ccffbb --- /dev/null +++ b/packages/flutter_tools/test/start_test.dart @@ -0,0 +1,72 @@ +// 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 'package:args/command_runner.dart'; +import 'package:mockito/mockito.dart'; +import 'package:sky_tools/src/commands/start.dart'; +import 'package:test/test.dart'; + +import 'src/mocks.dart'; + +main() => defineTests(); + +defineTests() { + group('start', () { + test('returns 0 when Android is connected and ready to be started', () { + StartCommand command = new StartCommand(); + applyMocksToCommand(command); + MockDeviceStore mockDevices = command.devices; + + when(mockDevices.android.isConnected()).thenReturn(true); + when(mockDevices.android.isAppInstalled(any)).thenReturn(false); + when(mockDevices.android.installApp(any)).thenReturn(true); + when(mockDevices.android.startBundle(any, any, any, any)).thenReturn(true); + when(mockDevices.android.stopApp(any)).thenReturn(true); + + when(mockDevices.iOS.isConnected()).thenReturn(false); + when(mockDevices.iOS.isAppInstalled(any)).thenReturn(false); + when(mockDevices.iOS.installApp(any)).thenReturn(false); + when(mockDevices.iOS.startApp(any)).thenReturn(false); + when(mockDevices.iOS.stopApp(any)).thenReturn(false); + + when(mockDevices.iOSSimulator.isConnected()).thenReturn(false); + when(mockDevices.iOSSimulator.isAppInstalled(any)).thenReturn(false); + when(mockDevices.iOSSimulator.installApp(any)).thenReturn(false); + when(mockDevices.iOSSimulator.startApp(any)).thenReturn(false); + when(mockDevices.iOSSimulator.stopApp(any)).thenReturn(false); + + CommandRunner runner = new CommandRunner('test_flutter', '') + ..addCommand(command); + runner.run(['start']).then((int code) => expect(code, equals(0))); + }); + + test('returns 0 when iOS is connected and ready to be started', () { + StartCommand command = new StartCommand(); + applyMocksToCommand(command); + MockDeviceStore mockDevices = command.devices; + + when(mockDevices.android.isConnected()).thenReturn(false); + when(mockDevices.android.isAppInstalled(any)).thenReturn(false); + when(mockDevices.android.installApp(any)).thenReturn(false); + when(mockDevices.android.startBundle(any, any, any, any)).thenReturn(false); + when(mockDevices.android.stopApp(any)).thenReturn(false); + + when(mockDevices.iOS.isConnected()).thenReturn(true); + when(mockDevices.iOS.isAppInstalled(any)).thenReturn(false); + when(mockDevices.iOS.installApp(any)).thenReturn(true); + when(mockDevices.iOS.startApp(any)).thenReturn(true); + when(mockDevices.iOS.stopApp(any)).thenReturn(false); + + when(mockDevices.iOSSimulator.isConnected()).thenReturn(false); + when(mockDevices.iOSSimulator.isAppInstalled(any)).thenReturn(false); + when(mockDevices.iOSSimulator.installApp(any)).thenReturn(false); + when(mockDevices.iOSSimulator.startApp(any)).thenReturn(false); + when(mockDevices.iOSSimulator.stopApp(any)).thenReturn(false); + + CommandRunner runner = new CommandRunner('test_flutter', '') + ..addCommand(command); + runner.run(['start']).then((int code) => expect(code, equals(0))); + }); + }); +} diff --git a/packages/flutter_tools/test/stop_test.dart b/packages/flutter_tools/test/stop_test.dart new file mode 100644 index 00000000000..53da532aa5c --- /dev/null +++ b/packages/flutter_tools/test/stop_test.dart @@ -0,0 +1,54 @@ +// 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 'package:args/command_runner.dart'; +import 'package:mockito/mockito.dart'; +import 'package:sky_tools/src/commands/stop.dart'; +import 'package:test/test.dart'; + +import 'src/mocks.dart'; + +main() => defineTests(); + +defineTests() { + group('stop', () { + test('returns 0 when Android is connected and ready to be stopped', () { + StopCommand command = new StopCommand(); + applyMocksToCommand(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); + + CommandRunner runner = new CommandRunner('test_flutter', '') + ..addCommand(command); + runner.run(['stop']).then((int code) => expect(code, equals(0))); + }); + + test('returns 0 when iOS is connected and ready to be stopped', () { + StopCommand command = new StopCommand(); + applyMocksToCommand(command); + MockDeviceStore mockDevices = command.devices; + + when(mockDevices.android.isConnected()).thenReturn(false); + when(mockDevices.android.stopApp(any)).thenReturn(false); + + when(mockDevices.iOS.isConnected()).thenReturn(true); + when(mockDevices.iOS.stopApp(any)).thenReturn(true); + + when(mockDevices.iOSSimulator.isConnected()).thenReturn(false); + when(mockDevices.iOSSimulator.stopApp(any)).thenReturn(false); + + CommandRunner runner = new CommandRunner('test_flutter', '') + ..addCommand(command); + runner.run(['stop']).then((int code) => expect(code, equals(0))); + }); + }); +} diff --git a/packages/flutter_tools/test/trace_test.dart b/packages/flutter_tools/test/trace_test.dart new file mode 100644 index 00000000000..83f5a1b18fb --- /dev/null +++ b/packages/flutter_tools/test/trace_test.dart @@ -0,0 +1,28 @@ +// 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 'package:args/command_runner.dart'; +import 'package:mockito/mockito.dart'; +import 'package:sky_tools/src/commands/trace.dart'; +import 'package:test/test.dart'; + +import 'src/mocks.dart'; + +main() => defineTests(); + +defineTests() { + group('trace', () { + test('returns 1 when no Android device is connected', () { + TraceCommand command = new TraceCommand(); + applyMocksToCommand(command); + MockDeviceStore mockDevices = command.devices; + + when(mockDevices.android.isConnected()).thenReturn(false); + + CommandRunner runner = new CommandRunner('test_flutter', '') + ..addCommand(command); + runner.run(['trace']).then((int code) => expect(code, equals(1))); + }); + }); +} diff --git a/packages/flutter_tools/tool/daemon_client.dart b/packages/flutter_tools/tool/daemon_client.dart new file mode 100644 index 00000000000..bb21f2008e0 --- /dev/null +++ b/packages/flutter_tools/tool/daemon_client.dart @@ -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}'); +} diff --git a/packages/flutter_tools/tool/travis.sh b/packages/flutter_tools/tool/travis.sh new file mode 100755 index 00000000000..ed16ec2999e --- /dev/null +++ b/packages/flutter_tools/tool/travis.sh @@ -0,0 +1,18 @@ +#!/bin/bash + +# 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. + +# Fast fail the script on failures. +set -e + +# Fetch all our dependencies +pub get + +# Verify that the libraries are error free. +pub global activate tuneup +pub global run tuneup check + +# And run our tests. +pub run test -j1 diff --git a/travis/setup.sh b/travis/setup.sh index f135a269407..840196f80c0 100755 --- a/travis/setup.sh +++ b/travis/setup.sh @@ -3,5 +3,6 @@ set -ex (cd packages/cassowary; pub get) (cd packages/newton; pub get) +(cd packages/flutter_tools; pub get) pub global activate tuneup diff --git a/travis/test.sh b/travis/test.sh index 134c465c1aa..d819706b354 100755 --- a/travis/test.sh +++ b/travis/test.sh @@ -3,3 +3,4 @@ set -ex (cd packages/cassowary; pub global run tuneup check; pub run test -j1) (cd packages/newton; pub global run tuneup check; pub run test -j1) +(cd packages/flutter_tools; pub global run tuneup check; pub run test -j1)