Unverified Commit e52b777a authored by David Iglesias's avatar David Iglesias Committed by GitHub

[web] flutter.js initialization with ui.webOnlyWarmupEngine (#100177)

parent 9e77d3ef
...@@ -17,6 +17,7 @@ import '../../dart/language_version.dart'; ...@@ -17,6 +17,7 @@ import '../../dart/language_version.dart';
import '../../dart/package_map.dart'; import '../../dart/package_map.dart';
import '../../globals.dart' as globals; import '../../globals.dart' as globals;
import '../../project.dart'; import '../../project.dart';
import '../../web/flutter_js.dart' as flutter_js;
import '../build_system.dart'; import '../build_system.dart';
import '../depfile.dart'; import '../depfile.dart';
import '../exceptions.dart'; import '../exceptions.dart';
...@@ -96,7 +97,7 @@ class WebEntrypointTarget extends Target { ...@@ -96,7 +97,7 @@ class WebEntrypointTarget extends Target {
@override @override
Future<void> build(Environment environment) async { Future<void> build(Environment environment) async {
final String? targetFile = environment.defines[kTargetFile]; final String? targetFile = environment.defines[kTargetFile];
final bool hasPlugins = environment.defines[kHasWebPlugins] == 'true'; final bool hasWebPlugins = environment.defines[kHasWebPlugins] == 'true';
final Uri importUri = environment.fileSystem.file(targetFile).absolute.uri; final Uri importUri = environment.fileSystem.file(targetFile).absolute.uri;
// TODO(zanderso): support configuration of this file. // TODO(zanderso): support configuration of this file.
const String packageFile = '.packages'; const String packageFile = '.packages';
...@@ -120,48 +121,54 @@ class WebEntrypointTarget extends Target { ...@@ -120,48 +121,54 @@ class WebEntrypointTarget extends Target {
// By construction, this will only be null if the .packages file does not // By construction, this will only be null if the .packages file does not
// have an entry for the user's application or if the main file is // have an entry for the user's application or if the main file is
// outside of the lib/ directory. // outside of the lib/ directory.
final String mainImport = packageConfig.toPackageUri(importUri)?.toString() final String importedEntrypoint = packageConfig.toPackageUri(importUri)?.toString()
?? importUri.toString(); ?? importUri.toString();
String contents; String? generatedImport;
if (hasPlugins) { if (hasWebPlugins) {
final Uri generatedUri = environment.projectDir final Uri generatedUri = environment.projectDir
.childDirectory('lib') .childDirectory('lib')
.childFile('generated_plugin_registrant.dart') .childFile('generated_plugin_registrant.dart')
.absolute .absolute
.uri; .uri;
final String generatedImport = packageConfig.toPackageUri(generatedUri)?.toString() generatedImport = packageConfig.toPackageUri(generatedUri)?.toString()
?? generatedUri.toString(); ?? generatedUri.toString();
contents = ''' }
// @dart=${languageVersion.major}.${languageVersion.minor}
import 'dart:ui' as ui;
import 'package:flutter_web_plugins/flutter_web_plugins.dart';
import '$generatedImport';
import '$mainImport' as entrypoint;
Future<void> main() async {
registerPlugins(webPluginRegistrar);
await ui.webOnlyInitializePlatform();
entrypoint.main();
}
''';
} else {
contents = '''
// @dart=${languageVersion.major}.${languageVersion.minor}
import 'dart:ui' as ui;
import '$mainImport' as entrypoint; final String contents = <String>[
'// @dart=${languageVersion.major}.${languageVersion.minor}',
'// Flutter web bootstrap script for $importedEntrypoint.',
'',
"import 'dart:ui' as ui;",
"import 'dart:async';",
'',
"import '$importedEntrypoint' as entrypoint;",
if (hasWebPlugins)
"import 'package:flutter_web_plugins/flutter_web_plugins.dart';",
if (hasWebPlugins)
"import '$generatedImport';",
'',
'typedef _UnaryFunction = dynamic Function(List<String> args);',
'typedef _NullaryFunction = dynamic Function();',
'',
'Future<void> main() async {',
' await ui.webOnlyWarmupEngine(',
' runApp: () {',
' if (entrypoint.main is _UnaryFunction) {',
' return (entrypoint.main as _UnaryFunction)(<String>[]);',
' }',
' return (entrypoint.main as _NullaryFunction)();',
' },',
if (hasWebPlugins) ...<String>[
' registerPlugins: () {',
' registerPlugins(webPluginRegistrar);',
' },',
],
' );',
'}',
'',
].join('\n');
Future<void> main() async {
await ui.webOnlyInitializePlatform();
entrypoint.main();
}
''';
}
environment.buildDir.childFile('main.dart') environment.buildDir.childFile('main.dart')
.writeAsStringSync(contents); .writeAsStringSync(contents);
} }
...@@ -449,6 +456,10 @@ class WebBuiltInAssets extends Target { ...@@ -449,6 +456,10 @@ class WebBuiltInAssets extends Target {
final String targetPath = fileSystem.path.join(environment.outputDir.path, 'canvaskit', relativePath); final String targetPath = fileSystem.path.join(environment.outputDir.path, 'canvaskit', relativePath);
file.copySync(targetPath); file.copySync(targetPath);
} }
// Write the flutter.js file
final File flutterJsFile = environment.outputDir.childFile('flutter.js');
flutterJsFile.writeAsStringSync(flutter_js.generateFlutterJsFile());
} }
} }
......
...@@ -41,6 +41,7 @@ import '../vmservice.dart'; ...@@ -41,6 +41,7 @@ import '../vmservice.dart';
import '../web/bootstrap.dart'; import '../web/bootstrap.dart';
import '../web/chrome.dart'; import '../web/chrome.dart';
import '../web/compile.dart'; import '../web/compile.dart';
import '../web/flutter_js.dart' as flutter_js;
import '../web/memory_fs.dart'; import '../web/memory_fs.dart';
typedef DwdsLauncher = Future<Dwds> Function({ typedef DwdsLauncher = Future<Dwds> Function({
...@@ -809,6 +810,7 @@ class WebDevFS implements DevFS { ...@@ -809,6 +810,7 @@ class WebDevFS implements DevFS {
'stack_trace_mapper.js', stackTraceMapper.readAsBytesSync()); 'stack_trace_mapper.js', stackTraceMapper.readAsBytesSync());
webAssetServer.writeFile( webAssetServer.writeFile(
'manifest.json', '{"info":"manifest not generated in run mode."}'); 'manifest.json', '{"info":"manifest not generated in run mode."}');
webAssetServer.writeFile('flutter.js', flutter_js.generateFlutterJsFile());
webAssetServer.writeFile('flutter_service_worker.js', webAssetServer.writeFile('flutter_service_worker.js',
'// Service worker not loaded in run mode.'); '// Service worker not loaded in run mode.');
webAssetServer.writeFile( webAssetServer.writeFile(
......
...@@ -473,13 +473,19 @@ class ResidentWebRunner extends ResidentRunner { ...@@ -473,13 +473,19 @@ class ResidentWebRunner extends ResidentRunner {
'typedef _UnaryFunction = dynamic Function(List<String> args);', 'typedef _UnaryFunction = dynamic Function(List<String> args);',
'typedef _NullaryFunction = dynamic Function();', 'typedef _NullaryFunction = dynamic Function();',
'Future<void> main() async {', 'Future<void> main() async {',
if (hasWebPlugins) ' await ui.webOnlyWarmupEngine(',
' registerPlugins(webPluginRegistrar);', ' runApp: () {',
' await ui.webOnlyInitializePlatform();', ' if (entrypoint.main is _UnaryFunction) {',
' if (entrypoint.main is _UnaryFunction) {', ' return (entrypoint.main as _UnaryFunction)(<String>[]);',
' return (entrypoint.main as _UnaryFunction)(<String>[]);', ' }',
' }', ' return (entrypoint.main as _NullaryFunction)();',
' return (entrypoint.main as _NullaryFunction)();', ' },',
if (hasWebPlugins) ...<String>[
' registerPlugins: () {',
' registerPlugins(webPluginRegistrar);',
' },',
],
' );',
'}', '}',
'', '',
].join('\n'); ].join('\n');
......
// 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.
/// Generates the flutter.js file.
///
/// flutter.js should be completely static, so **do not use any parameter or
/// environment variable to generate this file**.
String generateFlutterJsFile() {
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.
/**
* This script installs service_worker.js to provide PWA functionality to
* application. For more information, see:
* https://developers.google.com/web/fundamentals/primers/service-workers
*/
if (!_flutter) {
var _flutter = {};
}
_flutter.loader = null;
(function() {
"use strict";
class FlutterLoader {
// TODO: Move the below methods to "#private" once supported by all the browsers
// we support. In the meantime, we use the "revealing module" pattern.
// Watchdog to prevent injecting the main entrypoint multiple times.
_scriptLoaded = false;
// Resolver for the pending promise returned by loadEntrypoint.
_didCreateEngineInitializerResolve = null;
/**
* Initializes the main.dart.js with/without serviceWorker.
* @param {*} options
* @returns a Promise that will eventually resolve with an EngineInitializer,
* or will be rejected with the error caused by the loader.
*/
loadEntrypoint(options) {
const {
entrypointUrl = "main.dart.js",
serviceWorker,
} = (options || {});
return this._loadWithServiceWorker(entrypointUrl, serviceWorker);
}
/**
* Resolves the promise created by loadEntrypoint. Called by Flutter.
* Needs to be weirdly bound like it is, so "this" is preserved across
* the JS <-> Flutter jumps.
* @param {*} engineInitializer
*/
didCreateEngineInitializer = (function(engineInitializer) {
if (typeof this._didCreateEngineInitializerResolve != "function") {
console.warn("Do not call didCreateEngineInitializer by hand. Start with loadEntrypoint instead.");
}
this._didCreateEngineInitializerResolve(engineInitializer);
}).bind(this);
_loadEntrypoint(entrypointUrl) {
if (this._scriptLoaded) {
return null;
}
this._scriptLoaded = true;
return new Promise((resolve, reject) => {
let scriptTag = document.createElement("script");
scriptTag.src = entrypointUrl;
scriptTag.type = "application/javascript";
this._didCreateEngineInitializerResolve = resolve; // Cache the resolve, so it can be called from Flutter.
scriptTag.addEventListener("error", reject);
document.body.append(scriptTag);
});
}
_waitForServiceWorkerActivation(serviceWorker, entrypointUrl) {
if (!serviceWorker) return;
return new Promise((resolve, _) => {
serviceWorker.addEventListener("statechange", () => {
if (serviceWorker.state == "activated") {
console.log("Installed new service worker.");
resolve(this._loadEntrypoint(entrypointUrl));
}
});
});
}
_loadWithServiceWorker(entrypointUrl, serviceWorkerOptions) {
if (!("serviceWorker" in navigator) || serviceWorkerOptions == null) {
console.warn("Service worker not supported (or configured). Falling back to plain <script> tag.", serviceWorkerOptions);
return this._loadEntrypoint(entrypointUrl);
}
const {
serviceWorkerVersion,
timeoutMillis = 4000,
} = serviceWorkerOptions;
var serviceWorkerUrl = "flutter_service_worker.js?v=" + serviceWorkerVersion;
let loader = navigator.serviceWorker.register(serviceWorkerUrl)
.then((reg) => {
if (!reg.active && (reg.installing || reg.waiting)) {
// No active web worker and we have installed or are installing
// one for the first time. Simply wait for it to activate.
return this._waitForServiceWorkerActivation(reg.installing || reg.waiting, entrypointUrl);
} else if (!reg.active.scriptURL.endsWith(serviceWorkerVersion)) {
// When the app updates the serviceWorkerVersion changes, so we
// need to ask the service worker to update.
console.log("New service worker available.");
reg.update();
return this._waitForServiceWorkerActivation(reg.installing, entrypointUrl);
} else {
// Existing service worker is still good.
console.log("Loading app from service worker.");
return this._loadEntrypoint(entrypointUrl);
}
});
// Timeout race promise
let timeout;
if (timeoutMillis > 0) {
timeout = new Promise((resolve, _) => {
setTimeout(() => {
if (!this._scriptLoaded) {
console.warn("Failed to load app from service worker. Falling back to plain <script> tag.");
resolve(this._loadEntrypoint(entrypointUrl));
}
}, timeoutMillis);
});
}
return Promise.race([loader, timeout]);
}
}
_flutter.loader = new FlutterLoader();
}());
''';
}
...@@ -31,74 +31,28 @@ ...@@ -31,74 +31,28 @@
<title>{{projectName}}</title> <title>{{projectName}}</title>
<link rel="manifest" href="manifest.json"> <link rel="manifest" href="manifest.json">
<script>
// The value below is injected by flutter build, do not touch.
var serviceWorkerVersion = null;
</script>
<!-- This script adds the flutter initialization JS code -->
<script src="flutter.js" defer></script>
</head> </head>
<body> <body>
<!-- This script installs service_worker.js to provide PWA functionality to
application. For more information, see:
https://developers.google.com/web/fundamentals/primers/service-workers -->
<script> <script>
var serviceWorkerVersion = null; window.addEventListener('load', function(ev) {
var scriptLoaded = false; // Download main.dart.js
function loadMainDartJs() { _flutter.loader.loadEntrypoint({
if (scriptLoaded) { serviceWorker: {
return; serviceWorkerVersion: serviceWorkerVersion,
} }
scriptLoaded = true; }).then(function(engineInitializer) {
var scriptTag = document.createElement('script'); return engineInitializer.initializeEngine();
scriptTag.src = 'main.dart.js'; }).then(function(appRunner) {
scriptTag.type = 'application/javascript'; return appRunner.runApp();
document.body.append(scriptTag);
}
if ('serviceWorker' in navigator) {
// Service workers are supported. Use them.
window.addEventListener('load', function () {
// Wait for registration to finish before dropping the <script> tag.
// Otherwise, the browser will load the script multiple times,
// potentially different versions.
var serviceWorkerUrl = 'flutter_service_worker.js?v=' + serviceWorkerVersion;
navigator.serviceWorker.register(serviceWorkerUrl)
.then((reg) => {
function waitForActivation(serviceWorker) {
serviceWorker.addEventListener('statechange', () => {
if (serviceWorker.state == 'activated') {
console.log('Installed new service worker.');
loadMainDartJs();
}
});
}
if (!reg.active && (reg.installing || reg.waiting)) {
// No active web worker and we have installed or are installing
// one for the first time. Simply wait for it to activate.
waitForActivation(reg.installing || reg.waiting);
} else if (!reg.active.scriptURL.endsWith(serviceWorkerVersion)) {
// When the app updates the serviceWorkerVersion changes, so we
// need to ask the service worker to update.
console.log('New service worker available.');
reg.update();
waitForActivation(reg.installing);
} else {
// Existing service worker is still good.
console.log('Loading app from service worker.');
loadMainDartJs();
}
});
// If service worker doesn't succeed in a reasonable amount of time,
// fallback to plain <script> tag.
setTimeout(() => {
if (!scriptLoaded) {
console.warn(
'Failed to load app from service worker. Falling back to plain <script> tag.',
);
loadMainDartJs();
}
}, 4000);
}); });
} else { });
// Service workers not supported. Just drop the <script> tag.
loadMainDartJs();
}
</script> </script>
</body> </body>
</html> </html>
...@@ -13,6 +13,7 @@ import 'package:flutter_tools/src/build_system/build_system.dart'; ...@@ -13,6 +13,7 @@ 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/depfile.dart';
import 'package:flutter_tools/src/build_system/targets/web.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/globals.dart' as globals;
import 'package:flutter_tools/src/web/flutter_js.dart' as flutter_js;
import '../../../src/common.dart'; import '../../../src/common.dart';
import '../../../src/fake_process_manager.dart'; import '../../../src/fake_process_manager.dart';
...@@ -82,11 +83,12 @@ void main() { ...@@ -82,11 +83,12 @@ void main() {
expect(generated, contains("import 'package:foo/generated_plugin_registrant.dart';")); expect(generated, contains("import 'package:foo/generated_plugin_registrant.dart';"));
expect(generated, contains('registerPlugins(webPluginRegistrar);')); expect(generated, contains('registerPlugins(webPluginRegistrar);'));
// Main
expect(generated, contains('entrypoint.main();'));
// Import. // Import.
expect(generated, contains("import 'package:foo/main.dart' as entrypoint;")); expect(generated, contains("import 'package:foo/main.dart' as entrypoint;"));
// Main
expect(generated, contains('ui.webOnlyWarmupEngine('));
expect(generated, contains('entrypoint.main as _'));
})); }));
test('version.json is created after release build', () => testbed.run(() async { test('version.json is created after release build', () => testbed.run(() async {
...@@ -211,11 +213,12 @@ void main() { ...@@ -211,11 +213,12 @@ void main() {
expect(generated, contains("import 'package:foo/generated_plugin_registrant.dart';")); expect(generated, contains("import 'package:foo/generated_plugin_registrant.dart';"));
expect(generated, contains('registerPlugins(webPluginRegistrar);')); expect(generated, contains('registerPlugins(webPluginRegistrar);'));
// Main
expect(generated, contains('entrypoint.main();'));
// Import. // Import.
expect(generated, contains("import 'package:foo/main.dart' as entrypoint;")); expect(generated, contains("import 'package:foo/main.dart' as entrypoint;"));
// Main
expect(generated, contains('ui.webOnlyWarmupEngine('));
expect(generated, contains('entrypoint.main as _'));
}, overrides: <Type, Generator>{ }, overrides: <Type, Generator>{
Platform: () => windows, Platform: () => windows,
})); }));
...@@ -233,8 +236,14 @@ void main() { ...@@ -233,8 +236,14 @@ void main() {
// Plugins // Plugins
expect(generated, isNot(contains("import 'package:foo/generated_plugin_registrant.dart';"))); expect(generated, isNot(contains("import 'package:foo/generated_plugin_registrant.dart';")));
expect(generated, isNot(contains('registerPlugins(webPluginRegistrar);'))); expect(generated, isNot(contains('registerPlugins(webPluginRegistrar);')));
// Import.
expect(generated, contains("import 'package:foo/main.dart' as entrypoint;"));
// Main // Main
expect(generated, contains('entrypoint.main();')); expect(generated, contains('ui.webOnlyWarmupEngine('));
expect(generated, contains('entrypoint.main as _'));
})); }));
test('WebEntrypointTarget generates an entrypoint with a language version', () => testbed.run(() async { test('WebEntrypointTarget generates an entrypoint with a language version', () => testbed.run(() async {
...@@ -279,8 +288,12 @@ void main() { ...@@ -279,8 +288,12 @@ void main() {
expect(generated, isNot(contains("import 'package:foo/generated_plugin_registrant.dart';"))); expect(generated, isNot(contains("import 'package:foo/generated_plugin_registrant.dart';")));
expect(generated, isNot(contains('registerPlugins(webPluginRegistrar);'))); expect(generated, isNot(contains('registerPlugins(webPluginRegistrar);')));
// Import.
expect(generated, contains("import 'package:foo/main.dart' as entrypoint;"));
// Main // Main
expect(generated, contains('entrypoint.main();')); expect(generated, contains('ui.webOnlyWarmupEngine('));
expect(generated, contains('entrypoint.main as _'));
})); }));
test('Dart2JSTarget calls dart2js with expected args with csp', () => testbed.run(() async { test('Dart2JSTarget calls dart2js with expected args with csp', () => testbed.run(() async {
...@@ -683,4 +696,16 @@ void main() { ...@@ -683,4 +696,16 @@ void main() {
expect(environment.outputDir.childFile('flutter_service_worker.js').readAsStringSync(), expect(environment.outputDir.childFile('flutter_service_worker.js').readAsStringSync(),
contains('"main.dart.js"')); contains('"main.dart.js"'));
})); }));
test('flutter.js is not dynamically generated', () => testbed.run(() async {
globals.fs.file('bin/cache/flutter_web_sdk/canvaskit/foo')
..createSync(recursive: true)
..writeAsStringSync('OL');
await WebBuiltInAssets(globals.fs, globals.cache).build(environment);
// No caching of source maps.
expect(environment.outputDir.childFile('flutter.js').readAsStringSync(),
equals(flutter_js.generateFlutterJsFile()));
}));
} }
...@@ -569,7 +569,7 @@ void main() { ...@@ -569,7 +569,7 @@ void main() {
final String entrypointContents = fileSystem.file(webDevFS.mainUri).readAsStringSync(); final String entrypointContents = fileSystem.file(webDevFS.mainUri).readAsStringSync();
expect(entrypointContents, contains('// Flutter web bootstrap script')); expect(entrypointContents, contains('// Flutter web bootstrap script'));
expect(entrypointContents, contains("import 'dart:ui' as ui;")); expect(entrypointContents, contains("import 'dart:ui' as ui;"));
expect(entrypointContents, contains('await ui.webOnlyInitializePlatform();')); expect(entrypointContents, contains('await ui.webOnlyWarmupEngine('));
expect(logger.statusText, contains('Restarted application in')); expect(logger.statusText, contains('Restarted application in'));
expect(result.code, 0); expect(result.code, 0);
......
...@@ -685,13 +685,14 @@ void main() { ...@@ -685,13 +685,14 @@ void main() {
expect(webDevFS.webAssetServer.getFile('stack_trace_mapper.js'), isNotNull); expect(webDevFS.webAssetServer.getFile('stack_trace_mapper.js'), isNotNull);
expect(webDevFS.webAssetServer.getFile('main.dart'), isNotNull); expect(webDevFS.webAssetServer.getFile('main.dart'), isNotNull);
expect(webDevFS.webAssetServer.getFile('manifest.json'), isNotNull); expect(webDevFS.webAssetServer.getFile('manifest.json'), isNotNull);
expect(webDevFS.webAssetServer.getFile('flutter.js'), isNotNull);
expect(webDevFS.webAssetServer.getFile('flutter_service_worker.js'), isNotNull); expect(webDevFS.webAssetServer.getFile('flutter_service_worker.js'), isNotNull);
expect(webDevFS.webAssetServer.getFile('version.json'),isNotNull); expect(webDevFS.webAssetServer.getFile('version.json'),isNotNull);
expect(await webDevFS.webAssetServer.dartSourceContents('dart_sdk.js'), 'HELLO'); expect(await webDevFS.webAssetServer.dartSourceContents('dart_sdk.js'), 'HELLO');
expect(await webDevFS.webAssetServer.dartSourceContents('dart_sdk.js.map'), 'THERE'); expect(await webDevFS.webAssetServer.dartSourceContents('dart_sdk.js.map'), 'THERE');
// Update to the SDK. // Update to the SDK.
globals.fs.file(webPrecompiledSdk).writeAsStringSync('BELLOW'); globals.fs.file(webPrecompiledSdk).writeAsStringSync('BELLOW');
// New SDK should be visible.. // New SDK should be visible..
expect(await webDevFS.webAssetServer.dartSourceContents('dart_sdk.js'), 'BELLOW'); expect(await webDevFS.webAssetServer.dartSourceContents('dart_sdk.js'), 'BELLOW');
...@@ -795,6 +796,7 @@ void main() { ...@@ -795,6 +796,7 @@ void main() {
expect(webDevFS.webAssetServer.getFile('stack_trace_mapper.js'), isNotNull); expect(webDevFS.webAssetServer.getFile('stack_trace_mapper.js'), isNotNull);
expect(webDevFS.webAssetServer.getFile('main.dart'), isNotNull); expect(webDevFS.webAssetServer.getFile('main.dart'), isNotNull);
expect(webDevFS.webAssetServer.getFile('manifest.json'), isNotNull); expect(webDevFS.webAssetServer.getFile('manifest.json'), isNotNull);
expect(webDevFS.webAssetServer.getFile('flutter.js'), isNotNull);
expect(webDevFS.webAssetServer.getFile('flutter_service_worker.js'), isNotNull); expect(webDevFS.webAssetServer.getFile('flutter_service_worker.js'), isNotNull);
expect(webDevFS.webAssetServer.getFile('version.json'), isNotNull); expect(webDevFS.webAssetServer.getFile('version.json'), isNotNull);
expect(await webDevFS.webAssetServer.dartSourceContents('dart_sdk.js'), 'HELLO'); expect(await webDevFS.webAssetServer.dartSourceContents('dart_sdk.js'), 'HELLO');
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment