resident_web_runner.dart 22.6 KB
Newer Older
Ian Hickson's avatar
Ian Hickson committed
1
// Copyright 2014 The Flutter Authors. All rights reserved.
2 3 4 5 6
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import 'dart:async';

7
// ignore: import_of_legacy_library_into_null_safe
8
import 'package:dwds/dwds.dart';
9
import 'package:package_config/package_config.dart';
10
import 'package:vm_service/vm_service.dart' as vmservice;
11 12
import 'package:webkit_inspection_protocol/webkit_inspection_protocol.dart'
    hide StackTrace;
13

14
import '../application_package.dart';
15
import '../base/async_guard.dart';
16
import '../base/common.dart';
17
import '../base/file_system.dart';
18
import '../base/io.dart';
19
import '../base/logger.dart';
20
import '../base/net.dart';
21
import '../base/terminal.dart';
22
import '../base/time.dart';
23 24
import '../base/utils.dart';
import '../build_info.dart';
25
import '../build_system/targets/web.dart';
26
import '../cache.dart';
27
import '../dart/language_version.dart';
28
import '../devfs.dart';
29
import '../device.dart';
30
import '../flutter_plugins.dart';
31
import '../project.dart';
32
import '../reporting/reporting.dart';
33
import '../resident_devtools_handler.dart';
34
import '../resident_runner.dart';
35
import '../run_hot.dart';
36
import '../vmservice.dart';
37
import '../web/chrome.dart';
38
import '../web/compile.dart';
39
import '../web/file_generators/main_dart.dart' as main_dart;
40
import '../web/web_device.dart';
41
import '../web/web_runner.dart';
42
import 'devfs_web.dart';
43 44 45 46 47

/// Injectable factory to create a [ResidentWebRunner].
class DwdsWebRunnerFactory extends WebRunnerFactory {
  @override
  ResidentRunner createWebRunner(
48
    FlutterDevice device, {
49 50 51 52 53 54 55 56 57 58
    String? target,
    required bool stayResident,
    required FlutterProject flutterProject,
    required bool? ipv6,
    required DebuggingOptions debuggingOptions,
    required UrlTunneller? urlTunneller,
    required Logger? logger,
    required FileSystem fileSystem,
    required SystemClock systemClock,
    required Usage usage,
59
    bool machine = false,
60
  }) {
61
    return ResidentWebRunner(
62 63 64 65 66
      device,
      target: target,
      flutterProject: flutterProject,
      debuggingOptions: debuggingOptions,
      ipv6: ipv6,
67
      stayResident: stayResident,
68
      urlTunneller: urlTunneller,
69
      machine: machine,
70 71 72 73
      usage: usage,
      systemClock: systemClock,
      fileSystem: fileSystem,
      logger: logger,
74 75 76
    );
  }
}
77

78
const String kExitMessage = 'Failed to establish connection with the application '
79 80 81
  'instance in Chrome.\nThis can happen if the websocket connection used by the '
  'web tooling is unable to correctly establish a connection, for example due to a firewall.';

82
class ResidentWebRunner extends ResidentRunner {
83
  ResidentWebRunner(
84
    FlutterDevice device, {
85
    String? target,
86 87
    bool stayResident = true,
    bool machine = false,
88 89 90
    required this.flutterProject,
    required bool? ipv6,
    required DebuggingOptions debuggingOptions,
91
    required FileSystem fileSystem,
92 93 94 95
    required Logger? logger,
    required SystemClock systemClock,
    required Usage usage,
    UrlTunneller? urlTunneller,
96
    ResidentDevtoolsHandlerFactory devtoolsHandler = createDefaultHandler,
97 98 99 100 101 102
  }) : _fileSystem = fileSystem,
       _logger = logger,
       _systemClock = systemClock,
       _usage = usage,
       _urlTunneller = urlTunneller,
       super(
103 104
          <FlutterDevice>[device],
          target: target ?? fileSystem.path.join('lib', 'main.dart'),
105
          debuggingOptions: debuggingOptions,
106
          ipv6: ipv6,
107
          stayResident: stayResident,
108
          machine: machine,
109
          devtoolsHandler: devtoolsHandler,
110 111
        );

112
  final FileSystem _fileSystem;
113
  final Logger? _logger;
114 115
  final SystemClock _systemClock;
  final Usage _usage;
116
  final UrlTunneller? _urlTunneller;
117

118
  @override
119
  Logger? get logger => _logger;
120 121

  @override
122
  FileSystem get fileSystem => _fileSystem;
123

124
  FlutterDevice? get device => flutterDevices.first;
125
  final FlutterProject flutterProject;
126
  DateTime? firstBuildTime;
127

Dan Field's avatar
Dan Field committed
128 129
  // Used with the new compiler to generate a bootstrap file containing plugins
  // and platform initialization.
130
  Directory? _generatedEntrypointDirectory;
Dan Field's avatar
Dan Field committed
131

132 133
  // Only the debug builds of the web support the service protocol.
  @override
134
  bool get supportsServiceProtocol => isRunningDebug && deviceIsDebuggable;
135 136

  @override
137 138 139
  bool get debuggingEnabled => isRunningDebug && deviceIsDebuggable;

  /// WebServer device is debuggable when running with --start-paused.
140
  bool get deviceIsDebuggable => device!.device is! WebServerDevice || debuggingOptions.startPaused;
141

142 143 144
  @override
  bool get supportsWriteSkSL => false;

145 146 147 148
  @override
  // Web uses a different plugin registry.
  bool get generateDartPluginRegistry => false;

149
  bool get _enableDwds => debuggingEnabled;
150

151 152 153 154
  ConnectionResult? _connectionResult;
  StreamSubscription<vmservice.Event>? _stdOutSub;
  StreamSubscription<vmservice.Event>? _stdErrSub;
  StreamSubscription<vmservice.Event>? _extensionEventSub;
155
  bool _exited = false;
156 157
  WipConnection? _wipConnection;
  ChromiumLauncher? _chromiumLauncher;
158

159 160
  FlutterVmService get _vmService {
    if (_instance != null) {
161
      return _instance!;
162
    }
163 164
    final vmservice.VmService? service =_connectionResult?.vmService;
    final Uri websocketUri = Uri.parse(_connectionResult!.debugConnection!.uri);
165
    final Uri httpUri = _httpUriFromWebsocketUri(websocketUri);
166
    return _instance ??= FlutterVmService(service!, wsAddress: websocketUri, httpAddress: httpUri);
167
  }
168
  FlutterVmService? _instance;
169

170
  @override
171
  Future<void> cleanupAfterSignal() async {
172
    await _cleanup();
173 174 175
  }

  @override
176
  Future<void> cleanupAtFinish() async {
177 178 179 180
    await _cleanup();
  }

  Future<void> _cleanup() async {
181 182 183
    if (_exited) {
      return;
    }
184
    await residentDevtoolsHandler!.shutdown();
185
    await _stdOutSub?.cancel();
186
    await _stdErrSub?.cancel();
187
    await _extensionEventSub?.cancel();
188
    await device!.device!.stopApp(null);
189 190 191 192
    try {
      _generatedEntrypointDirectory?.deleteSync(recursive: true);
    } on FileSystemException {
      // Best effort to clean up temp dirs.
193 194
      _logger!.printTrace(
        'Failed to clean up temp directory: ${_generatedEntrypointDirectory!.path}',
195 196
      );
    }
197
    _exited = true;
198 199
  }

200 201 202 203 204
  Future<void> _cleanupAndExit() async {
    await _cleanup();
    appFinished();
  }

205
  @override
206 207 208 209
  void printHelp({bool details = true}) {
    if (details) {
      return printHelpDetails();
    }
210
    const String fire = '🔥';
211
    const String rawMessage =
212
        '  To hot restart changes while running, press "r" or "R".';
213 214
    final String message = _logger!.terminal.color(
      fire + _logger!.terminal.bolden(rawMessage),
215 216
      TerminalColor.red,
    );
217
    _logger!.printStatus(message);
218
    const String quitMessage = 'To quit, press "q".';
219 220
    _logger!.printStatus('For a more detailed help message, press "h". $quitMessage');
    _logger!.printStatus('');
221
    printDebuggerList();
222 223
  }

224 225 226
  @override
  Future<void> stopEchoingDeviceLog() async {
    // Do nothing for ResidentWebRunner
227
    await device!.stopEchoingDeviceLog();
228 229
  }

230 231
  @override
  Future<int> run({
232 233
    Completer<DebugConnectionInfo>? connectionInfoCompleter,
    Completer<void>? appStartedCompleter,
234
    bool enableDevTools = false, // ignored, we don't yet support devtools for web
235
    String? route,
236
  }) async {
237
    firstBuildTime = DateTime.now();
238
    final ApplicationPackage? package = await ApplicationPackageFactory.instance!.getPackageForPlatform(
239
      TargetPlatform.web_javascript,
240
      buildInfo: debuggingOptions.buildInfo,
241 242
    );
    if (package == null) {
243 244
      _logger!.printStatus('This application is not configured to build on the web.');
      _logger!.printStatus('To add web support to a project, run `flutter create .`.');
245
    }
246
    final String modeName = debuggingOptions.buildInfo.friendlyModeName;
247
    _logger!.printStatus(
248
      'Launching ${getDisplayPath(target, _fileSystem)} '
249
      'on ${device!.device!.name} in $modeName mode...',
250
    );
251 252
    if (device!.device is ChromiumDevice) {
      _chromiumLauncher = (device!.device! as ChromiumDevice).chromeLauncher;
253 254
    }

255 256
    try {
      return await asyncGuard(() async {
257
        final ExpressionCompiler? expressionCompiler =
258
          debuggingOptions.webEnableExpressionEvaluation
259
              ? WebExpressionCompiler(device!.generator!, fileSystem: _fileSystem)
260
              : null;
261
        device!.devFS = WebDevFS(
262 263
          hostname: debuggingOptions.hostname ?? 'localhost',
          port: debuggingOptions.port != null
264
            ? int.tryParse(debuggingOptions.port!)
265
            : null,
266
          packagesFilePath: packagesFilePath,
267
          urlTunneller: _urlTunneller,
268
          useSseForDebugProxy: debuggingOptions.webUseSseForDebugProxy,
269
          useSseForDebugBackend: debuggingOptions.webUseSseForDebugBackend,
270
          useSseForInjectedClient: debuggingOptions.webUseSseForInjectedClient,
271
          buildInfo: debuggingOptions.buildInfo,
272
          enableDwds: _enableDwds,
273
          enableDds: debuggingOptions.enableDds,
274
          entrypoint: _fileSystem.file(target).uri,
275
          expressionCompiler: expressionCompiler,
276
          chromiumLauncher: _chromiumLauncher,
277
          nullAssertions: debuggingOptions.nullAssertions,
278
          nullSafetyMode: debuggingOptions.buildInfo.nullSafetyMode,
279
          nativeNullAssertions: debuggingOptions.nativeNullAssertions,
280
        );
281
        final Uri url = await device!.devFS!.create();
282
        if (debuggingOptions.buildInfo.isDebug) {
283
          await runSourceGenerators();
284 285
          final UpdateFSReport report = await _updateDevFS(fullRestart: true);
          if (!report.success) {
286
            _logger!.printError('Failed to compile application.');
287
            appFailedToStart();
288 289
            return 1;
          }
290
          device!.generator!.accept();
291
          cacheInitialDillCompilation();
292 293 294 295 296 297
        } else {
          await buildWeb(
            flutterProject,
            target,
            debuggingOptions.buildInfo,
            false,
298
            kNoneWorker,
299
            true,
300
            debuggingOptions.nativeNullAssertions,
301
            null,
302
            null,
303 304
          );
        }
305
        await device!.device!.startApp(
306 307 308 309 310 311 312 313 314 315
          package,
          mainPath: target,
          debuggingOptions: debuggingOptions,
          platformArgs: <String, Object>{
            'uri': url.toString(),
          },
        );
        return attach(
          connectionInfoCompleter: connectionInfoCompleter,
          appStartedCompleter: appStartedCompleter,
316
          enableDevTools: enableDevTools,
317 318
        );
      });
319
    } on WebSocketException catch (error, stackTrace) {
320
      appFailedToStart();
321
      _logger!.printError('$error', stackTrace: stackTrace);
322
      throwToolExit(kExitMessage);
323
    } on ChromeDebugException catch (error, stackTrace) {
324
      appFailedToStart();
325
      _logger!.printError('$error', stackTrace: stackTrace);
326
      throwToolExit(kExitMessage);
327
    } on AppConnectionException catch (error, stackTrace) {
328
      appFailedToStart();
329
      _logger!.printError('$error', stackTrace: stackTrace);
330
      throwToolExit(kExitMessage);
331
    } on SocketException catch (error, stackTrace) {
332
      appFailedToStart();
333
      _logger!.printError('$error', stackTrace: stackTrace);
334
      throwToolExit(kExitMessage);
335 336 337
    } on Exception {
      appFailedToStart();
      rethrow;
338
    }
339 340 341 342 343
  }

  @override
  Future<OperationResult> restart({
    bool fullRestart = false,
344 345
    bool? pause = false,
    String? reason,
346 347
    bool benchmarkMode = false,
  }) async {
348
    final DateTime start = _systemClock.now();
349
    final Status status = _logger!.startProgress(
350 351 352 353
      'Performing hot restart...',
      progressId: 'hot.restart',
    );

354
    if (debuggingOptions.buildInfo.isDebug) {
355
      await runSourceGenerators();
356
      // Full restart is always false for web, since the extra recompile is wasteful.
357
      final UpdateFSReport report = await _updateDevFS();
358
      if (report.success) {
359
        device!.generator!.accept();
360 361
      } else {
        status.stop();
362
        await device!.generator!.reject();
363 364
        return OperationResult(1, 'Failed to recompile application.');
      }
365
    } else {
366 367 368 369 370 371
      try {
        await buildWeb(
          flutterProject,
          target,
          debuggingOptions.buildInfo,
          false,
372
          kNoneWorker,
373
          true,
374
          debuggingOptions.nativeNullAssertions,
375
          kBaseHref,
376
          null,
377 378 379 380
        );
      } on ToolExit {
        return OperationResult(1, 'Failed to recompile application.');
      }
381 382 383
    }

    try {
384
      if (!deviceIsDebuggable) {
385
        _logger!.printStatus('Recompile complete. Page requires refresh.');
386
      } else if (isRunningDebug) {
387
        await _vmService.service.callMethod('hotRestart');
388
      } else {
389 390 391 392 393
        // On non-debug builds, a hard refresh is required to ensure the
        // up to date sources are loaded.
        await _wipConnection?.sendCommand('Page.reload', <String, Object>{
          'ignoreCache': !debuggingOptions.buildInfo.isDebug,
        });
394
      }
395 396
    } on Exception catch (err) {
      return OperationResult(1, err.toString(), fatal: true);
397 398 399
    } finally {
      status.stop();
    }
400

401
    final Duration elapsed = _systemClock.now().difference(start);
402
    final String elapsedMS = getElapsedAsMilliseconds(elapsed);
403 404
    _logger!.printStatus('Restarted application in $elapsedMS.');
    unawaited(residentDevtoolsHandler!.hotRestart(flutterDevices));
405 406 407

    // Don't track restart times for dart2js builds or web-server devices.
    if (debuggingOptions.buildInfo.isDebug && deviceIsDebuggable) {
408
      _usage.sendTiming('hot', 'web-incremental-restart', elapsed);
409 410 411
      HotEvent(
        'restart',
        targetPlatform: getNameForTargetPlatform(TargetPlatform.web_javascript),
412
        sdkName: await device!.device!.sdkNameAndVersion,
413 414 415
        emulator: false,
        fullRestart: true,
        reason: reason,
416
        overallTimeInMs: elapsed.inMilliseconds,
417
        fastReassemble: false,
418
      ).send();
419 420 421 422
    }
    return OperationResult.ok;
  }

Dan Field's avatar
Dan Field committed
423 424 425
  // Flutter web projects need to include a generated main entrypoint to call the
  // appropriate bootstrap method and inject plugins.
  // Keep this in sync with build_system/targets/web.dart.
426 427
  Future<Uri> _generateEntrypoint(Uri mainUri, PackageConfig? packageConfig) async {
    File? result = _generatedEntrypointDirectory?.childFile('web_entrypoint.dart');
Dan Field's avatar
Dan Field committed
428
    if (_generatedEntrypointDirectory == null) {
429
      _generatedEntrypointDirectory ??= _fileSystem.systemTempDirectory.createTempSync('flutter_tools.')
Dan Field's avatar
Dan Field committed
430
        ..createSync();
431
      result = _generatedEntrypointDirectory!.childFile('web_entrypoint.dart');
Dan Field's avatar
Dan Field committed
432

433
      // Generates the generated_plugin_registrar
434
      await injectBuildTimePluginFiles(flutterProject, webPlatform: true, destination: _generatedEntrypointDirectory!);
435 436
      // The below works because `injectBuildTimePluginFiles` is configured to write
      // the web_plugin_registrant.dart file alongside the generated main.dart
437
      const String generatedImport = 'web_plugin_registrant.dart';
Dan Field's avatar
Dan Field committed
438

439
      Uri? importedEntrypoint = packageConfig!.toPackageUri(mainUri);
440 441
      // Special handling for entrypoints that are not under lib, such as test scripts.
      if (importedEntrypoint == null) {
442 443 444 445
        final String parent = _fileSystem.file(mainUri).parent.path;
        flutterDevices.first.generator!
          ..addFileSystemRoot(parent)
          ..addFileSystemRoot(_fileSystem.directory('test').absolute.path);
446 447
        importedEntrypoint = Uri(
          scheme: 'org-dartlang-app',
448
          path: '/${mainUri.pathSegments.last}',
449
        );
450
      }
451
      final LanguageVersion languageVersion = determineLanguageVersion(
452
        _fileSystem.file(mainUri),
453
        packageConfig[flutterProject.manifest.appName],
454
        Cache.flutterRoot!,
455
      );
Dan Field's avatar
Dan Field committed
456

457 458 459 460 461
      final String entrypoint = main_dart.generateMainDartFile(importedEntrypoint.toString(),
        languageVersion: languageVersion,
        pluginRegistrantEntrypoint: generatedImport,
      );

Dan Field's avatar
Dan Field committed
462 463
      result.writeAsStringSync(entrypoint);
    }
464
    return result!.absolute.uri;
Dan Field's avatar
Dan Field committed
465 466
  }

467 468 469 470
  Future<UpdateFSReport> _updateDevFS({bool fullRestart = false}) async {
    final bool isFirstUpload = !assetBundle.wasBuiltOnce();
    final bool rebuildBundle = assetBundle.needsBuild();
    if (rebuildBundle) {
471
      _logger!.printTrace('Updating assets');
472 473 474 475
      final int result = await assetBundle.build(
        packagesPath: debuggingOptions.buildInfo.packagesPath,
        targetPlatform: TargetPlatform.web_javascript,
      );
476
      if (result != 0) {
477
        return UpdateFSReport();
478 479
      }
    }
480
    final InvalidationResult invalidationResult = await projectFileInvalidator.findInvalidated(
481 482
      lastCompiled: device!.devFS!.lastCompiled,
      urisToMonitor: device!.devFS!.sources,
483
      packagesPath: packagesFilePath,
484
      packageConfig: device!.devFS!.lastPackageConfig
485
        ?? debuggingOptions.buildInfo.packageConfig,
486
    );
487 488
    final Status devFSStatus = _logger!.startProgress(
      'Waiting for connection from debug service on ${device!.device!.name}...',
489
    );
490
    final UpdateFSReport report = await device!.devFS!.update(
491
      mainUri: await _generateEntrypoint(
492
        _fileSystem.file(mainPath).absolute.uri,
493 494
        invalidationResult.packageConfig,
      ),
495 496 497 498
      target: target,
      bundle: assetBundle,
      firstBuildTime: firstBuildTime,
      bundleFirstUpload: isFirstUpload,
499
      generator: device!.generator!,
500 501 502
      fullRestart: fullRestart,
      dillOutputPath: dillOutputPath,
      projectRootPath: projectRootPath,
503
      pathToReload: getReloadPath(fullRestart: fullRestart, swap: false),
504 505
      invalidatedFiles: invalidationResult.uris!,
      packageConfig: invalidationResult.packageConfig!,
506
      trackWidgetCreation: debuggingOptions.buildInfo.trackWidgetCreation,
507
      shaderCompiler: device!.developmentShaderCompiler,
508 509
    );
    devFSStatus.stop();
510
    _logger!.printTrace('Synced ${getSizeAsMB(report.syncedBytes)}.');
511 512 513 514 515
    return report;
  }

  @override
  Future<int> attach({
516 517
    Completer<DebugConnectionInfo>? connectionInfoCompleter,
    Completer<void>? appStartedCompleter,
518
    bool allowExistingDdsInstance = false,
519
    bool enableDevTools = false, // ignored, we don't yet support devtools for web
520
    bool needsFullRestart = true,
521
  }) async {
522
    if (_chromiumLauncher != null) {
523
      final Chromium chrome = await _chromiumLauncher!.connectedInstance;
524
      final ChromeTab? chromeTab = await chrome.chromeConnection.getTab((ChromeTab chromeTab) {
525
        return !chromeTab.url.startsWith('chrome-extension');
526
      }, retryFor: const Duration(seconds: 5));
527 528 529
      if (chromeTab == null) {
        throwToolExit('Failed to connect to Chrome instance.');
      }
530 531
      _wipConnection = await chromeTab.connect();
    }
532
    Uri? websocketUri;
533
    if (supportsServiceProtocol) {
534 535
      final WebDevFS webDevFS = device!.devFS! as WebDevFS;
      final bool useDebugExtension = device!.device is WebServerDevice && debuggingOptions.startPaused;
536
      _connectionResult = await webDevFS.connect(useDebugExtension);
537
      unawaited(_connectionResult!.debugConnection!.onDone.whenComplete(_cleanupAndExit));
538

539 540
      void onLogEvent(vmservice.Event event)  {
        final String message = processVmServiceMessage(event);
541
        _logger!.printStatus(message);
542 543
      }

544 545
      _stdOutSub = _vmService.service.onStdoutEvent.listen(onLogEvent);
      _stdErrSub = _vmService.service.onStderrEvent.listen(onLogEvent);
546
      try {
547
        await _vmService.service.streamListen(vmservice.EventStreams.kStdout);
548 549 550 551 552
      } on vmservice.RPCError {
        // It is safe to ignore this error because we expect an error to be
        // thrown if we're not already subscribed.
      }
      try {
553
        await _vmService.service.streamListen(vmservice.EventStreams.kStderr);
554 555 556
      } on vmservice.RPCError {
        // It is safe to ignore this error because we expect an error to be
        // thrown if we're not already subscribed.
557
      }
558
      try {
559
        await _vmService.service.streamListen(vmservice.EventStreams.kIsolate);
560 561 562
      } on vmservice.RPCError {
        // It is safe to ignore this error because we expect an error to be
        // thrown if we're not already subscribed.
563
      }
564 565
      await setUpVmService(
        (String isolateId, {
566 567
          bool? force,
          bool? pause,
568
        }) async {
569
          await restart(pause: pause);
570 571 572
        },
        null,
        null,
573
        device!.device,
574 575 576 577 578
        null,
        printStructuredErrorLog,
        _vmService.service,
      );

579

580 581
      websocketUri = Uri.parse(_connectionResult!.debugConnection!.uri);
      device!.vmService = _vmService;
582

583 584 585
      // Run main immediately if the app is not started paused or if there
      // is no debugger attached. Otherwise, runMain when a resume event
      // is received.
586
      if (!debuggingOptions.startPaused || !supportsServiceProtocol) {
587
        _connectionResult!.appConnection!.runMain();
588
      } else {
589
        late StreamSubscription<void> resumeSub;
590
        resumeSub = _vmService.service.onDebugEvent
591 592
            .listen((vmservice.Event event) {
          if (event.type == vmservice.EventKind.kResume) {
593
            _connectionResult!.appConnection!.runMain();
594 595
            resumeSub.cancel();
          }
596
        });
597
      }
598 599
      if (enableDevTools) {
        // The method below is guaranteed never to return a failing future.
600
        unawaited(residentDevtoolsHandler!.serveAndAnnounceDevTools(
601 602 603 604
          devToolsServerAddress: debuggingOptions.devToolsServerAddress,
          flutterDevices: flutterDevices,
        ));
      }
605
    }
606
    if (websocketUri != null) {
607
      if (debuggingOptions.vmserviceOutFile != null) {
608
        _fileSystem.file(debuggingOptions.vmserviceOutFile)
609 610 611
          ..createSync(recursive: true)
          ..writeAsStringSync(websocketUri.toString());
      }
612 613
      _logger!.printStatus('Debug service listening on $websocketUri');
      _logger!.printStatus('');
614
      if (debuggingOptions.buildInfo.nullSafetyMode ==  NullSafetyMode.sound) {
615
        _logger!.printStatus('💪 Running with sound null safety 💪', emphasis: true);
616
      } else {
617
        _logger!.printStatus(
618
          'Running without sound null safety ⚠️',
619 620
          emphasis: true,
        );
621
        _logger!.printStatus(
622
          'Dart 3 will only support sound null safety, see https://dart.dev/null-safety',
623 624
        );
      }
625
    }
626
    appStartedCompleter?.complete();
627 628 629 630 631 632
    connectionInfoCompleter?.complete(DebugConnectionInfo(wsUri: websocketUri));
    if (stayResident) {
      await waitForAppToFinish();
    } else {
      await stopEchoingDeviceLog();
      await exitApp();
633
    }
634 635
    await cleanupAtFinish();
    return 0;
636
  }
637 638 639

  @override
  Future<void> exitApp() async {
640
    await device!.exitApps();
641 642
    appFinished();
  }
643
}
644

645 646 647 648
Uri _httpUriFromWebsocketUri(Uri websocketUri) {
  const String wsPath = '/ws';
  final String path = websocketUri.path;
  return websocketUri.replace(scheme: 'http', path: path.substring(0, path.length - wsPath.length));
649
}