blob: 035fe08862287036c200499357376757ceeabeeb [file] [log] [blame]
// Copyright 2014 The Flutter 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';
// ignore: import_of_legacy_library_into_null_safe
import 'package:dwds/dwds.dart';
import 'package:package_config/package_config.dart';
import 'package:vm_service/vm_service.dart' as vmservice;
import 'package:webkit_inspection_protocol/webkit_inspection_protocol.dart'
hide StackTrace;
import '../application_package.dart';
import '../base/async_guard.dart';
import '../base/common.dart';
import '../base/file_system.dart';
import '../base/io.dart';
import '../base/logger.dart';
import '../base/net.dart';
import '../base/terminal.dart';
import '../base/time.dart';
import '../base/utils.dart';
import '../build_info.dart';
import '../build_system/targets/web.dart';
import '../cache.dart';
import '../dart/language_version.dart';
import '../devfs.dart';
import '../device.dart';
import '../flutter_plugins.dart';
import '../project.dart';
import '../reporting/reporting.dart';
import '../resident_devtools_handler.dart';
import '../resident_runner.dart';
import '../run_hot.dart';
import '../vmservice.dart';
import '../web/chrome.dart';
import '../web/compile.dart';
import '../web/file_generators/main_dart.dart' as main_dart;
import '../web/web_device.dart';
import '../web/web_runner.dart';
import 'devfs_web.dart';
/// Injectable factory to create a [ResidentWebRunner].
class DwdsWebRunnerFactory extends WebRunnerFactory {
@override
ResidentRunner createWebRunner(
FlutterDevice device, {
String? target,
required bool stayResident,
required FlutterProject flutterProject,
required bool? ipv6,
required DebuggingOptions debuggingOptions,
UrlTunneller? urlTunneller,
required Logger logger,
required FileSystem fileSystem,
required SystemClock systemClock,
required Usage usage,
bool machine = false,
}) {
return ResidentWebRunner(
device,
target: target,
flutterProject: flutterProject,
debuggingOptions: debuggingOptions,
ipv6: ipv6,
stayResident: stayResident,
urlTunneller: urlTunneller,
machine: machine,
usage: usage,
systemClock: systemClock,
fileSystem: fileSystem,
logger: logger,
);
}
}
const String kExitMessage = 'Failed to establish connection with the application '
'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.';
class ResidentWebRunner extends ResidentRunner {
ResidentWebRunner(
FlutterDevice device, {
String? target,
bool stayResident = true,
bool machine = false,
required this.flutterProject,
required bool? ipv6,
required DebuggingOptions debuggingOptions,
required FileSystem fileSystem,
required Logger logger,
required SystemClock systemClock,
required Usage usage,
UrlTunneller? urlTunneller,
ResidentDevtoolsHandlerFactory devtoolsHandler = createDefaultHandler,
}) : _fileSystem = fileSystem,
_logger = logger,
_systemClock = systemClock,
_usage = usage,
_urlTunneller = urlTunneller,
super(
<FlutterDevice>[device],
target: target ?? fileSystem.path.join('lib', 'main.dart'),
debuggingOptions: debuggingOptions,
ipv6: ipv6,
stayResident: stayResident,
machine: machine,
devtoolsHandler: devtoolsHandler,
);
final FileSystem _fileSystem;
final Logger _logger;
final SystemClock _systemClock;
final Usage _usage;
final UrlTunneller? _urlTunneller;
@override
Logger get logger => _logger;
@override
FileSystem get fileSystem => _fileSystem;
FlutterDevice? get device => flutterDevices.first;
final FlutterProject flutterProject;
DateTime? firstBuildTime;
// Used with the new compiler to generate a bootstrap file containing plugins
// and platform initialization.
Directory? _generatedEntrypointDirectory;
// Only the debug builds of the web support the service protocol.
@override
bool get supportsServiceProtocol => isRunningDebug && deviceIsDebuggable;
@override
bool get debuggingEnabled => isRunningDebug && deviceIsDebuggable;
/// WebServer device is debuggable when running with --start-paused.
bool get deviceIsDebuggable => device!.device is! WebServerDevice || debuggingOptions.startPaused;
@override
bool get supportsWriteSkSL => false;
@override
// Web uses a different plugin registry.
bool get generateDartPluginRegistry => false;
bool get _enableDwds => debuggingEnabled;
ConnectionResult? _connectionResult;
StreamSubscription<vmservice.Event>? _stdOutSub;
StreamSubscription<vmservice.Event>? _stdErrSub;
StreamSubscription<vmservice.Event>? _extensionEventSub;
bool _exited = false;
WipConnection? _wipConnection;
ChromiumLauncher? _chromiumLauncher;
FlutterVmService get _vmService {
if (_instance != null) {
return _instance!;
}
final vmservice.VmService? service =_connectionResult?.vmService;
final Uri websocketUri = Uri.parse(_connectionResult!.debugConnection!.uri);
final Uri httpUri = _httpUriFromWebsocketUri(websocketUri);
return _instance ??= FlutterVmService(service!, wsAddress: websocketUri, httpAddress: httpUri);
}
FlutterVmService? _instance;
@override
Future<void> cleanupAfterSignal() async {
await _cleanup();
}
@override
Future<void> cleanupAtFinish() async {
await _cleanup();
}
Future<void> _cleanup() async {
if (_exited) {
return;
}
await residentDevtoolsHandler!.shutdown();
await _stdOutSub?.cancel();
await _stdErrSub?.cancel();
await _extensionEventSub?.cancel();
await device!.device!.stopApp(null);
try {
_generatedEntrypointDirectory?.deleteSync(recursive: true);
} on FileSystemException {
// Best effort to clean up temp dirs.
_logger.printTrace(
'Failed to clean up temp directory: ${_generatedEntrypointDirectory!.path}',
);
}
_exited = true;
}
Future<void> _cleanupAndExit() async {
await _cleanup();
appFinished();
}
@override
void printHelp({bool details = true}) {
if (details) {
return printHelpDetails();
}
const String fire = '🔥';
const String rawMessage =
' To hot restart changes while running, press "r" or "R".';
final String message = _logger.terminal.color(
fire + _logger.terminal.bolden(rawMessage),
TerminalColor.red,
);
_logger.printStatus(message);
const String quitMessage = 'To quit, press "q".';
_logger.printStatus('For a more detailed help message, press "h". $quitMessage');
_logger.printStatus('');
printDebuggerList();
}
@override
Future<void> stopEchoingDeviceLog() async {
// Do nothing for ResidentWebRunner
await device!.stopEchoingDeviceLog();
}
@override
Future<int> run({
Completer<DebugConnectionInfo>? connectionInfoCompleter,
Completer<void>? appStartedCompleter,
bool enableDevTools = false, // ignored, we don't yet support devtools for web
String? route,
}) async {
firstBuildTime = DateTime.now();
final ApplicationPackage? package = await ApplicationPackageFactory.instance!.getPackageForPlatform(
TargetPlatform.web_javascript,
buildInfo: debuggingOptions.buildInfo,
);
if (package == null) {
_logger.printStatus('This application is not configured to build on the web.');
_logger.printStatus('To add web support to a project, run `flutter create .`.');
}
final String modeName = debuggingOptions.buildInfo.friendlyModeName;
_logger.printStatus(
'Launching ${getDisplayPath(target, _fileSystem)} '
'on ${device!.device!.name} in $modeName mode...',
);
if (device!.device is ChromiumDevice) {
_chromiumLauncher = (device!.device! as ChromiumDevice).chromeLauncher;
}
try {
return await asyncGuard(() async {
final ExpressionCompiler? expressionCompiler =
debuggingOptions.webEnableExpressionEvaluation
? WebExpressionCompiler(device!.generator!, fileSystem: _fileSystem)
: null;
device!.devFS = WebDevFS(
hostname: debuggingOptions.hostname ?? 'localhost',
port: debuggingOptions.port != null
? int.tryParse(debuggingOptions.port!)
: null,
packagesFilePath: packagesFilePath,
urlTunneller: _urlTunneller,
useSseForDebugProxy: debuggingOptions.webUseSseForDebugProxy,
useSseForDebugBackend: debuggingOptions.webUseSseForDebugBackend,
useSseForInjectedClient: debuggingOptions.webUseSseForInjectedClient,
buildInfo: debuggingOptions.buildInfo,
enableDwds: _enableDwds,
enableDds: debuggingOptions.enableDds,
entrypoint: _fileSystem.file(target).uri,
expressionCompiler: expressionCompiler,
chromiumLauncher: _chromiumLauncher,
nullAssertions: debuggingOptions.nullAssertions,
nullSafetyMode: debuggingOptions.buildInfo.nullSafetyMode,
nativeNullAssertions: debuggingOptions.nativeNullAssertions,
);
final Uri url = await device!.devFS!.create();
if (debuggingOptions.buildInfo.isDebug) {
await runSourceGenerators();
final UpdateFSReport report = await _updateDevFS(fullRestart: true);
if (!report.success) {
_logger.printError('Failed to compile application.');
appFailedToStart();
return 1;
}
device!.generator!.accept();
cacheInitialDillCompilation();
} else {
await buildWeb(
flutterProject,
target,
debuggingOptions.buildInfo,
false,
kNoneWorker,
true,
debuggingOptions.nativeNullAssertions,
false,
);
}
await device!.device!.startApp(
package,
mainPath: target,
debuggingOptions: debuggingOptions,
platformArgs: <String, Object>{
'uri': url.toString(),
},
);
return attach(
connectionInfoCompleter: connectionInfoCompleter,
appStartedCompleter: appStartedCompleter,
enableDevTools: enableDevTools,
);
});
} on WebSocketException catch (error, stackTrace) {
appFailedToStart();
_logger.printError('$error', stackTrace: stackTrace);
throwToolExit(kExitMessage);
} on ChromeDebugException catch (error, stackTrace) {
appFailedToStart();
_logger.printError('$error', stackTrace: stackTrace);
throwToolExit(kExitMessage);
} on AppConnectionException catch (error, stackTrace) {
appFailedToStart();
_logger.printError('$error', stackTrace: stackTrace);
throwToolExit(kExitMessage);
} on SocketException catch (error, stackTrace) {
appFailedToStart();
_logger.printError('$error', stackTrace: stackTrace);
throwToolExit(kExitMessage);
} on Exception {
appFailedToStart();
rethrow;
}
}
@override
Future<OperationResult> restart({
bool fullRestart = false,
bool? pause = false,
String? reason,
bool benchmarkMode = false,
}) async {
final DateTime start = _systemClock.now();
final Status status = _logger.startProgress(
'Performing hot restart...',
progressId: 'hot.restart',
);
if (debuggingOptions.buildInfo.isDebug) {
await runSourceGenerators();
// Full restart is always false for web, since the extra recompile is wasteful.
final UpdateFSReport report = await _updateDevFS();
if (report.success) {
device!.generator!.accept();
} else {
status.stop();
await device!.generator!.reject();
return OperationResult(1, 'Failed to recompile application.');
}
} else {
try {
await buildWeb(
flutterProject,
target,
debuggingOptions.buildInfo,
false,
kNoneWorker,
true,
debuggingOptions.nativeNullAssertions,
false,
);
} on ToolExit {
return OperationResult(1, 'Failed to recompile application.');
}
}
try {
if (!deviceIsDebuggable) {
_logger.printStatus('Recompile complete. Page requires refresh.');
} else if (isRunningDebug) {
await _vmService.service.callMethod('hotRestart');
} else {
// 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,
});
}
} on Exception catch (err) {
return OperationResult(1, err.toString(), fatal: true);
} finally {
status.stop();
}
final Duration elapsed = _systemClock.now().difference(start);
final String elapsedMS = getElapsedAsMilliseconds(elapsed);
_logger.printStatus('Restarted application in $elapsedMS.');
unawaited(residentDevtoolsHandler!.hotRestart(flutterDevices));
// Don't track restart times for dart2js builds or web-server devices.
if (debuggingOptions.buildInfo.isDebug && deviceIsDebuggable) {
_usage.sendTiming('hot', 'web-incremental-restart', elapsed);
HotEvent(
'restart',
targetPlatform: getNameForTargetPlatform(TargetPlatform.web_javascript),
sdkName: await device!.device!.sdkNameAndVersion,
emulator: false,
fullRestart: true,
reason: reason,
overallTimeInMs: elapsed.inMilliseconds,
fastReassemble: false,
).send();
}
return OperationResult.ok;
}
// 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.
Future<Uri> _generateEntrypoint(Uri mainUri, PackageConfig? packageConfig) async {
File? result = _generatedEntrypointDirectory?.childFile('web_entrypoint.dart');
if (_generatedEntrypointDirectory == null) {
_generatedEntrypointDirectory ??= _fileSystem.systemTempDirectory.createTempSync('flutter_tools.')
..createSync();
result = _generatedEntrypointDirectory!.childFile('web_entrypoint.dart');
// Generates the generated_plugin_registrar
await injectBuildTimePluginFiles(flutterProject, webPlatform: true, destination: _generatedEntrypointDirectory!);
// The below works because `injectBuildTimePluginFiles` is configured to write
// the web_plugin_registrant.dart file alongside the generated main.dart
const String generatedImport = 'web_plugin_registrant.dart';
Uri? importedEntrypoint = packageConfig!.toPackageUri(mainUri);
// Special handling for entrypoints that are not under lib, such as test scripts.
if (importedEntrypoint == null) {
final String parent = _fileSystem.file(mainUri).parent.path;
flutterDevices.first.generator!
..addFileSystemRoot(parent)
..addFileSystemRoot(_fileSystem.directory('test').absolute.path);
importedEntrypoint = Uri(
scheme: 'org-dartlang-app',
path: '/${mainUri.pathSegments.last}',
);
}
final LanguageVersion languageVersion = determineLanguageVersion(
_fileSystem.file(mainUri),
packageConfig[flutterProject.manifest.appName],
Cache.flutterRoot!,
);
final String entrypoint = main_dart.generateMainDartFile(importedEntrypoint.toString(),
languageVersion: languageVersion,
pluginRegistrantEntrypoint: generatedImport,
);
result.writeAsStringSync(entrypoint);
}
return result!.absolute.uri;
}
Future<UpdateFSReport> _updateDevFS({bool fullRestart = false}) async {
final bool isFirstUpload = !assetBundle.wasBuiltOnce();
final bool rebuildBundle = assetBundle.needsBuild();
if (rebuildBundle) {
_logger.printTrace('Updating assets');
final int result = await assetBundle.build(
packagesPath: debuggingOptions.buildInfo.packagesPath,
targetPlatform: TargetPlatform.web_javascript,
);
if (result != 0) {
return UpdateFSReport();
}
}
final InvalidationResult invalidationResult = await projectFileInvalidator.findInvalidated(
lastCompiled: device!.devFS!.lastCompiled,
urisToMonitor: device!.devFS!.sources,
packagesPath: packagesFilePath,
packageConfig: device!.devFS!.lastPackageConfig
?? debuggingOptions.buildInfo.packageConfig,
);
final Status devFSStatus = _logger.startProgress(
'Waiting for connection from debug service on ${device!.device!.name}...',
);
final UpdateFSReport report = await device!.devFS!.update(
mainUri: await _generateEntrypoint(
_fileSystem.file(mainPath).absolute.uri,
invalidationResult.packageConfig,
),
target: target,
bundle: assetBundle,
firstBuildTime: firstBuildTime,
bundleFirstUpload: isFirstUpload,
generator: device!.generator!,
fullRestart: fullRestart,
dillOutputPath: dillOutputPath,
projectRootPath: projectRootPath,
pathToReload: getReloadPath(fullRestart: fullRestart, swap: false),
invalidatedFiles: invalidationResult.uris!,
packageConfig: invalidationResult.packageConfig!,
trackWidgetCreation: debuggingOptions.buildInfo.trackWidgetCreation,
shaderCompiler: device!.developmentShaderCompiler,
);
devFSStatus.stop();
_logger.printTrace('Synced ${getSizeAsMB(report.syncedBytes)}.');
return report;
}
@override
Future<int> attach({
Completer<DebugConnectionInfo>? connectionInfoCompleter,
Completer<void>? appStartedCompleter,
bool allowExistingDdsInstance = false,
bool enableDevTools = false, // ignored, we don't yet support devtools for web
bool needsFullRestart = true,
}) async {
if (_chromiumLauncher != null) {
final Chromium chrome = await _chromiumLauncher!.connectedInstance;
final ChromeTab? chromeTab = await chrome.chromeConnection.getTab((ChromeTab chromeTab) {
return !chromeTab.url.startsWith('chrome-extension');
}, retryFor: const Duration(seconds: 5));
if (chromeTab == null) {
throwToolExit('Failed to connect to Chrome instance.');
}
_wipConnection = await chromeTab.connect();
}
Uri? websocketUri;
if (supportsServiceProtocol) {
final WebDevFS webDevFS = device!.devFS! as WebDevFS;
final bool useDebugExtension = device!.device is WebServerDevice && debuggingOptions.startPaused;
_connectionResult = await webDevFS.connect(useDebugExtension);
unawaited(_connectionResult!.debugConnection!.onDone.whenComplete(_cleanupAndExit));
void onLogEvent(vmservice.Event event) {
final String message = processVmServiceMessage(event);
_logger.printStatus(message);
}
_stdOutSub = _vmService.service.onStdoutEvent.listen(onLogEvent);
_stdErrSub = _vmService.service.onStderrEvent.listen(onLogEvent);
try {
await _vmService.service.streamListen(vmservice.EventStreams.kStdout);
} 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 {
await _vmService.service.streamListen(vmservice.EventStreams.kStderr);
} 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 {
await _vmService.service.streamListen(vmservice.EventStreams.kIsolate);
} on vmservice.RPCError {
// It is safe to ignore this error because we expect an error to be
// thrown if we're not already subscribed.
}
await setUpVmService(
reloadSources: (String isolateId, {bool? force, bool? pause}) async {
await restart(pause: pause);
},
device: device!.device,
flutterProject: flutterProject,
printStructuredErrorLogMethod: printStructuredErrorLog,
vmService: _vmService.service,
);
websocketUri = Uri.parse(_connectionResult!.debugConnection!.uri);
device!.vmService = _vmService;
// 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.
if (!debuggingOptions.startPaused || !supportsServiceProtocol) {
_connectionResult!.appConnection!.runMain();
} else {
late StreamSubscription<void> resumeSub;
resumeSub = _vmService.service.onDebugEvent
.listen((vmservice.Event event) {
if (event.type == vmservice.EventKind.kResume) {
_connectionResult!.appConnection!.runMain();
resumeSub.cancel();
}
});
}
if (enableDevTools) {
// The method below is guaranteed never to return a failing future.
unawaited(residentDevtoolsHandler!.serveAndAnnounceDevTools(
devToolsServerAddress: debuggingOptions.devToolsServerAddress,
flutterDevices: flutterDevices,
));
}
}
if (websocketUri != null) {
if (debuggingOptions.vmserviceOutFile != null) {
_fileSystem.file(debuggingOptions.vmserviceOutFile)
..createSync(recursive: true)
..writeAsStringSync(websocketUri.toString());
}
_logger.printStatus('Debug service listening on $websocketUri');
if (debuggingOptions.buildInfo.nullSafetyMode != NullSafetyMode.sound) {
_logger.printStatus('');
_logger.printStatus(
'Running without sound null safety ⚠️',
emphasis: true,
);
_logger.printStatus(
'Dart 3 will only support sound null safety, see https://dart.dev/null-safety',
);
}
}
appStartedCompleter?.complete();
connectionInfoCompleter?.complete(DebugConnectionInfo(wsUri: websocketUri));
if (stayResident) {
await waitForAppToFinish();
} else {
await stopEchoingDeviceLog();
await exitApp();
}
await cleanupAtFinish();
return 0;
}
@override
Future<void> exitApp() async {
await device!.exitApps();
appFinished();
}
}
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));
}