resident_web_runner.dart 22.5 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 85
    FlutterDevice? device, {
    String? target,
86 87
    bool stayResident = true,
    bool machine = false,
88 89 90 91 92 93 94 95
    required this.flutterProject,
    required bool? ipv6,
    required DebuggingOptions debuggingOptions,
    required FileSystem? fileSystem,
    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 113
  final FileSystem? _fileSystem;
  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 248 249
    _logger!.printStatus(
      'Launching ${getDisplayPath(target, _fileSystem!)} '
      '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 283 284
        if (debuggingOptions.buildInfo.isDebug) {
          final UpdateFSReport report = await _updateDevFS(fullRestart: true);
          if (!report.success) {
285
            _logger!.printError('Failed to compile application.');
286
            appFailedToStart();
287 288
            return 1;
          }
289
          device!.generator!.accept();
290
          cacheInitialDillCompilation();
291 292 293 294 295 296
        } else {
          await buildWeb(
            flutterProject,
            target,
            debuggingOptions.buildInfo,
            false,
297
            kNoneWorker,
298
            true,
299
            debuggingOptions.nativeNullAssertions,
300
            null,
301
            null,
302 303
          );
        }
304
        await device!.device!.startApp(
305 306 307 308 309 310 311 312 313 314
          package,
          mainPath: target,
          debuggingOptions: debuggingOptions,
          platformArgs: <String, Object>{
            'uri': url.toString(),
          },
        );
        return attach(
          connectionInfoCompleter: connectionInfoCompleter,
          appStartedCompleter: appStartedCompleter,
315
          enableDevTools: enableDevTools,
316 317
        );
      });
318
    } on WebSocketException catch (error, stackTrace) {
319
      appFailedToStart();
320
      _logger!.printError('$error', stackTrace: stackTrace);
321
      throwToolExit(kExitMessage);
322
    } on ChromeDebugException catch (error, stackTrace) {
323
      appFailedToStart();
324
      _logger!.printError('$error', stackTrace: stackTrace);
325
      throwToolExit(kExitMessage);
326
    } on AppConnectionException catch (error, stackTrace) {
327
      appFailedToStart();
328
      _logger!.printError('$error', stackTrace: stackTrace);
329
      throwToolExit(kExitMessage);
330
    } on SocketException catch (error, stackTrace) {
331
      appFailedToStart();
332
      _logger!.printError('$error', stackTrace: stackTrace);
333
      throwToolExit(kExitMessage);
334 335 336
    } on Exception {
      appFailedToStart();
      rethrow;
337
    }
338 339 340 341 342
  }

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

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

    try {
383
      if (!deviceIsDebuggable) {
384
        _logger!.printStatus('Recompile complete. Page requires refresh.');
385
      } else if (isRunningDebug) {
386
        await _vmService.service.callMethod('hotRestart');
387
      } else {
388 389 390 391 392
        // 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,
        });
393
      }
394 395
    } on Exception catch (err) {
      return OperationResult(1, err.toString(), fatal: true);
396 397 398
    } finally {
      status.stop();
    }
399

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

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

Dan Field's avatar
Dan Field committed
422 423 424
  // 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.
425 426
  Future<Uri> _generateEntrypoint(Uri mainUri, PackageConfig? packageConfig) async {
    File? result = _generatedEntrypointDirectory?.childFile('web_entrypoint.dart');
Dan Field's avatar
Dan Field committed
427
    if (_generatedEntrypointDirectory == null) {
428
      _generatedEntrypointDirectory ??= _fileSystem!.systemTempDirectory.createTempSync('flutter_tools.')
Dan Field's avatar
Dan Field committed
429
        ..createSync();
430
      result = _generatedEntrypointDirectory!.childFile('web_entrypoint.dart');
Dan Field's avatar
Dan Field committed
431

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

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

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

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

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

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

536 537
      void onLogEvent(vmservice.Event event)  {
        final String message = processVmServiceMessage(event);
538
        _logger!.printStatus(message);
539 540
      }

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

576

577 578
      websocketUri = Uri.parse(_connectionResult!.debugConnection!.uri);
      device!.vmService = _vmService;
579

580 581 582
      // 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.
583
      if (!debuggingOptions.startPaused || !supportsServiceProtocol) {
584
        _connectionResult!.appConnection!.runMain();
585
      } else {
586
        late StreamSubscription<void> resumeSub;
587
        resumeSub = _vmService.service.onDebugEvent
588 589
            .listen((vmservice.Event event) {
          if (event.type == vmservice.EventKind.kResume) {
590
            _connectionResult!.appConnection!.runMain();
591 592
            resumeSub.cancel();
          }
593
        });
594
      }
595 596
      if (enableDevTools) {
        // The method below is guaranteed never to return a failing future.
597
        unawaited(residentDevtoolsHandler!.serveAndAnnounceDevTools(
598 599 600 601
          devToolsServerAddress: debuggingOptions.devToolsServerAddress,
          flutterDevices: flutterDevices,
        ));
      }
602
    }
603
    if (websocketUri != null) {
604
      if (debuggingOptions.vmserviceOutFile != null) {
605
        _fileSystem!.file(debuggingOptions.vmserviceOutFile)
606 607 608
          ..createSync(recursive: true)
          ..writeAsStringSync(websocketUri.toString());
      }
609 610
      _logger!.printStatus('Debug service listening on $websocketUri');
      _logger!.printStatus('');
611
      if (debuggingOptions.buildInfo.nullSafetyMode ==  NullSafetyMode.sound) {
612
        _logger!.printStatus('💪 Running with sound null safety 💪', emphasis: true);
613
      } else {
614
        _logger!.printStatus(
615 616 617
          'Running with unsound null safety',
          emphasis: true,
        );
618
        _logger!.printStatus(
619 620 621
          'For more information see https://dart.dev/null-safety/unsound-null-safety',
        );
      }
622
    }
623
    appStartedCompleter?.complete();
624 625 626 627 628 629
    connectionInfoCompleter?.complete(DebugConnectionInfo(wsUri: websocketUri));
    if (stayResident) {
      await waitForAppToFinish();
    } else {
      await stopEchoingDeviceLog();
      await exitApp();
630
    }
631 632
    await cleanupAtFinish();
    return 0;
633
  }
634 635 636

  @override
  Future<void> exitApp() async {
637
    await device!.exitApps();
638 639
    appFinished();
  }
640
}
641

642 643 644 645
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));
646
}