Flutter Web Bootstrapping Improvements (#144434)

This makes several changes to flutter web app bootstrapping.
* The build now produces a `flutter_bootstrap.js` file.
  * By default, this file does the basic streamlined startup of a flutter app with the service worker settings and no user configuration.
  * The user can also put a `flutter_bootstrap.js` file in the `web` subdirectory in the project directory which can have whatever custom bootstrapping logic they'd like to write instead. This file is also templated, and can use any of the tokens  that can be used with the `index.html` (with the exception of `{{flutter_bootstrap_js}}`, see below).
* Introduced a few new templating tokens for `index.html`:
  * `{{flutter_js}}` => inlines the entirety of `flutter.js`
  * `{{flutter_service_worker_version}}` => replaced directly by the service worker version. This can be used instead of the script that sets the `serviceWorkerVersion` local variable that we used to have by default.
  * `{{flutter_bootstrap_js}}` => inlines the entirety of `flutter_bootstrap.js` (this token obviously doesn't apply to `flutter_bootstrap.js` itself).
* Changed `IndexHtml` to be called `WebTemplate` instead, since it is used for more than just the index.html now.
* We now emit warnings at build time for certain deprecated flows:
  * Warn on the old service worker version pattern (i.e.`(const|var) serviceWorkerVersion = null`) and recommends using `{{flutter_service_worker_version}}` token instead
  * Warn on use of `FlutterLoader.loadEntrypoint` and recommend using `FlutterLoader.load` instead
  * Warn on manual loading of `flutter_service_worker.js`.
* The default `index.html` on `flutter create` now uses an async script tag with `flutter_bootstrap.js`.
This commit is contained in:
Jackson Gardner 2024-03-12 15:41:26 -07:00 committed by GitHub
parent 5b006bf50c
commit 3c30e3cb20
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
31 changed files with 823 additions and 784 deletions

View file

@ -41,23 +41,8 @@ found in the LICENSE file. -->
const serviceWorkerVersion = null;
</script>
<!-- This script adds the flutter initialization JS code -->
<script src="flutter.js" defer></script>
</head>
<body>
<script>
window.addEventListener('load', function(ev) {
// Download main.dart.js
_flutter.loader.loadEntrypoint({
serviceWorker: {
serviceWorkerVersion: serviceWorkerVersion,
},
onEntrypointLoaded: function(engineInitializer) {
engineInitializer.initializeEngine().then(function(appRunner) {
appRunner.runApp();
});
}
});
});
</script>
<script src="flutter_bootstrap.js" async></script>
</body>
</html>

View file

@ -0,0 +1,8 @@
{{flutter_js}}
{{flutter_build_config}}
_flutter.loader.load({
config: {
// Use the local CanvasKit bundle instead of the CDN to reduce test flakiness.
canvasKitBaseUrl: "/canvaskit/",
},
});

View file

@ -6,16 +6,8 @@ found in the LICENSE file. -->
<head>
<meta charset="UTF-8">
<title>Web Benchmarks</title>
<script src="flutter.js"></script>
</head>
<body>
<script>
{{flutter_build_config}}
_flutter.loader.load({
config: {
canvasKitBaseUrl: '/canvaskit/',
}
});
</script>
<script src="flutter_bootstrap.js" async></script>
</body>
</html>

View file

@ -163,7 +163,11 @@ Future<void> _waitForAppToLoad(
print('Waiting for app to load $waitForCounts');
await Future.any(<Future<Object?>>[
() async {
int tries = 1;
while (!waitForCounts.entries.every((MapEntry<String, int> entry) => (requestedPathCounts[entry.key] ?? 0) >= entry.value)) {
if (tries++ % 20 == 0) {
print('Still waiting. Requested so far: $requestedPathCounts');
}
await Future<void>.delayed(const Duration(milliseconds: 100));
}
}(),
@ -304,15 +308,16 @@ Future<void> runWebServiceWorkerTest({
'flutter.js': 1,
'main.dart.js': 1,
'flutter_service_worker.js': 1,
'flutter_bootstrap.js': 1,
'assets/FontManifest.json': 1,
'assets/AssetManifest.bin.json': 1,
'assets/fonts/MaterialIcons-Regular.otf': 1,
'CLOSE': 1,
// In headless mode Chrome does not load 'manifest.json' and 'favicon.ico'.
// In headless mode Chrome does not load 'manifest.json' and 'favicon.png'.
if (!headless)
...<String, int>{
'manifest.json': 1,
'favicon.ico': 1,
'favicon.png': 1,
},
});
expect(reportedVersion, '1');
@ -346,12 +351,13 @@ Future<void> runWebServiceWorkerTest({
if (shouldExpectFlutterJs)
'flutter.js': 1,
'flutter_service_worker.js': 2,
'flutter_bootstrap.js': 1,
'main.dart.js': 1,
'assets/AssetManifest.bin.json': 1,
'assets/FontManifest.json': 1,
'CLOSE': 1,
if (!headless)
'favicon.ico': 1,
'favicon.png': 1,
});
expect(reportedVersion, '2');
@ -377,14 +383,15 @@ Future<void> runWebServiceWorkerTest({
'main.dart.js': 1,
'assets/FontManifest.json': 1,
'flutter_service_worker.js': 1,
'flutter_bootstrap.js': 1,
'assets/AssetManifest.bin.json': 1,
'assets/fonts/MaterialIcons-Regular.otf': 1,
'CLOSE': 1,
// In headless mode Chrome does not load 'manifest.json' and 'favicon.ico'.
// In headless mode Chrome does not load 'manifest.json' and 'favicon.png'.
if (!headless)
...<String, int>{
'manifest.json': 1,
'favicon.ico': 1,
'favicon.png': 1,
},
});
@ -429,6 +436,7 @@ Future<void> runWebServiceWorkerTest({
if (shouldExpectFlutterJs)
'flutter.js': 1,
'flutter_service_worker.js': 2,
'flutter_bootstrap.js': 1,
'main.dart.js': 1,
'assets/AssetManifest.bin.json': 1,
'assets/FontManifest.json': 1,
@ -436,7 +444,7 @@ Future<void> runWebServiceWorkerTest({
if (!headless)
...<String, int>{
'manifest.json': 1,
'favicon.ico': 1,
'favicon.png': 1,
},
});
@ -508,8 +516,8 @@ Future<void> runWebServiceWorkerTestWithCachingResources({
workingDirectory: _testAppWebDirectory,
);
final bool shouldExpectFlutterJs = testType != ServiceWorkerTestType.withoutFlutterJs;
final bool usesFlutterBootstrapJs = testType == ServiceWorkerTestType.generatedEntrypoint;
final bool shouldExpectFlutterJs = !usesFlutterBootstrapJs && testType != ServiceWorkerTestType.withoutFlutterJs;
print('BEGIN runWebServiceWorkerTestWithCachingResources(headless: $headless, testType: $testType)');
try {
@ -534,14 +542,15 @@ Future<void> runWebServiceWorkerTestWithCachingResources({
'flutter.js': 1,
'main.dart.js': 1,
'flutter_service_worker.js': 1,
'flutter_bootstrap.js': usesFlutterBootstrapJs ? 2 : 1,
'assets/FontManifest.json': 1,
'assets/AssetManifest.bin.json': 1,
'assets/fonts/MaterialIcons-Regular.otf': 1,
// In headless mode Chrome does not load 'manifest.json' and 'favicon.ico'.
// In headless mode Chrome does not load 'manifest.json' and 'favicon.png'.
if (!headless)
...<String, int>{
'manifest.json': 1,
'favicon.ico': 1,
'favicon.png': 1,
},
});
@ -593,13 +602,14 @@ Future<void> runWebServiceWorkerTestWithCachingResources({
'flutter.js': 1,
'main.dart.js': 1,
'flutter_service_worker.js': 2,
'flutter_bootstrap.js': usesFlutterBootstrapJs ? 2 : 1,
'assets/FontManifest.json': 1,
'assets/AssetManifest.bin.json': 1,
'assets/fonts/MaterialIcons-Regular.otf': 1,
// In headless mode Chrome does not load 'manifest.json' and 'favicon.ico'.
// In headless mode Chrome does not load 'manifest.json' and 'favicon.png'.
if (!headless)
...<String, int>{
'favicon.ico': 1,
'favicon.png': 1,
},
});
} finally {
@ -682,11 +692,11 @@ Future<void> runWebServiceWorkerTestWithBlockedServiceWorkers({
'assets/FontManifest.json': 1,
'assets/fonts/MaterialIcons-Regular.otf': 1,
'CLOSE': 1,
// In headless mode Chrome does not load 'manifest.json' and 'favicon.ico'.
// In headless mode Chrome does not load 'manifest.json' and 'favicon.png'.
if (!headless)
...<String, int>{
'manifest.json': 1,
'favicon.ico': 1,
'favicon.png': 1,
},
});
} finally {
@ -770,14 +780,15 @@ Future<void> runWebServiceWorkerTestWithCustomServiceWorkerVersion({
'main.dart.js': 1,
'CLOSE': 1,
'flutter_service_worker.js': 1,
'flutter_bootstrap.js': 1,
'assets/FontManifest.json': 1,
'assets/AssetManifest.bin.json': 1,
'assets/fonts/MaterialIcons-Regular.otf': 1,
// In headless mode Chrome does not load 'manifest.json' and 'favicon.ico'.
// In headless mode Chrome does not load 'manifest.json' and 'favicon.png'.
if (!headless)
...<String, int>{
'manifest.json': 1,
'favicon.ico': 1,
'favicon.png': 1,
},
});
@ -794,11 +805,11 @@ Future<void> runWebServiceWorkerTestWithCustomServiceWorkerVersion({
'assets/FontManifest.json': 1,
'assets/fonts/MaterialIcons-Regular.otf': 1,
'CLOSE': 1,
// In headless mode Chrome does not load 'manifest.json' and 'favicon.ico'.
// In headless mode Chrome does not load 'manifest.json' and 'favicon.png'.
if (!headless)
...<String, int>{
'manifest.json': 1,
'favicon.ico': 1,
'favicon.png': 1,
},
});
@ -816,11 +827,11 @@ Future<void> runWebServiceWorkerTestWithCustomServiceWorkerVersion({
'assets/FontManifest.json': 1,
'assets/fonts/MaterialIcons-Regular.otf': 1,
'CLOSE': 1,
// In headless mode Chrome does not load 'manifest.json' and 'favicon.ico'.
// In headless mode Chrome does not load 'manifest.json' and 'favicon.png'.
if (!headless)
...<String, int>{
'manifest.json': 1,
'favicon.ico': 1,
'favicon.png': 1,
},
});
} finally {

View file

@ -5,14 +5,16 @@
import 'package:flutter/material.dart';
Future<void> main() async {
runApp(const Scaffold(
body: Center(
child: Column(
children: <Widget>[
Icon(Icons.ac_unit),
Text('Hello, World', textDirection: TextDirection.ltr),
],
runApp(const Directionality(
textDirection: TextDirection.ltr,
child: Scaffold(
body: Center(
child: Column(
children: <Widget>[
Icon(Icons.ac_unit),
Text('Hello, World', textDirection: TextDirection.ltr),
],
),
),
),
));
)));
}

View file

@ -12,6 +12,9 @@ found in the LICENSE file. -->
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black">
<meta name="apple-mobile-web-app-title" content="Web Test">
<!-- Favicon -->
<link rel="icon" type="image/png" href="favicon.png"/>
<link rel="manifest" href="manifest.json">
</head>
<body>

View file

@ -12,6 +12,9 @@ found in the LICENSE file. -->
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black">
<meta name="apple-mobile-web-app-title" content="Web Test">
<!-- Favicon -->
<link rel="icon" type="image/png" href="favicon.png"/>
<link rel="manifest" href="manifest.json">
<script>

View file

@ -12,6 +12,9 @@ found in the LICENSE file. -->
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black">
<meta name="apple-mobile-web-app-title" content="Web Test">
<!-- Favicon -->
<link rel="icon" type="image/png" href="favicon.png"/>
<link rel="manifest" href="manifest.json">
<script>
// The value below is injected by flutter build, do not touch.

View file

@ -13,6 +13,9 @@ found in the LICENSE file. -->
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black">
<meta name="apple-mobile-web-app-title" content="Web Test">
<!-- Favicon -->
<link rel="icon" type="image/png" href="favicon.png"/>
<link rel="manifest" href="manifest.json">
<script>
// The value below is injected by flutter build, do not touch.

View file

@ -16,6 +16,9 @@ found in the LICENSE file. -->
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black">
<meta name="apple-mobile-web-app-title" content="Web Test">
<!-- Favicon -->
<link rel="icon" type="image/png" href="favicon.png"/>
<link rel="manifest" href="manifest.json">
<script nonce="SOME_NONCE">
// The value below is injected by flutter build, do not touch.

View file

@ -16,6 +16,9 @@ found in the LICENSE file. -->
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black">
<meta name="apple-mobile-web-app-title" content="Web Test">
<!-- Favicon -->
<link rel="icon" type="image/png" href="favicon.png"/>
<link rel="manifest" href="manifest.json">
<script>
// The value below is injected by flutter build, do not touch.

View file

@ -12,6 +12,9 @@ found in the LICENSE file. -->
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black">
<meta name="apple-mobile-web-app-title" content="Web Test">
<!-- Favicon -->
<link rel="icon" type="image/png" href="favicon.png"/>
<link rel="manifest" href="manifest.json">
<script>
// The value below is injected by flutter build, do not touch.

View file

@ -12,6 +12,9 @@ found in the LICENSE file. -->
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black">
<meta name="apple-mobile-web-app-title" content="Web Test">
<!-- Favicon -->
<link rel="icon" type="image/png" href="favicon.png"/>
<link rel="manifest" href="manifest.json">
<script>
// The value below is injected by flutter build, do not touch.

View file

@ -12,6 +12,9 @@ found in the LICENSE file. -->
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black">
<meta name="apple-mobile-web-app-title" content="Web Test">
<!-- Favicon -->
<link rel="icon" type="image/png" href="favicon.png"/>
<link rel="manifest" href="manifest.json">
</head>
<body>

View file

@ -0,0 +1,8 @@
{{flutter_build_config}}
{{flutter_js}}
_flutter.loader.load({
config: {
// Use the local CanvasKit bundle instead of the CDN to reduce test flakiness.
canvasKitBaseUrl: "/canvaskit/",
},
});

View file

@ -5,17 +5,8 @@ found in the LICENSE file. -->
<html>
<head>
<title>Web Integration Tests</title>
<script src="flutter.js"></script>
</head>
<body>
<script>
{{flutter_build_config}}
_flutter.loader.load({
config: {
// Use the local CanvasKit bundle instead of the CDN to reduce test flakiness.
canvasKitBaseUrl: "/canvaskit/",
},
});
</script>
<script src="flutter_bootstrap.js" async></script>
</body>
</html>

View file

@ -17,11 +17,12 @@ import '../../dart/language_version.dart';
import '../../dart/package_map.dart';
import '../../flutter_plugins.dart';
import '../../globals.dart' as globals;
import '../../html_utils.dart';
import '../../project.dart';
import '../../web/bootstrap.dart';
import '../../web/compile.dart';
import '../../web/file_generators/flutter_service_worker_js.dart';
import '../../web/file_generators/main_dart.dart' as main_dart;
import '../../web_template.dart';
import '../build_system.dart';
import '../depfile.dart';
import '../exceptions.dart';
@ -328,18 +329,37 @@ class Dart2WasmTarget extends Dart2WebTarget {
/// Unpacks the dart2js or dart2wasm compilation and resources to a given
/// output directory.
class WebReleaseBundle extends Target {
WebReleaseBundle(List<WebCompilerConfig> configs) : this._withTargets(
configs.map((WebCompilerConfig config) =>
WebReleaseBundle(List<WebCompilerConfig> configs) : this._(
compileTargets: configs.map((WebCompilerConfig config) =>
switch (config) {
WasmCompilerConfig() => Dart2WasmTarget(config),
JsCompilerConfig() => Dart2JSTarget(config),
}
).toList()
).toList(),
);
const WebReleaseBundle._withTargets(this.compileTargets);
WebReleaseBundle._({
required this.compileTargets,
}) : templatedFilesTarget = WebTemplatedFiles(generateBuildConfigString(compileTargets));
static String generateBuildConfigString(List<Dart2WebTarget> compileTargets) {
final List<Map<String, Object?>> buildDescriptions = compileTargets.map(
(Dart2WebTarget target) => target.buildConfig
).toList();
final Map<String, Object?> buildConfig = <String, Object?>{
'engineRevision': globals.flutterVersion.engineRevision,
'builds': buildDescriptions,
};
return '''
if (!window._flutter) {
window._flutter = {};
}
_flutter.buildConfig = ${jsonEncode(buildConfig)};
''';
}
final List<Dart2WebTarget> compileTargets;
final WebTemplatedFiles templatedFilesTarget;
List<String> get buildFiles => compileTargets.fold(
const Iterable<String>.empty(),
@ -350,7 +370,10 @@ class WebReleaseBundle extends Target {
String get name => 'web_release_bundle';
@override
List<Target> get dependencies => compileTargets;
List<Target> get dependencies => <Target>[
...compileTargets,
templatedFilesTarget,
];
@override
List<Source> get inputs => <Source>[
@ -371,11 +394,12 @@ class WebReleaseBundle extends Target {
@override
Future<void> build(Environment environment) async {
final FileSystem fileSystem = environment.fileSystem;
for (final File outputFile in environment.buildDir.listSync(recursive: true).whereType<File>()) {
final String basename = environment.fileSystem.path.basename(outputFile.path);
final String basename = fileSystem.path.basename(outputFile.path);
if (buildFiles.contains(basename)) {
outputFile.copySync(
environment.outputDir.childFile(environment.fileSystem.path.basename(outputFile.path)).path
environment.outputDir.childFile(fileSystem.path.basename(outputFile.path)).path
);
}
}
@ -404,34 +428,18 @@ class WebReleaseBundle extends Target {
// Copy other resource files out of web/ directory.
final List<File> outputResourcesFiles = <File>[];
for (final File inputFile in inputResourceFiles) {
final File outputFile = environment.fileSystem.file(environment.fileSystem.path.join(
final String relativePath = fileSystem.path.relative(inputFile.path, from: webResources.path);
if (relativePath == 'index.html' || relativePath == 'flutter_bootstrap.js') {
// Skip these, these are handled by the templated file target.
continue;
}
final File outputFile = fileSystem.file(fileSystem.path.join(
environment.outputDir.path,
environment.fileSystem.path.relative(inputFile.path, from: webResources.path)));
relativePath));
if (!outputFile.parent.existsSync()) {
outputFile.parent.createSync(recursive: true);
}
outputResourcesFiles.add(outputFile);
// insert a random hash into the requests for service_worker.js. This is not a content hash,
// because it would need to be the hash for the entire bundle and not just the resource
// in question.
if (environment.fileSystem.path.basename(inputFile.path) == 'index.html') {
final List<Map<String, Object?>> buildDescriptions = compileTargets.map(
(Dart2WebTarget target) => target.buildConfig
).toList();
final Map<String, Object?> buildConfig = <String, Object?>{
'engineRevision': globals.flutterVersion.engineRevision,
'builds': buildDescriptions,
};
final String buildConfigString = '_flutter.buildConfig = ${jsonEncode(buildConfig)};';
final IndexHtml indexHtml = IndexHtml(inputFile.readAsStringSync());
indexHtml.applySubstitutions(
baseHref: environment.defines[kBaseHref] ?? '/',
serviceWorkerVersion: Random().nextInt(4294967296).toString(),
buildConfig: buildConfigString,
);
outputFile.writeAsStringSync(indexHtml.content);
continue;
}
inputFile.copySync(outputFile.path);
}
final Depfile resourceFile = Depfile(inputResourceFiles, outputResourcesFiles);
@ -461,6 +469,110 @@ class WebReleaseBundle extends Target {
}
}
class WebTemplatedFiles extends Target {
WebTemplatedFiles(this.buildConfigString);
final String buildConfigString;
@override
String get buildKey => buildConfigString;
void _emitWebTemplateWarning(
Environment environment,
String filePath,
WebTemplateWarning warning
) {
environment.logger.printWarning(
'Warning: In $filePath:${warning.lineNumber}: ${warning.warningText}'
);
}
@override
Future<void> build(Environment environment) async {
final Directory webResources = environment.projectDir
.childDirectory('web');
final File inputFlutterBootstrapJs = webResources.childFile('flutter_bootstrap.js');
final String inputBootstrapContent;
if (await inputFlutterBootstrapJs.exists()) {
inputBootstrapContent = await inputFlutterBootstrapJs.readAsString();
} else {
inputBootstrapContent = generateDefaultFlutterBootstrapScript();
}
final WebTemplate bootstrapTemplate = WebTemplate(inputBootstrapContent);
for (final WebTemplateWarning warning in bootstrapTemplate.getWarnings()) {
_emitWebTemplateWarning(environment, 'flutter_bootstrap.js', warning);
}
final FileSystem fileSystem = environment.fileSystem;
final File flutterJsFile = fileSystem.file(fileSystem.path.join(
globals.artifacts!.getHostArtifact(HostArtifact.flutterJsDirectory).path,
'flutter.js',
));
// Insert a random hash into the requests for service_worker.js. This is not a content hash,
// because it would need to be the hash for the entire bundle and not just the resource
// in question.
final String serviceWorkerVersion = Random().nextInt(4294967296).toString();
bootstrapTemplate.applySubstitutions(
baseHref: '',
serviceWorkerVersion: serviceWorkerVersion,
flutterJsFile: flutterJsFile,
buildConfig: buildConfigString,
);
final File outputFlutterBootstrapJs = fileSystem.file(fileSystem.path.join(
environment.outputDir.path,
'flutter_bootstrap.js'
));
await outputFlutterBootstrapJs.writeAsString(bootstrapTemplate.content);
await for (final FileSystemEntity file in webResources.list(recursive: true)) {
if (file is File && file.basename == 'index.html') {
final WebTemplate indexHtmlTemplate = WebTemplate(file.readAsStringSync());
final String relativePath = fileSystem.path.relative(file.path, from: webResources.path);
for (final WebTemplateWarning warning in indexHtmlTemplate.getWarnings()) {
_emitWebTemplateWarning(environment, relativePath, warning);
}
indexHtmlTemplate.applySubstitutions(
baseHref: environment.defines[kBaseHref] ?? '/',
serviceWorkerVersion: serviceWorkerVersion,
flutterJsFile: flutterJsFile,
buildConfig: buildConfigString,
flutterBootstrapJs: bootstrapTemplate.content,
);
final File outputIndexHtml = fileSystem.file(fileSystem.path.join(
environment.outputDir.path,
relativePath,
));
await outputIndexHtml.create(recursive: true);
await outputIndexHtml.writeAsString(indexHtmlTemplate.content);
}
}
}
@override
List<Target> get dependencies => <Target>[];
@override
List<Source> get inputs => const <Source>[
Source.pattern('{PROJECT_DIR}/web/*/index.html'),
Source.pattern('{PROJECT_DIR}/web/flutter_bootstrap.js'),
Source.hostArtifact(HostArtifact.flutterWebSdk),
];
@override
String get name => 'web_templated_files';
@override
List<Source> get outputs => const <Source>[
Source.pattern('{OUTPUT_DIR}/*/index.html'),
Source.pattern('{OUTPUT_DIR}/flutter_bootstrap.js'),
];
}
/// Static assets provided by the Flutter SDK that do not change, such as
/// CanvasKit.
///
@ -596,6 +708,7 @@ class WebServiceWorker extends Target {
'main.dart.mjs',
],
'index.html',
'flutter_bootstrap.js',
if (urlToHash.containsKey('assets/AssetManifest.bin.json'))
'assets/AssetManifest.bin.json',
if (urlToHash.containsKey('assets/FontManifest.json'))

View file

@ -8,13 +8,13 @@ import '../base/utils.dart';
import '../build_info.dart';
import '../features.dart';
import '../globals.dart' as globals;
import '../html_utils.dart';
import '../project.dart';
import '../runner/flutter_command.dart'
show DevelopmentArtifact, FlutterCommandResult, FlutterOptions;
import '../web/compile.dart';
import '../web/file_generators/flutter_service_worker_js.dart';
import '../web/web_constants.dart';
import '../web_template.dart';
import 'build.dart';
class BuildWebCommand extends BuildSubCommand {

View file

@ -34,13 +34,13 @@ import '../dart/package_map.dart';
import '../devfs.dart';
import '../device.dart';
import '../globals.dart' as globals;
import '../html_utils.dart';
import '../project.dart';
import '../vmservice.dart';
import '../web/bootstrap.dart';
import '../web/chrome.dart';
import '../web/compile.dart';
import '../web/memory_fs.dart';
import '../web_template.dart';
typedef DwdsLauncher = Future<Dwds> Function({
required AssetReader assetReader,
@ -120,7 +120,7 @@ class WebAssetServer implements AssetReader {
this._nullSafetyMode,
this._ddcModuleSystem, {
required this.webRenderer,
}) : basePath = _getIndexHtml().getBaseHref();
}) : basePath = _getWebTemplate('index.html', _kDefaultIndex).getBaseHref();
// Fallback to "application/octet-stream" on null which
// makes no claims as to the structure of the data.
@ -386,7 +386,11 @@ class WebAssetServer implements AssetReader {
// If the response is `/`, then we are requesting the index file.
if (requestPath == '/' || requestPath.isEmpty) {
return _serveIndex();
return _serveIndexHtml();
}
if (requestPath == 'flutter_bootstrap.js') {
return _serveFlutterBootstrapJs();
}
final Map<String, String> headers = <String, String>{};
@ -478,7 +482,7 @@ class WebAssetServer implements AssetReader {
requestPath.startsWith('canvaskit/')) {
return shelf.Response.notFound('');
}
return _serveIndex();
return _serveIndexHtml();
}
// For real files, use a serialized file stat plus path as a revision.
@ -524,8 +528,7 @@ class WebAssetServer implements AssetReader {
/// Determines what rendering backed to use.
final WebRendererMode webRenderer;
shelf.Response _serveIndex() {
final IndexHtml indexHtml = _getIndexHtml();
String get _buildConfigString {
final Map<String, dynamic> buildConfig = <String, dynamic>{
'engineRevision': globals.flutterVersion.engineRevision,
'builds': <dynamic>[
@ -536,19 +539,52 @@ class WebAssetServer implements AssetReader {
},
],
};
final String buildConfigString = '_flutter.buildConfig = ${jsonEncode(buildConfig)};';
return '''
if (!window._flutter) {
window._flutter = {};
}
_flutter.buildConfig = ${jsonEncode(buildConfig)};
''';
}
File get _flutterJsFile => globals.fs.file(globals.fs.path.join(
globals.artifacts!.getHostArtifact(HostArtifact.flutterJsDirectory).path,
'flutter.js',
));
String get _flutterBootstrapJsContent {
final WebTemplate bootstrapTemplate = _getWebTemplate(
'flutter_bootstrap.js',
generateDefaultFlutterBootstrapScript()
);
bootstrapTemplate.applySubstitutions(
baseHref: '/',
serviceWorkerVersion: null,
buildConfig: _buildConfigString,
flutterJsFile: _flutterJsFile,
);
return bootstrapTemplate.content;
}
shelf.Response _serveFlutterBootstrapJs() {
return shelf.Response.ok(_flutterBootstrapJsContent, headers: <String, String>{
HttpHeaders.contentTypeHeader: 'text/javascript',
});
}
shelf.Response _serveIndexHtml() {
final WebTemplate indexHtml = _getWebTemplate('index.html', _kDefaultIndex);
indexHtml.applySubstitutions(
// Currently, we don't support --base-href for the "run" command.
baseHref: '/',
serviceWorkerVersion: null,
buildConfig: buildConfigString,
buildConfig: _buildConfigString,
flutterJsFile: _flutterJsFile,
flutterBootstrapJs: _flutterBootstrapJsContent,
);
final Map<String, String> headers = <String, String>{
return shelf.Response.ok(indexHtml.content, headers: <String, String>{
HttpHeaders.contentTypeHeader: 'text/html',
};
return shelf.Response.ok(indexHtml.content, headers: headers);
});
}
// Attempt to resolve `path` to a dart file.
@ -860,6 +896,21 @@ class WebDevFS implements DevFS {
@override
final Directory rootDirectory;
Future<void> _validateTemplateFile(String filename) async {
final File file =
globals.fs.currentDirectory.childDirectory('web').childFile(filename);
if (!await file.exists()) {
return;
}
final WebTemplate template = WebTemplate(await file.readAsString());
for (final WebTemplateWarning warning in template.getWarnings()) {
globals.logger.printWarning(
'Warning: In $filename:${warning.lineNumber}: ${warning.warningText}'
);
}
}
@override
Future<UpdateFSReport> update({
required Uri mainUri,
@ -950,6 +1001,8 @@ class WebDevFS implements DevFS {
);
}
}
await _validateTemplateFile('index.html');
await _validateTemplateFile('flutter_bootstrap.js');
final DateTime candidateCompileTime = DateTime.now();
if (fullRestart) {
generator.reset();
@ -1173,10 +1226,10 @@ String? _stripBasePath(String path, String basePath) {
return stripLeadingSlash(path);
}
IndexHtml _getIndexHtml() {
final File indexHtml =
globals.fs.currentDirectory.childDirectory('web').childFile('index.html');
WebTemplate _getWebTemplate(String filename, String fallbackContent) {
final File template =
globals.fs.currentDirectory.childDirectory('web').childFile(filename);
final String htmlContent =
indexHtml.existsSync() ? indexHtml.readAsStringSync() : _kDefaultIndex;
return IndexHtml(htmlContent);
template.existsSync() ? template.readAsStringSync() : fallbackContent;
return WebTemplate(htmlContent);
}

View file

@ -472,3 +472,16 @@ String generateTestBootstrapFileContents(
})();
''';
}
String generateDefaultFlutterBootstrapScript() {
return '''
{{flutter_js}}
{{flutter_build_config}}
_flutter.loader.load({
serviceWorkerSettings: {
serviceWorkerVersion: {{flutter_service_worker_version}}
}
});
''';
}

View file

@ -1,380 +0,0 @@
// 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.
if (!_flutter) {
var _flutter = {};
}
_flutter.loader = null;
(function () {
"use strict";
const baseUri = ensureTrailingSlash(getBaseURI());
function getBaseURI() {
const base = document.querySelector("base");
return (base && base.getAttribute("href")) || "";
}
function ensureTrailingSlash(uri) {
if (uri == "") {
return uri;
}
return uri.endsWith("/") ? uri : `${uri}/`;
}
/**
* Wraps `promise` in a timeout of the given `duration` in ms.
*
* Resolves/rejects with whatever the original `promises` does, or rejects
* if `promise` takes longer to complete than `duration`. In that case,
* `debugName` is used to compose a legible error message.
*
* If `duration` is < 0, the original `promise` is returned unchanged.
* @param {Promise} promise
* @param {number} duration
* @param {string} debugName
* @returns {Promise} a wrapped promise.
*/
async function timeout(promise, duration, debugName) {
if (duration < 0) {
return promise;
}
let timeoutId;
const _clock = new Promise((_, reject) => {
timeoutId = setTimeout(() => {
reject(
new Error(
`${debugName} took more than ${duration}ms to resolve. Moving on.`,
{
cause: timeout,
}
)
);
}, duration);
});
return Promise.race([promise, _clock]).finally(() => {
clearTimeout(timeoutId);
});
}
/**
* Handles the creation of a TrustedTypes `policy` that validates URLs based
* on an (optional) incoming array of RegExes.
*/
class FlutterTrustedTypesPolicy {
/**
* Constructs the policy.
* @param {[RegExp]} validPatterns the patterns to test URLs
* @param {String} policyName the policy name (optional)
*/
constructor(validPatterns, policyName = "flutter-js") {
const patterns = validPatterns || [
/\.js$/,
];
if (window.trustedTypes) {
this.policy = trustedTypes.createPolicy(policyName, {
createScriptURL: function(url) {
const parsed = new URL(url, window.location);
const file = parsed.pathname.split("/").pop();
const matches = patterns.some((pattern) => pattern.test(file));
if (matches) {
return parsed.toString();
}
console.error(
"URL rejected by TrustedTypes policy",
policyName, ":", url, "(download prevented)");
}
});
}
}
}
/**
* Handles loading/reloading Flutter's service worker, if configured.
*
* @see: https://developers.google.com/web/fundamentals/primers/service-workers
*/
class FlutterServiceWorkerLoader {
/**
* Injects a TrustedTypesPolicy (or undefined if the feature is not supported).
* @param {TrustedTypesPolicy | undefined} policy
*/
setTrustedTypesPolicy(policy) {
this._ttPolicy = policy;
}
/**
* Returns a Promise that resolves when the latest Flutter service worker,
* configured by `settings` has been loaded and activated.
*
* Otherwise, the promise is rejected with an error message.
* @param {*} settings Service worker settings
* @returns {Promise} that resolves when the latest serviceWorker is ready.
*/
loadServiceWorker(settings) {
if (settings == null) {
// In the future, settings = null -> uninstall service worker?
console.debug("Null serviceWorker configuration. Skipping.");
return Promise.resolve();
}
if (!("serviceWorker" in navigator)) {
let errorMessage = "Service Worker API unavailable.";
if (!window.isSecureContext) {
errorMessage += "\nThe current context is NOT secure."
errorMessage += "\nRead more: https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts";
}
return Promise.reject(
new Error(errorMessage)
);
}
const {
serviceWorkerVersion,
serviceWorkerUrl = `${baseUri}flutter_service_worker.js?v=${serviceWorkerVersion}`,
timeoutMillis = 4000,
} = settings;
// Apply the TrustedTypes policy, if present.
let url = serviceWorkerUrl;
if (this._ttPolicy != null) {
url = this._ttPolicy.createScriptURL(url);
}
const serviceWorkerActivation = navigator.serviceWorker
.register(url)
.then((serviceWorkerRegistration) => this._getNewServiceWorker(serviceWorkerRegistration, serviceWorkerVersion))
.then(this._waitForServiceWorkerActivation);
// Timeout race promise
return timeout(
serviceWorkerActivation,
timeoutMillis,
"prepareServiceWorker"
);
}
/**
* Returns the latest service worker for the given `serviceWorkerRegistration`.
*
* This might return the current service worker, if there's no new service worker
* awaiting to be installed/updated.
*
* @param {ServiceWorkerRegistration} serviceWorkerRegistration
* @param {String} serviceWorkerVersion
* @returns {Promise<ServiceWorker>}
*/
async _getNewServiceWorker(serviceWorkerRegistration, serviceWorkerVersion) {
if (!serviceWorkerRegistration.active && (serviceWorkerRegistration.installing || serviceWorkerRegistration.waiting)) {
// No active web worker and we have installed or are installing
// one for the first time. Simply wait for it to activate.
console.debug("Installing/Activating first service worker.");
return serviceWorkerRegistration.installing || serviceWorkerRegistration.waiting;
} else if (!serviceWorkerRegistration.active.scriptURL.endsWith(serviceWorkerVersion)) {
// When the app updates the serviceWorkerVersion changes, so we
// need to ask the service worker to update.
const newRegistration = await serviceWorkerRegistration.update();
console.debug("Updating service worker.");
return newRegistration.installing || newRegistration.waiting || newRegistration.active;
} else {
console.debug("Loading from existing service worker.");
return serviceWorkerRegistration.active;
}
}
/**
* Returns a Promise that resolves when the `serviceWorker` changes its
* state to "activated".
*
* @param {ServiceWorker} serviceWorker
* @returns {Promise<void>}
*/
async _waitForServiceWorkerActivation(serviceWorker) {
if (!serviceWorker || serviceWorker.state == "activated") {
if (!serviceWorker) {
throw new Error("Cannot activate a null service worker!");
} else {
console.debug("Service worker already active.");
return;
}
}
return new Promise((resolve, _) => {
serviceWorker.addEventListener("statechange", () => {
if (serviceWorker.state == "activated") {
console.debug("Activated new service worker.");
resolve();
}
});
});
}
}
/**
* Handles injecting the main Flutter web entrypoint (main.dart.js), and notifying
* the user when Flutter is ready, through `didCreateEngineInitializer`.
*
* @see https://docs.flutter.dev/development/platform-integration/web/initialization
*/
class FlutterEntrypointLoader {
/**
* Creates a FlutterEntrypointLoader.
*/
constructor() {
// Watchdog to prevent injecting the main entrypoint multiple times.
this._scriptLoaded = false;
}
/**
* Injects a TrustedTypesPolicy (or undefined if the feature is not supported).
* @param {TrustedTypesPolicy | undefined} policy
*/
setTrustedTypesPolicy(policy) {
this._ttPolicy = policy;
}
/**
* Loads flutter main entrypoint, specified by `entrypointUrl`, and calls a
* user-specified `onEntrypointLoaded` callback with an EngineInitializer
* object when it's done.
*
* @param {*} options
* @returns {Promise | undefined} that will eventually resolve with an
* EngineInitializer, or will be rejected with the error caused by the loader.
* Returns undefined when an `onEntrypointLoaded` callback is supplied in `options`.
*/
async loadEntrypoint(options) {
const { entrypointUrl = `${baseUri}main.dart.js`, onEntrypointLoaded, nonce } =
options || {};
return this._loadEntrypoint(entrypointUrl, onEntrypointLoaded, nonce);
}
/**
* Resolves the promise created by loadEntrypoint, and calls the `onEntrypointLoaded`
* function supplied by the user (if needed).
*
* Called by Flutter through `_flutter.loader.didCreateEngineInitializer` method,
* which is bound to the correct instance of the FlutterEntrypointLoader by
* the FlutterLoader object.
*
* @param {Function} engineInitializer @see https://github.com/flutter/engine/blob/main/lib/web_ui/lib/src/engine/js_interop/js_loader.dart#L42
*/
didCreateEngineInitializer(engineInitializer) {
if (typeof this._didCreateEngineInitializerResolve === "function") {
this._didCreateEngineInitializerResolve(engineInitializer);
// Remove the resolver after the first time, so Flutter Web can hot restart.
this._didCreateEngineInitializerResolve = null;
// Make the engine revert to "auto" initialization on hot restart.
delete _flutter.loader.didCreateEngineInitializer;
}
if (typeof this._onEntrypointLoaded === "function") {
this._onEntrypointLoaded(engineInitializer);
}
}
/**
* Injects a script tag into the DOM, and configures this loader to be able to
* handle the "entrypoint loaded" notifications received from Flutter web.
*
* @param {string} entrypointUrl the URL of the script that will initialize
* Flutter.
* @param {Function} onEntrypointLoaded a callback that will be called when
* Flutter web notifies this object that the entrypoint is
* loaded.
* @returns {Promise | undefined} a Promise that resolves when the entrypoint
* is loaded, or undefined if `onEntrypointLoaded`
* is a function.
*/
_loadEntrypoint(entrypointUrl, onEntrypointLoaded, nonce) {
const useCallback = typeof onEntrypointLoaded === "function";
if (!this._scriptLoaded) {
this._scriptLoaded = true;
const scriptTag = this._createScriptTag(entrypointUrl, nonce);
if (useCallback) {
// Just inject the script tag, and return nothing; Flutter will call
// `didCreateEngineInitializer` when it's done.
console.debug("Injecting <script> tag. Using callback.");
this._onEntrypointLoaded = onEntrypointLoaded;
document.body.append(scriptTag);
} else {
// Inject the script tag and return a promise that will get resolved
// with the EngineInitializer object from Flutter when it calls
// `didCreateEngineInitializer` later.
return new Promise((resolve, reject) => {
console.debug(
"Injecting <script> tag. Using Promises. Use the callback approach instead!"
);
this._didCreateEngineInitializerResolve = resolve;
scriptTag.addEventListener("error", reject);
document.body.append(scriptTag);
});
}
}
}
/**
* Creates a script tag for the given URL.
* @param {string} url
* @returns {HTMLScriptElement}
*/
_createScriptTag(url, nonce) {
const scriptTag = document.createElement("script");
scriptTag.type = "application/javascript";
if (nonce) {
scriptTag.nonce = nonce;
}
// Apply TrustedTypes validation, if available.
let trustedUrl = url;
if (this._ttPolicy != null) {
trustedUrl = this._ttPolicy.createScriptURL(url);
}
scriptTag.src = trustedUrl;
return scriptTag;
}
}
/**
* The public interface of _flutter.loader. Exposes two methods:
* * loadEntrypoint (which coordinates the default Flutter web loading procedure)
* * didCreateEngineInitializer (which is called by Flutter to notify that its
* Engine is ready to be initialized)
*/
class FlutterLoader {
/**
* Initializes the Flutter web app.
* @param {*} options
* @returns {Promise?} a (Deprecated) Promise that will eventually resolve
* with an EngineInitializer, or will be rejected with
* any error caused by the loader. Or Null, if the user
* supplies an `onEntrypointLoaded` Function as an option.
*/
async loadEntrypoint(options) {
const { serviceWorker, ...entrypoint } = options || {};
// A Trusted Types policy that is going to be used by the loader.
const flutterTT = new FlutterTrustedTypesPolicy();
// The FlutterServiceWorkerLoader instance could be injected as a dependency
// (and dynamically imported from a module if not present).
const serviceWorkerLoader = new FlutterServiceWorkerLoader();
serviceWorkerLoader.setTrustedTypesPolicy(flutterTT.policy);
await serviceWorkerLoader.loadServiceWorker(serviceWorker).catch(e => {
// Regardless of what happens with the injection of the SW, the show must go on
console.warn("Exception while loading service worker:", e);
});
// The FlutterEntrypointLoader instance could be injected as a dependency
// (and dynamically imported from a module if not present).
const entrypointLoader = new FlutterEntrypointLoader();
entrypointLoader.setTrustedTypesPolicy(flutterTT.policy);
// Install the `didCreateEngineInitializer` listener where Flutter web expects it to be.
this.didCreateEngineInitializer =
entrypointLoader.didCreateEngineInitializer.bind(entrypointLoader);
return entrypointLoader.loadEntrypoint(entrypoint);
}
}
_flutter.loader = new FlutterLoader();
})();

View file

@ -1,61 +0,0 @@
// 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 '../web_constants.dart';
String generateWasmBootstrapFile(bool isSkwasm) {
return '''
// 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.
(async function () {
let dart2wasm_runtime;
let moduleInstance;
try {
const dartModulePromise = WebAssembly.compileStreaming(fetch('main.dart.wasm'));
${generateImports(isSkwasm)}
dart2wasm_runtime = await import('./main.dart.mjs');
moduleInstance = await dart2wasm_runtime.instantiate(dartModulePromise, imports);
} catch (exception) {
console.error(`Failed to fetch and instantiate wasm module: \${exception}`);
console.error('$kWasmMoreInfo');
}
if (moduleInstance) {
try {
await dart2wasm_runtime.invoke(moduleInstance);
} catch (exception) {
console.error(`Exception while invoking test: \${exception}`);
}
}
})();
''';
}
String generateImports(bool isSkwasm) {
if (isSkwasm) {
return r'''
const imports = new Promise((resolve, reject) => {
const skwasmScript = document.createElement('script');
skwasmScript.src = 'canvaskit/skwasm.js';
document.body.appendChild(skwasmScript);
skwasmScript.addEventListener('load', async () => {
const skwasmInstance = await skwasm();
window._flutter_skwasmInstance = skwasmInstance;
resolve({
'skwasm': skwasmInstance.wasmExports,
'skwasmWrapper': skwasmInstance,
'ffi': {
'memory': skwasmInstance.wasmMemory,
}
});
});
});
''';
} else {
return ' const imports = {};';
}
}

View file

@ -6,10 +6,20 @@ import 'package:html/dom.dart';
import 'package:html/parser.dart';
import 'base/common.dart';
import 'base/file_system.dart';
/// Placeholder for base href
const String kBaseHrefPlaceholder = r'$FLUTTER_BASE_HREF';
class WebTemplateWarning {
WebTemplateWarning(
this.warningText,
this.lineNumber,
);
final String warningText;
final int lineNumber;
}
/// Utility class for parsing and performing operations on the contents of the
/// index.html file.
///
@ -21,8 +31,8 @@ const String kBaseHrefPlaceholder = r'$FLUTTER_BASE_HREF';
/// return indexHtml.getBaseHref();
/// }
/// ```
class IndexHtml {
IndexHtml(this._content);
class WebTemplate {
WebTemplate(this._content);
String get content => _content;
String _content;
@ -58,11 +68,42 @@ class IndexHtml {
return stripLeadingSlash(stripTrailingSlash(baseHref));
}
List<WebTemplateWarning> getWarnings() {
return <WebTemplateWarning>[
..._getWarningsForPattern(
RegExp('(const|var) serviceWorkerVersion = null'),
'Local variable for "serviceWorkerVersion" is deprecated. Use "{{flutter_service_worker_version}}" template token instead.',
),
..._getWarningsForPattern(
"navigator.serviceWorker.register('flutter_service_worker.js')",
'Manual service worker registration deprecated. Use flutter.js service worker bootstrapping instead.',
),
..._getWarningsForPattern(
'_flutter.loader.loadEntrypoint(',
'"FlutterLoader.loadEntrypoint" is deprecated. Use "FlutterLoader.load" instead.',
),
];
}
List<WebTemplateWarning> _getWarningsForPattern(Pattern pattern, String warningText) {
return <WebTemplateWarning>[
for (final Match match in pattern.allMatches(_content))
_getWarningForMatch(match, warningText)
];
}
WebTemplateWarning _getWarningForMatch(Match match, String warningText) {
final int lineCount = RegExp(r'(\r\n|\r|\n)').allMatches(_content.substring(0, match.start)).length;
return WebTemplateWarning(warningText, lineCount + 1);
}
/// Applies substitutions to the content of the index.html file.
void applySubstitutions({
required String baseHref,
required String? serviceWorkerVersion,
required File flutterJsFile,
String? buildConfig,
String? flutterBootstrapJs,
}) {
if (_content.contains(kBaseHrefPlaceholder)) {
_content = _content.replaceAll(kBaseHrefPlaceholder, baseHref);
@ -82,12 +123,30 @@ class IndexHtml {
"navigator.serviceWorker.register('flutter_service_worker.js?v=$serviceWorkerVersion')",
);
}
_content = _content.replaceAll(
'{{flutter_service_worker_version}}',
serviceWorkerVersion != null ? '"$serviceWorkerVersion"' : 'null',
);
if (buildConfig != null) {
_content = _content.replaceFirst(
_content = _content.replaceAll(
'{{flutter_build_config}}',
buildConfig,
);
}
if (_content.contains('{{flutter_js}}')) {
_content = _content.replaceAll(
'{{flutter_js}}',
flutterJsFile.readAsStringSync(),
);
}
if (flutterBootstrapJs != null) {
_content = _content.replaceAll(
'{{flutter_bootstrap_js}}',
flutterBootstrapJs,
);
}
}
}

View file

@ -31,29 +31,8 @@
<title>{{projectName}}</title>
<link rel="manifest" href="manifest.json">
<script>
// The value below is injected by flutter build, do not touch.
const serviceWorkerVersion = null;
</script>
<!-- This script adds the flutter initialization JS code -->
<script src="flutter.js" defer></script>
</head>
<body>
<script>
window.addEventListener('load', function(ev) {
// Download main.dart.js
_flutter.loader.loadEntrypoint({
serviceWorker: {
serviceWorkerVersion: serviceWorkerVersion,
},
onEntrypointLoaded: function(engineInitializer) {
engineInitializer.initializeEngine().then(function(appRunner) {
appRunner.runApp();
});
}
});
});
</script>
<script src="flutter_bootstrap.js" async></script>
</body>
</html>

View file

@ -12,10 +12,10 @@ import 'package:flutter_tools/src/build_system/build_system.dart';
import 'package:flutter_tools/src/build_system/depfile.dart';
import 'package:flutter_tools/src/build_system/targets/web.dart';
import 'package:flutter_tools/src/globals.dart' as globals;
import 'package:flutter_tools/src/html_utils.dart';
import 'package:flutter_tools/src/isolated/mustache_template.dart';
import 'package:flutter_tools/src/web/compile.dart';
import 'package:flutter_tools/src/web/file_generators/flutter_service_worker_js.dart';
import 'package:flutter_tools/src/web_template.dart';
import '../../../src/common.dart';
import '../../../src/fake_process_manager.dart';
@ -144,9 +144,7 @@ void main() {
<!DOCTYPE html><html><base href="$kBaseHrefPlaceholder"><head></head></html>
''');
environment.buildDir.childFile('main.dart.js').createSync();
await WebReleaseBundle(<WebCompilerConfig>[
const JsCompilerConfig()
]).build(environment);
await WebTemplatedFiles('buildConfig').build(environment);
expect(environment.outputDir.childFile('index.html').readAsStringSync(), contains('/basehreftest/'));
}));
@ -159,9 +157,7 @@ void main() {
<!DOCTYPE html><html><head><base href='/basehreftest/'></head></html>
''');
environment.buildDir.childFile('main.dart.js').createSync();
await WebReleaseBundle(<WebCompilerConfig>[
const JsCompilerConfig()
]).build(environment);
await WebTemplatedFiles('build config').build(environment);
expect(environment.outputDir.childFile('index.html').readAsStringSync(), contains('/basehreftest/'));
}));
@ -169,18 +165,9 @@ void main() {
test('WebReleaseBundle copies dart2js output and resource files to output directory', () => testbed.run(() async {
environment.defines[kBuildMode] = 'release';
final Directory webResources = environment.projectDir.childDirectory('web');
webResources.childFile('index.html')
..createSync(recursive: true)
..writeAsStringSync('''
<html>
<script src="main.dart.js" type="application/javascript"></script>
<script>
navigator.serviceWorker.register('flutter_service_worker.js');
</script>
</html>
''');
webResources.childFile('foo.txt')
.writeAsStringSync('A');
..createSync(recursive: true)
..writeAsStringSync('A');
environment.buildDir.childFile('main.dart.js').createSync();
environment.buildDir.childFile('main.dart.js.map').createSync();
@ -206,11 +193,6 @@ void main() {
expect(environment.outputDir.childFile('foo.txt')
.readAsStringSync(), 'B');
// Appends number to requests for service worker only
expect(environment.outputDir.childFile('index.html').readAsStringSync(), allOf(
contains('<script src="main.dart.js" type="application/javascript">'),
contains('flutter_service_worker.js?v='),
));
}));
test('WebReleaseBundle copies over output files when they change', () => testbed.run(() async {

View file

@ -1,178 +0,0 @@
// 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 'package:flutter_tools/src/html_utils.dart';
import '../src/common.dart';
const String htmlSample1 = '''
<!DOCTYPE html>
<html>
<head>
<title></title>
<base href="/foo/222/">
<meta charset="utf-8">
<link rel="icon" type="image/png" href="favicon.png"/>
</head>
<body>
<div></div>
<script src="main.dart.js"></script>
</body>
</html>
''';
const String htmlSample2 = '''
<!DOCTYPE html>
<html>
<head>
<title></title>
<base href="$kBaseHrefPlaceholder">
<meta charset="utf-8">
<link rel="icon" type="image/png" href="favicon.png"/>
</head>
<body>
<div></div>
<script src="main.dart.js"></script>
<script>
const serviceWorkerVersion = null;
</script>
<script>
navigator.serviceWorker.register('flutter_service_worker.js');
</script>
</body>
</html>
''';
const String htmlSampleLegacyVar = '''
<!DOCTYPE html>
<html>
<head>
<title></title>
<base href="$kBaseHrefPlaceholder">
<meta charset="utf-8">
<link rel="icon" type="image/png" href="favicon.png"/>
</head>
<body>
<div></div>
<script src="main.dart.js"></script>
<script>
var serviceWorkerVersion = null;
</script>
<script>
navigator.serviceWorker.register('flutter_service_worker.js');
</script>
</body>
</html>
''';
String htmlSample2Replaced({
required String baseHref,
required String serviceWorkerVersion,
}) =>
'''
<!DOCTYPE html>
<html>
<head>
<title></title>
<base href="$baseHref">
<meta charset="utf-8">
<link rel="icon" type="image/png" href="favicon.png"/>
</head>
<body>
<div></div>
<script src="main.dart.js"></script>
<script>
const serviceWorkerVersion = "$serviceWorkerVersion";
</script>
<script>
navigator.serviceWorker.register('flutter_service_worker.js?v=$serviceWorkerVersion');
</script>
</body>
</html>
''';
const String htmlSample3 = '''
<!DOCTYPE html>
<html>
<head>
<title></title>
<meta charset="utf-8">
<link rel="icon" type="image/png" href="favicon.png"/>
</head>
<body>
<div></div>
<script src="main.dart.js"></script>
</body>
</html>
''';
void main() {
test('can parse baseHref', () {
expect(IndexHtml('<base href="/foo/111/">').getBaseHref(), 'foo/111');
expect(IndexHtml(htmlSample1).getBaseHref(), 'foo/222');
expect(IndexHtml(htmlSample2).getBaseHref(), ''); // Placeholder base href.
});
test('handles missing baseHref', () {
expect(IndexHtml('').getBaseHref(), '');
expect(IndexHtml('<base>').getBaseHref(), '');
expect(IndexHtml(htmlSample3).getBaseHref(), '');
});
test('throws on invalid baseHref', () {
expect(() => IndexHtml('<base href>').getBaseHref(), throwsToolExit());
expect(() => IndexHtml('<base href="">').getBaseHref(), throwsToolExit());
expect(() => IndexHtml('<base href="foo/111">').getBaseHref(), throwsToolExit());
expect(
() => IndexHtml('<base href="foo/111/">').getBaseHref(),
throwsToolExit(),
);
expect(
() => IndexHtml('<base href="/foo/111">').getBaseHref(),
throwsToolExit(),
);
});
test('applies substitutions', () {
final IndexHtml indexHtml = IndexHtml(htmlSample2);
indexHtml.applySubstitutions(
baseHref: '/foo/333/',
serviceWorkerVersion: 'v123xyz',
);
expect(
indexHtml.content,
htmlSample2Replaced(
baseHref: '/foo/333/',
serviceWorkerVersion: 'v123xyz',
),
);
});
test('applies substitutions with legacy var version syntax', () {
final IndexHtml indexHtml = IndexHtml(htmlSampleLegacyVar);
indexHtml.applySubstitutions(
baseHref: '/foo/333/',
serviceWorkerVersion: 'v123xyz',
);
expect(
indexHtml.content,
htmlSample2Replaced(
baseHref: '/foo/333/',
serviceWorkerVersion: 'v123xyz',
),
);
});
test('re-parses after substitutions', () {
final IndexHtml indexHtml = IndexHtml(htmlSample2);
expect(indexHtml.getBaseHref(), ''); // Placeholder base href.
indexHtml.applySubstitutions(
baseHref: '/foo/333/',
serviceWorkerVersion: 'v123xyz',
);
// The parsed base href should be updated after substitutions.
expect(indexHtml.getBaseHref(), 'foo/333');
});
}

View file

@ -17,9 +17,9 @@ import 'package:flutter_tools/src/compile.dart';
import 'package:flutter_tools/src/convert.dart';
import 'package:flutter_tools/src/devfs.dart';
import 'package:flutter_tools/src/globals.dart' as globals;
import 'package:flutter_tools/src/html_utils.dart';
import 'package:flutter_tools/src/isolated/devfs_web.dart';
import 'package:flutter_tools/src/web/compile.dart';
import 'package:flutter_tools/src/web_template.dart';
import 'package:logging/logging.dart' as logging;
import 'package:package_config/package_config.dart';
import 'package:shelf/shelf.dart';
@ -344,6 +344,11 @@ void main() {
final Directory webDir =
globals.fs.currentDirectory.childDirectory('web')..createSync();
webDir.childFile('index.html').writeAsStringSync(htmlContent);
globals.fs.file(globals.fs.path.join(
globals.artifacts!.getHostArtifact(HostArtifact.flutterJsDirectory).path,
'flutter.js',
))..createSync(recursive: true)..writeAsStringSync('flutter.js content');
final Response response = await webAssetServer.handleRequest(
Request('GET', Uri.parse('http://foobar/base/path/')));
@ -360,6 +365,10 @@ void main() {
final Directory webDir =
globals.fs.currentDirectory.childDirectory('web')..createSync();
webDir.childFile('index.html').writeAsStringSync(htmlContent);
globals.fs.file(globals.fs.path.join(
globals.artifacts!.getHostArtifact(HostArtifact.flutterJsDirectory).path,
'flutter.js',
))..createSync(recursive: true)..writeAsStringSync('flutter.js content');
final Response response = await webAssetServer
.handleRequest(Request('GET', Uri.parse('http://foobar/')));
@ -530,6 +539,10 @@ void main() {
final Directory webDir =
globals.fs.currentDirectory.childDirectory('web')..createSync();
webDir.childFile('index.html').writeAsStringSync(htmlContent);
globals.fs.file(globals.fs.path.join(
globals.artifacts!.getHostArtifact(HostArtifact.flutterJsDirectory).path,
'flutter.js',
))..createSync(recursive: true)..writeAsStringSync('flutter.js content');
final Response response = await webAssetServer.handleRequest(
Request('GET', Uri.parse('http://foobar/bar/baz')));
@ -586,6 +599,11 @@ void main() {
test(
'serves default index.html',
() => testbed.run(() async {
globals.fs.file(globals.fs.path.join(
globals.artifacts!.getHostArtifact(HostArtifact.flutterJsDirectory).path,
'flutter.js',
))..createSync(recursive: true)..writeAsStringSync('flutter.js content');
final Response response = await webAssetServer
.handleRequest(Request('GET', Uri.parse('http://foobar/')));

View file

@ -17,9 +17,9 @@ import 'package:flutter_tools/src/compile.dart';
import 'package:flutter_tools/src/convert.dart';
import 'package:flutter_tools/src/devfs.dart';
import 'package:flutter_tools/src/globals.dart' as globals;
import 'package:flutter_tools/src/html_utils.dart';
import 'package:flutter_tools/src/isolated/devfs_web.dart';
import 'package:flutter_tools/src/web/compile.dart';
import 'package:flutter_tools/src/web_template.dart';
import 'package:logging/logging.dart' as logging;
import 'package:package_config/package_config.dart';
import 'package:shelf/shelf.dart';
@ -244,6 +244,10 @@ void main() {
.childDirectory('web')
..createSync();
webDir.childFile('index.html').writeAsStringSync(htmlContent);
globals.fs.file(globals.fs.path.join(
globals.artifacts!.getHostArtifact(HostArtifact.flutterJsDirectory).path,
'flutter.js',
))..createSync(recursive: true)..writeAsStringSync('flutter.js content');
final Response response = await webAssetServer
.handleRequest(Request('GET', Uri.parse('http://foobar/base/path/')));
@ -257,6 +261,11 @@ void main() {
final Directory webDir = globals.fs.currentDirectory.childDirectory('web')
..createSync();
webDir.childFile('index.html').writeAsStringSync(htmlContent);
globals.fs.file(globals.fs.path.join(
globals.artifacts!.getHostArtifact(HostArtifact.flutterJsDirectory).path,
'flutter.js',
))..createSync(recursive: true)..writeAsStringSync('flutter.js content');
final Response response = await webAssetServer
.handleRequest(Request('GET', Uri.parse('http://foobar/')));
@ -404,6 +413,10 @@ void main() {
.childDirectory('web')
..createSync();
webDir.childFile('index.html').writeAsStringSync(htmlContent);
globals.fs.file(globals.fs.path.join(
globals.artifacts!.getHostArtifact(HostArtifact.flutterJsDirectory).path,
'flutter.js',
))..createSync(recursive: true)..writeAsStringSync('flutter.js content');
final Response response = await webAssetServer
.handleRequest(Request('GET', Uri.parse('http://foobar/bar/baz')));
@ -454,6 +467,11 @@ void main() {
}));
test('serves default index.html', () => testbed.run(() async {
globals.fs.file(globals.fs.path.join(
globals.artifacts!.getHostArtifact(HostArtifact.flutterJsDirectory).path,
'flutter.js',
))..createSync(recursive: true)..writeAsStringSync('flutter.js content');
final Response response = await webAssetServer
.handleRequest(Request('GET', Uri.parse('http://foobar/')));
@ -830,6 +848,10 @@ void main() {
.getHostArtifact(HostArtifact.webPrecompiledAmdCanvaskitSoundSdk).path;
final String webPrecompiledCanvaskitSdkSourcemaps = globals.artifacts!
.getHostArtifact(HostArtifact.webPrecompiledAmdCanvaskitSoundSdkSourcemaps).path;
final String flutterJs = globals.fs.path.join(
globals.artifacts!.getHostArtifact(HostArtifact.flutterJsDirectory).path,
'flutter.js',
);
globals.fs.file(webPrecompiledSdk)
..createSync(recursive: true)
..writeAsStringSync('HELLO');
@ -842,6 +864,9 @@ void main() {
globals.fs.file(webPrecompiledCanvaskitSdkSourcemaps)
..createSync(recursive: true)
..writeAsStringSync('CHUM');
globals.fs.file(flutterJs)
..createSync(recursive: true)
..writeAsStringSync('(flutter.js content)');
await webDevFS.update(
mainUri: globals.fs.file(globals.fs.path.join('lib', 'main.dart')).uri,

View file

@ -0,0 +1,341 @@
// 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 'package:file/file.dart';
import 'package:file/memory.dart';
import 'package:flutter_tools/src/web_template.dart';
import '../src/common.dart';
const String htmlSample1 = '''
<!DOCTYPE html>
<html>
<head>
<title></title>
<base href="/foo/222/">
<meta charset="utf-8">
<link rel="icon" type="image/png" href="favicon.png"/>
</head>
<body>
<div></div>
<script src="main.dart.js"></script>
</body>
</html>
''';
const String htmlSample2 = '''
<!DOCTYPE html>
<html>
<head>
<title></title>
<base href="$kBaseHrefPlaceholder">
<meta charset="utf-8">
<link rel="icon" type="image/png" href="favicon.png"/>
</head>
<body>
<div></div>
<script src="main.dart.js"></script>
<script>
const serviceWorkerVersion = null;
</script>
<script>
navigator.serviceWorker.register('flutter_service_worker.js');
</script>
</body>
</html>
''';
const String htmlSampleInlineFlutterJsBootstrap = '''
<!DOCTYPE html>
<html>
<head>
<title></title>
<base href="/foo/222/">
<meta charset="utf-8">
<link rel="icon" type="image/png" href="favicon.png"/>
</head>
<body>
<div></div>
<script>
{{flutter_js}}
{{flutter_build_config}}
_flutter.loader.load({
serviceWorker: {
serviceWorkerVersion: {{flutter_service_worker_version}},
},
});
</script>
</body>
</html>
''';
const String htmlSampleInlineFlutterJsBootstrapOutput = '''
<!DOCTYPE html>
<html>
<head>
<title></title>
<base href="/foo/222/">
<meta charset="utf-8">
<link rel="icon" type="image/png" href="favicon.png"/>
</head>
<body>
<div></div>
<script>
(flutter.js content)
(build config)
_flutter.loader.load({
serviceWorker: {
serviceWorkerVersion: "(service worker version)",
},
});
</script>
</body>
</html>
''';
const String htmlSampleFullFlutterBootstrapReplacement = '''
<!DOCTYPE html>
<html>
<head>
<title></title>
<base href="/foo/222/">
<meta charset="utf-8">
<link rel="icon" type="image/png" href="favicon.png"/>
</head>
<body>
<div></div>
<script>
{{flutter_bootstrap_js}}
</script>
</body>
</html>
''';
const String htmlSampleFullFlutterBootstrapReplacementOutput = '''
<!DOCTYPE html>
<html>
<head>
<title></title>
<base href="/foo/222/">
<meta charset="utf-8">
<link rel="icon" type="image/png" href="favicon.png"/>
</head>
<body>
<div></div>
<script>
(flutter bootstrap script)
</script>
</body>
</html>
''';
const String htmlSampleLegacyVar = '''
<!DOCTYPE html>
<html>
<head>
<title></title>
<base href="$kBaseHrefPlaceholder">
<meta charset="utf-8">
<link rel="icon" type="image/png" href="favicon.png"/>
</head>
<body>
<div></div>
<script src="main.dart.js"></script>
<script>
var serviceWorkerVersion = null;
</script>
<script>
navigator.serviceWorker.register('flutter_service_worker.js');
</script>
</body>
</html>
''';
const String htmlSampleLegacyLoadEntrypoint = '''
<!DOCTYPE html>
<html>
<head>
<title></title>
<base href="$kBaseHrefPlaceholder">
<meta charset="utf-8">
<link rel="icon" type="image/png" href="favicon.png"/>
<script src="flutter.js" defer></script>
</head>
<body>
<div></div>
<script>
window.addEventListener('load', function(ev) {
_flutter.loader.loadEntrypoint({
onEntrypointLoaded: function(engineInitializer) {
engineInitializer.initializeEngine().then(function(appRunner) {
appRunner.runApp();
});
});
});
</script>
</body>
</html>
''';
String htmlSample2Replaced({
required String baseHref,
required String serviceWorkerVersion,
}) =>
'''
<!DOCTYPE html>
<html>
<head>
<title></title>
<base href="$baseHref">
<meta charset="utf-8">
<link rel="icon" type="image/png" href="favicon.png"/>
</head>
<body>
<div></div>
<script src="main.dart.js"></script>
<script>
const serviceWorkerVersion = "$serviceWorkerVersion";
</script>
<script>
navigator.serviceWorker.register('flutter_service_worker.js?v=$serviceWorkerVersion');
</script>
</body>
</html>
''';
const String htmlSample3 = '''
<!DOCTYPE html>
<html>
<head>
<title></title>
<meta charset="utf-8">
<link rel="icon" type="image/png" href="favicon.png"/>
</head>
<body>
<div></div>
<script src="main.dart.js"></script>
</body>
</html>
''';
void main() {
final MemoryFileSystem fs = MemoryFileSystem();
final File flutterJs = fs.file('flutter.js');
flutterJs.writeAsStringSync('(flutter.js content)');
test('can parse baseHref', () {
expect(WebTemplate('<base href="/foo/111/">').getBaseHref(), 'foo/111');
expect(WebTemplate(htmlSample1).getBaseHref(), 'foo/222');
expect(WebTemplate(htmlSample2).getBaseHref(), ''); // Placeholder base href.
});
test('handles missing baseHref', () {
expect(WebTemplate('').getBaseHref(), '');
expect(WebTemplate('<base>').getBaseHref(), '');
expect(WebTemplate(htmlSample3).getBaseHref(), '');
});
test('throws on invalid baseHref', () {
expect(() => WebTemplate('<base href>').getBaseHref(), throwsToolExit());
expect(() => WebTemplate('<base href="">').getBaseHref(), throwsToolExit());
expect(() => WebTemplate('<base href="foo/111">').getBaseHref(), throwsToolExit());
expect(
() => WebTemplate('<base href="foo/111/">').getBaseHref(),
throwsToolExit(),
);
expect(
() => WebTemplate('<base href="/foo/111">').getBaseHref(),
throwsToolExit(),
);
});
test('applies substitutions', () {
final WebTemplate indexHtml = WebTemplate(htmlSample2);
indexHtml.applySubstitutions(
baseHref: '/foo/333/',
serviceWorkerVersion: 'v123xyz',
flutterJsFile: flutterJs,
);
expect(
indexHtml.content,
htmlSample2Replaced(
baseHref: '/foo/333/',
serviceWorkerVersion: 'v123xyz',
),
);
});
test('applies substitutions with legacy var version syntax', () {
final WebTemplate indexHtml = WebTemplate(htmlSampleLegacyVar);
indexHtml.applySubstitutions(
baseHref: '/foo/333/',
serviceWorkerVersion: 'v123xyz',
flutterJsFile: flutterJs,
);
expect(
indexHtml.content,
htmlSample2Replaced(
baseHref: '/foo/333/',
serviceWorkerVersion: 'v123xyz',
),
);
});
test('applies substitutions to inline flutter.js bootstrap script', () {
final WebTemplate indexHtml = WebTemplate(htmlSampleInlineFlutterJsBootstrap);
expect(indexHtml.getWarnings(), isEmpty);
indexHtml.applySubstitutions(
baseHref: '/',
serviceWorkerVersion: '(service worker version)',
flutterJsFile: flutterJs,
buildConfig: '(build config)',
);
expect(indexHtml.content, htmlSampleInlineFlutterJsBootstrapOutput);
});
test('applies substitutions to full flutter_bootstrap.js replacement', () {
final WebTemplate indexHtml = WebTemplate(htmlSampleFullFlutterBootstrapReplacement);
expect(indexHtml.getWarnings(), isEmpty);
indexHtml.applySubstitutions(
baseHref: '/',
serviceWorkerVersion: '(service worker version)',
flutterJsFile: flutterJs,
buildConfig: '(build config)',
flutterBootstrapJs: '(flutter bootstrap script)',
);
expect(indexHtml.content, htmlSampleFullFlutterBootstrapReplacementOutput);
});
test('re-parses after substitutions', () {
final WebTemplate indexHtml = WebTemplate(htmlSample2);
expect(indexHtml.getBaseHref(), ''); // Placeholder base href.
indexHtml.applySubstitutions(
baseHref: '/foo/333/',
serviceWorkerVersion: 'v123xyz',
flutterJsFile: flutterJs,
);
// The parsed base href should be updated after substitutions.
expect(indexHtml.getBaseHref(), 'foo/333');
});
test('warns on legacy service worker patterns', () {
final WebTemplate indexHtml = WebTemplate(htmlSampleLegacyVar);
final List<WebTemplateWarning> warnings = indexHtml.getWarnings();
expect(warnings.length, 2);
expect(warnings.where((WebTemplateWarning warning) => warning.lineNumber == 13), isNotEmpty);
expect(warnings.where((WebTemplateWarning warning) => warning.lineNumber == 16), isNotEmpty);
});
test('warns on legacy FlutterLoader.loadEntrypoint', () {
final WebTemplate indexHtml = WebTemplate(htmlSampleLegacyLoadEntrypoint);
final List<WebTemplateWarning> warnings = indexHtml.getWarnings();
expect(warnings.length, 1);
expect(warnings.single.lineNumber, 14);
});
}

View file

@ -21,6 +21,8 @@ void main() async {
await _testProject(HotReloadProject(indexHtml: indexHtmlFlutterJsPromisesShort), name: 'flutter.js (promises, short)');
await _testProject(HotReloadProject(indexHtml: indexHtmlFlutterJsLoad), name: 'flutter.js (load)');
await _testProject(HotReloadProject(indexHtml: indexHtmlNoFlutterJs), name: 'No flutter.js');
await _testProject(HotReloadProject(indexHtml: indexHtmlWithFlutterBootstrapScriptTag), name: 'Using flutter_bootstrap.js script tag');
await _testProject(HotReloadProject(indexHtml: indexHtmlWithInlinedFlutterBootstrapScript), name: 'Using inlined flutter_bootstrap.js');
}
Future<void> _testProject(HotReloadProject project, {String name = 'Default'}) async {
@ -73,8 +75,7 @@ Future<void> _testProject(HotReloadProject project, {String name = 'Default'}) a
completer.complete();
}
});
await flutter.run(chrome: true,
additionalCommandArgs: <String>['--dart-define=FLUTTER_WEB_USE_SKIA=true', '--verbose']);
await flutter.run(chrome: true, additionalCommandArgs: <String>['--verbose', '--web-renderer=canvaskit']);
project.uncommentHotReloadPrint();
try {
await flutter.hotRestart();

View file

@ -198,3 +198,53 @@ $initScript
</body>
</html>
''';
/// index.html using flutter bootstrap script
const String indexHtmlWithFlutterBootstrapScriptTag = '''
<!DOCTYPE HTML>
<!-- 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. -->
<html>
<head>
<meta charset="UTF-8">
<meta content="IE=Edge" http-equiv="X-UA-Compatible">
<title>Web Test</title>
<!-- iOS meta tags & icons -->
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black">
<meta name="apple-mobile-web-app-title" content="Web Test">
<link rel="manifest" href="manifest.json">
</head>
<body>
<script src="flutter_bootstrap.js" async></script>
</body>
</html>
''';
/// index.html using flutter bootstrap script
const String indexHtmlWithInlinedFlutterBootstrapScript = '''
<!DOCTYPE HTML>
<!-- 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. -->
<html>
<head>
<meta charset="UTF-8">
<meta content="IE=Edge" http-equiv="X-UA-Compatible">
<title>Web Test</title>
<!-- iOS meta tags & icons -->
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black">
<meta name="apple-mobile-web-app-title" content="Web Test">
<link rel="manifest" href="manifest.json">
</head>
<body>
<script>
{{flutter_bootstrap_js}}
</script>
</body>
</html>
''';