mirror of
https://github.com/flutter/flutter.git
synced 2026-02-20 02:29:02 +08:00
Reland (x2) "Output .js files as ES6 modules. (flutter#52023)" (flutter/engine#53718)
Second attempt to reland https://github.com/flutter/engine/pull/52023 Fixes since the previous reland attempt: * We need to pass the skwasm main JS URI when loading the module so that it can pass that along to the worker. Since the worker uses the workaround to allow a cross script worker, it has trouble locating the main JS URI in relation to itself in a way that actually works for dynamic imports, so passing it along fixes that issue. * Some of the Google3 tests relied on the relative default canvaskit path. Dynamic module imports seems to not handle relative paths the way we expect, so we do our own URL resolution using the URL constructor before passing it into the dynamic import API. Also cleaned up some of the other relative pathing stuff that we do around the base URI. in flutter.js
This commit is contained in:
parent
8f0cb56a60
commit
76caabd2c4
2
DEPS
2
DEPS
@ -277,7 +277,7 @@ allowed_hosts = [
|
||||
]
|
||||
|
||||
deps = {
|
||||
'src': 'https://github.com/flutter/buildroot.git' + '@' + '8c2d66fa4e6298894425f5bdd0591bc5b1154c53',
|
||||
'src': 'https://github.com/flutter/buildroot.git' + '@' + 'e265c359126b24351f534080fb22edaa159f2215',
|
||||
|
||||
'src/flutter/third_party/depot_tools':
|
||||
Var('chromium_git') + '/chromium/tools/depot_tools.git' + '@' + '580b4ff3f5cd0dcaa2eacda28cefe0f45320e8f7',
|
||||
|
||||
@ -575,6 +575,7 @@ class BrowserPlatform extends PlatformPlugin {
|
||||
// Some of our tests rely on color emoji
|
||||
useColorEmoji: true,
|
||||
canvasKitVariant: "${getCanvasKitVariant()}",
|
||||
canvasKitBaseUrl: "/canvaskit",
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
@ -3,14 +3,14 @@
|
||||
// found in the LICENSE file.
|
||||
|
||||
import { createWasmInstantiator } from "./instantiate_wasm.js";
|
||||
import { joinPathSegments } from "./utils.js";
|
||||
import { resolveUrlWithSegments } from "./utils.js";
|
||||
|
||||
export const loadCanvasKit = (deps, config, browserEnvironment, canvasKitBaseUrl) => {
|
||||
if (window.flutterCanvasKit) {
|
||||
// The user has set this global variable ahead of time, so we just return that.
|
||||
return Promise.resolve(window.flutterCanvasKit);
|
||||
}
|
||||
window.flutterCanvasKitLoaded = new Promise((resolve, reject) => {
|
||||
window.flutterCanvasKitLoaded = (async () => {
|
||||
if (window.flutterCanvasKit) {
|
||||
// The user has set this global variable ahead of time, so we just return that.
|
||||
return window.flutterCanvasKit;
|
||||
}
|
||||
const supportsChromiumCanvasKit = browserEnvironment.hasChromiumBreakIterators && browserEnvironment.hasImageCodecs;
|
||||
if (!supportsChromiumCanvasKit && config.canvasKitVariant == "chromium") {
|
||||
throw "Chromium CanvasKit variant specifically requested, but unsupported in this browser";
|
||||
@ -18,31 +18,18 @@ export const loadCanvasKit = (deps, config, browserEnvironment, canvasKitBaseUrl
|
||||
const useChromiumCanvasKit = supportsChromiumCanvasKit && (config.canvasKitVariant !== "full");
|
||||
let baseUrl = canvasKitBaseUrl;
|
||||
if (useChromiumCanvasKit) {
|
||||
baseUrl = joinPathSegments(baseUrl, "chromium");
|
||||
baseUrl = resolveUrlWithSegments(baseUrl, "chromium");
|
||||
}
|
||||
let canvasKitUrl = joinPathSegments(baseUrl, "canvaskit.js");
|
||||
let canvasKitUrl = resolveUrlWithSegments(baseUrl, "canvaskit.js");
|
||||
if (deps.flutterTT.policy) {
|
||||
canvasKitUrl = deps.flutterTT.policy.createScriptURL(canvasKitUrl);
|
||||
}
|
||||
const wasmInstantiator = createWasmInstantiator(joinPathSegments(baseUrl, "canvaskit.wasm"));
|
||||
const script = document.createElement("script");
|
||||
script.src = canvasKitUrl;
|
||||
if (config.nonce) {
|
||||
script.nonce = config.nonce;
|
||||
}
|
||||
script.addEventListener("load", async () => {
|
||||
try {
|
||||
const canvasKit = await CanvasKitInit({
|
||||
instantiateWasm: wasmInstantiator,
|
||||
});
|
||||
window.flutterCanvasKit = canvasKit;
|
||||
resolve(canvasKit);
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
const wasmInstantiator = createWasmInstantiator(resolveUrlWithSegments(baseUrl, "canvaskit.wasm"));
|
||||
const canvasKitModule = await import(canvasKitUrl);
|
||||
window.flutterCanvasKit = await canvasKitModule.default({
|
||||
instantiateWasm: wasmInstantiator,
|
||||
});
|
||||
script.addEventListener("error", reject);
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
return window.flutterCanvasKit;
|
||||
})();
|
||||
return window.flutterCanvasKitLoaded;
|
||||
}
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
// Use of this source code is governed by a BSD-style license that can be
|
||||
// found in the LICENSE file.
|
||||
|
||||
import { baseUri, joinPathSegments } from "./utils.js";
|
||||
import { resolveUrlWithSegments } from "./utils.js";
|
||||
|
||||
/**
|
||||
* Handles injecting the main Flutter web entrypoint (main.dart.js), and notifying
|
||||
@ -37,7 +37,7 @@ export class FlutterEntrypointLoader {
|
||||
* Returns undefined when an `onEntrypointLoaded` callback is supplied in `options`.
|
||||
*/
|
||||
async loadEntrypoint(options) {
|
||||
const { entrypointUrl = joinPathSegments(baseUri, "main.dart.js"), onEntrypointLoaded, nonce } =
|
||||
const { entrypointUrl = resolveUrlWithSegments("main.dart.js"), onEntrypointLoaded, nonce } =
|
||||
options || {};
|
||||
return this._loadJSEntrypoint(entrypointUrl, onEntrypointLoaded, nonce);
|
||||
}
|
||||
@ -68,7 +68,7 @@ export class FlutterEntrypointLoader {
|
||||
return this._loadWasmEntrypoint(build, deps, entryPointBaseUrl, onEntrypointLoaded);
|
||||
} else {
|
||||
const mainPath = build.mainJsPath ?? "main.dart.js";
|
||||
const entrypointUrl = joinPathSegments(baseUri, entryPointBaseUrl, mainPath);
|
||||
const entrypointUrl = resolveUrlWithSegments(entryPointBaseUrl, mainPath);
|
||||
return this._loadJSEntrypoint(entrypointUrl, onEntrypointLoaded, nonce);
|
||||
}
|
||||
}
|
||||
@ -148,8 +148,8 @@ export class FlutterEntrypointLoader {
|
||||
|
||||
this._onEntrypointLoaded = onEntrypointLoaded;
|
||||
const { mainWasmPath, jsSupportRuntimePath } = build;
|
||||
const moduleUri = joinPathSegments(baseUri, entrypointBaseUrl, mainWasmPath);
|
||||
let jsSupportRuntimeUri = joinPathSegments(baseUri, entrypointBaseUrl, jsSupportRuntimePath);
|
||||
const moduleUri = resolveUrlWithSegments(entrypointBaseUrl, mainWasmPath);
|
||||
let jsSupportRuntimeUri = resolveUrlWithSegments(entrypointBaseUrl, jsSupportRuntimePath);
|
||||
if (this._ttPolicy != null) {
|
||||
jsSupportRuntimeUri = this._ttPolicy.createScriptURL(jsSupportRuntimeUri);
|
||||
}
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
// Use of this source code is governed by a BSD-style license that can be
|
||||
// found in the LICENSE file.
|
||||
|
||||
import { baseUri, joinPathSegments } from "./utils.js";
|
||||
import { resolveUrlWithSegments } from "./utils.js";
|
||||
|
||||
/**
|
||||
* Wraps `promise` in a timeout of the given `duration` in ms.
|
||||
@ -78,7 +78,7 @@ export class FlutterServiceWorkerLoader {
|
||||
}
|
||||
const {
|
||||
serviceWorkerVersion,
|
||||
serviceWorkerUrl = joinPathSegments(baseUri, `flutter_service_worker.js?v=${serviceWorkerVersion}`),
|
||||
serviceWorkerUrl = resolveUrlWithSegments(`flutter_service_worker.js?v=${serviceWorkerVersion}`),
|
||||
timeoutMillis = 4000,
|
||||
} = settings;
|
||||
// Apply the TrustedTypes policy, if present.
|
||||
|
||||
@ -3,45 +3,35 @@
|
||||
// found in the LICENSE file.
|
||||
|
||||
import { createWasmInstantiator } from "./instantiate_wasm.js";
|
||||
import { joinPathSegments } from "./utils.js";
|
||||
import { resolveUrlWithSegments } from "./utils.js";
|
||||
|
||||
export const loadSkwasm = (deps, config, browserEnvironment, baseUrl) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
let skwasmUrl = joinPathSegments(baseUrl, "skwasm.js");
|
||||
if (deps.flutterTT.policy) {
|
||||
skwasmUrl = deps.flutterTT.policy.createScriptURL(skwasmUrl);
|
||||
}
|
||||
const wasmInstantiator = createWasmInstantiator(joinPathSegments(baseUrl, "skwasm.wasm"));
|
||||
const script = document.createElement("script");
|
||||
script.src = skwasmUrl;
|
||||
if (config.nonce) {
|
||||
script.nonce = config.nonce;
|
||||
}
|
||||
script.addEventListener("load", async () => {
|
||||
try {
|
||||
const skwasmInstance = await skwasm({
|
||||
instantiateWasm: wasmInstantiator,
|
||||
locateFile: (fileName, scriptDirectory) => {
|
||||
// When hosted via a CDN or some other url that is not the same
|
||||
// origin as the main script of the page, we will fail to create
|
||||
// a web worker with the .worker.js script. This workaround will
|
||||
// make sure that the worker JS can be loaded regardless of where
|
||||
// it is hosted.
|
||||
const url = scriptDirectory + fileName;
|
||||
if (url.endsWith(".worker.js")) {
|
||||
return URL.createObjectURL(new Blob(
|
||||
[`importScripts("${url}");`],
|
||||
{ "type": "application/javascript" }));
|
||||
}
|
||||
return url;
|
||||
}
|
||||
});
|
||||
resolve(skwasmInstance);
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
export const loadSkwasm = async (deps, config, browserEnvironment, baseUrl) => {
|
||||
const rawSkwasmUrl = resolveUrlWithSegments(baseUrl, "skwasm.js")
|
||||
let skwasmUrl = rawSkwasmUrl;
|
||||
if (deps.flutterTT.policy) {
|
||||
skwasmUrl = deps.flutterTT.policy.createScriptURL(skwasmUrl);
|
||||
}
|
||||
const wasmInstantiator = createWasmInstantiator(resolveUrlWithSegments(baseUrl, "skwasm.wasm"));
|
||||
const skwasm = await import(skwasmUrl);
|
||||
return await skwasm.default({
|
||||
instantiateWasm: wasmInstantiator,
|
||||
locateFile: (fileName, scriptDirectory) => {
|
||||
// When hosted via a CDN or some other url that is not the same
|
||||
// origin as the main script of the page, we will fail to create
|
||||
// a web worker with the .worker.js script. This workaround will
|
||||
// make sure that the worker JS can be loaded regardless of where
|
||||
// it is hosted.
|
||||
const url = scriptDirectory + fileName;
|
||||
if (url.endsWith('.worker.js')) {
|
||||
return URL.createObjectURL(new Blob(
|
||||
[`importScripts('${url}');`],
|
||||
{ 'type': 'application/javascript' }));
|
||||
}
|
||||
});
|
||||
script.addEventListener("error", reject);
|
||||
document.head.appendChild(script);
|
||||
return url;
|
||||
},
|
||||
// Because of the above workaround, the worker is just a blob and
|
||||
// can't locate the main script using a relative path to itself,
|
||||
// so we pass the main script location in.
|
||||
mainScriptUrlOrBlob: rawSkwasmUrl,
|
||||
});
|
||||
}
|
||||
|
||||
@ -2,14 +2,11 @@
|
||||
// Use of this source code is governed by a BSD-style license that can be
|
||||
// found in the LICENSE file.
|
||||
|
||||
export const baseUri = getBaseURI();
|
||||
|
||||
function getBaseURI() {
|
||||
const base = document.querySelector("base");
|
||||
return (base && base.getAttribute("href")) || "";
|
||||
export function resolveUrlWithSegments(...segments) {
|
||||
return new URL(joinPathSegments(...segments), document.baseURI).toString()
|
||||
}
|
||||
|
||||
export function joinPathSegments(...segments) {
|
||||
function joinPathSegments(...segments) {
|
||||
return segments.filter((segment) => !!segment).map((segment, i) => {
|
||||
if (i === 0) {
|
||||
return stripRightSlashes(segment);
|
||||
@ -54,5 +51,5 @@ export function getCanvaskitBaseUrl(config, buildConfig) {
|
||||
if (buildConfig.engineRevision && !buildConfig.useLocalCanvasKit) {
|
||||
return joinPathSegments("https://www.gstatic.com/flutter-canvaskit", buildConfig.engineRevision);
|
||||
}
|
||||
return "/canvaskit";
|
||||
return "canvaskit";
|
||||
}
|
||||
|
||||
@ -259,12 +259,13 @@ extension CanvasKitExtension on CanvasKit {
|
||||
);
|
||||
}
|
||||
|
||||
@JS('window.CanvasKitInit')
|
||||
external JSAny _CanvasKitInit(CanvasKitInitOptions options);
|
||||
@JS()
|
||||
@staticInterop
|
||||
class CanvasKitModule {}
|
||||
|
||||
Future<CanvasKit> CanvasKitInit(CanvasKitInitOptions options) {
|
||||
return js_util.promiseToFuture<CanvasKit>(
|
||||
_CanvasKitInit(options).toObjectShallow);
|
||||
extension CanvasKitModuleExtension on CanvasKitModule {
|
||||
@JS('default')
|
||||
external JSPromise<JSAny> defaultExport(CanvasKitInitOptions options);
|
||||
}
|
||||
|
||||
typedef LocateFileCallback = String Function(String file, String unusedBase);
|
||||
@ -3661,11 +3662,11 @@ String canvasKitWasmModuleUrl(String file, String canvasKitBase) =>
|
||||
/// Downloads the CanvasKit JavaScript, then calls `CanvasKitInit` to download
|
||||
/// and intialize the CanvasKit wasm.
|
||||
Future<CanvasKit> downloadCanvasKit() async {
|
||||
await _downloadOneOf(_canvasKitJsUrls);
|
||||
final CanvasKitModule canvasKitModule = await _downloadOneOf(_canvasKitJsUrls);
|
||||
|
||||
final CanvasKit canvasKit = await CanvasKitInit(CanvasKitInitOptions(
|
||||
final CanvasKit canvasKit = (await canvasKitModule.defaultExport(CanvasKitInitOptions(
|
||||
locateFile: createLocateFileCallback(canvasKitWasmModuleUrl),
|
||||
));
|
||||
)).toDart) as CanvasKit;
|
||||
|
||||
if (canvasKit.ParagraphBuilder.RequiresClientICU() && !browserSupportsCanvaskitChromium) {
|
||||
throw Exception(
|
||||
@ -3681,10 +3682,12 @@ Future<CanvasKit> downloadCanvasKit() async {
|
||||
/// downloads it.
|
||||
///
|
||||
/// If none of the URLs can be downloaded, throws an [Exception].
|
||||
Future<void> _downloadOneOf(Iterable<String> urls) async {
|
||||
Future<CanvasKitModule> _downloadOneOf(Iterable<String> urls) async {
|
||||
for (final String url in urls) {
|
||||
if (await _downloadCanvasKitJs(url)) {
|
||||
return;
|
||||
try {
|
||||
return await _downloadCanvasKitJs(url);
|
||||
} catch (_) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
@ -3694,36 +3697,15 @@ Future<void> _downloadOneOf(Iterable<String> urls) async {
|
||||
);
|
||||
}
|
||||
|
||||
String _resolveUrl(String url) {
|
||||
return createDomURL(url, domWindow.document.baseUri).toJSString().toDart;
|
||||
}
|
||||
|
||||
/// Downloads the CanvasKit JavaScript file at [url].
|
||||
///
|
||||
/// Returns a [Future] that completes with `true` if the CanvasKit JavaScript
|
||||
/// file was successfully downloaded, or `false` if it failed.
|
||||
Future<bool> _downloadCanvasKitJs(String url) {
|
||||
final DomHTMLScriptElement canvasKitScript =
|
||||
createDomHTMLScriptElement(configuration.nonce);
|
||||
canvasKitScript.src = createTrustedScriptUrl(url);
|
||||
|
||||
final Completer<bool> canvasKitLoadCompleter = Completer<bool>();
|
||||
|
||||
late final DomEventListener loadCallback;
|
||||
late final DomEventListener errorCallback;
|
||||
|
||||
void loadEventHandler(DomEvent _) {
|
||||
canvasKitScript.remove();
|
||||
canvasKitLoadCompleter.complete(true);
|
||||
}
|
||||
void errorEventHandler(DomEvent errorEvent) {
|
||||
canvasKitScript.remove();
|
||||
canvasKitLoadCompleter.complete(false);
|
||||
}
|
||||
|
||||
loadCallback = createDomEventListener(loadEventHandler);
|
||||
errorCallback = createDomEventListener(errorEventHandler);
|
||||
|
||||
canvasKitScript.addEventListener('load', loadCallback);
|
||||
canvasKitScript.addEventListener('error', errorCallback);
|
||||
|
||||
domDocument.head!.appendChild(canvasKitScript);
|
||||
|
||||
return canvasKitLoadCompleter.future;
|
||||
Future<CanvasKitModule> _downloadCanvasKitJs(String url) async {
|
||||
final JSAny scriptUrl = createTrustedScriptUrl(_resolveUrl(url));
|
||||
return (await importModule(scriptUrl).toDart) as CanvasKitModule;
|
||||
}
|
||||
|
||||
@ -2368,9 +2368,15 @@ extension DomPopStateEventExtension on DomPopStateEvent {
|
||||
dynamic get state => _state?.toObjectDeep;
|
||||
}
|
||||
|
||||
@JS()
|
||||
@JS('URL')
|
||||
@staticInterop
|
||||
class DomURL {}
|
||||
class DomURL {
|
||||
external factory DomURL.arg1(JSString url);
|
||||
external factory DomURL.arg2(JSString url, JSString? base);
|
||||
}
|
||||
|
||||
DomURL createDomURL(String url, [String? base]) =>
|
||||
base == null ? DomURL.arg1(url.toJS) : DomURL.arg2(url.toJS, base.toJS);
|
||||
|
||||
extension DomURLExtension on DomURL {
|
||||
@JS('createObjectURL')
|
||||
@ -2381,6 +2387,9 @@ extension DomURLExtension on DomURL {
|
||||
@JS('revokeObjectURL')
|
||||
external JSVoid _revokeObjectURL(JSString url);
|
||||
void revokeObjectURL(String url) => _revokeObjectURL(url.toJS);
|
||||
|
||||
@JS('toString')
|
||||
external JSString toJSString();
|
||||
}
|
||||
|
||||
@JS('Blob')
|
||||
@ -3383,16 +3392,16 @@ final DomTrustedTypePolicy _ttPolicy = domWindow.trustedTypes!.createPolicy(
|
||||
|
||||
/// Converts a String `url` into a [DomTrustedScriptURL] object when the
|
||||
/// Trusted Types API is available, else returns the unmodified `url`.
|
||||
Object createTrustedScriptUrl(String url) {
|
||||
JSAny createTrustedScriptUrl(String url) {
|
||||
if (domWindow.trustedTypes != null) {
|
||||
// Pass `url` through Flutter Engine's TrustedType policy.
|
||||
final DomTrustedScriptURL trustedUrl = _ttPolicy.createScriptURL(url);
|
||||
|
||||
assert(trustedUrl.url != '', 'URL: $url rejected by TrustedTypePolicy');
|
||||
|
||||
return trustedUrl;
|
||||
return trustedUrl as JSAny;
|
||||
}
|
||||
return url;
|
||||
return url.toJS;
|
||||
}
|
||||
|
||||
DomMessageChannel createDomMessageChannel() => DomMessageChannel();
|
||||
|
||||
@ -18,13 +18,6 @@ void testMain() {
|
||||
// Initialize CanvasKit...
|
||||
await bootstrapAndRunApp();
|
||||
|
||||
// CanvasKitInit should be defined...
|
||||
expect(
|
||||
js_util.hasProperty(domWindow, 'CanvasKitInit'),
|
||||
isTrue,
|
||||
reason: 'CanvasKitInit should be defined on Window',
|
||||
);
|
||||
|
||||
// window.exports and window.module should be undefined!
|
||||
expect(
|
||||
js_util.hasProperty(domWindow, 'exports'),
|
||||
|
||||
@ -56,15 +56,7 @@ Future<JsFlutterConfiguration?> bootstrapAndExtractConfig() {
|
||||
initializeEngine: ([JsFlutterConfiguration? config]) async => configCompleter.complete(config),
|
||||
runApp: () async {}
|
||||
);
|
||||
final FlutterLoader? loader = flutter?.loader;
|
||||
if (loader == null || loader.isAutoStart) {
|
||||
// TODO(jacksongardner): Unit tests under dart2wasm still use the old way which
|
||||
// doesn't invoke flutter.js directly, so we autostart here. Once dart2wasm tests
|
||||
// work with flutter.js, we can remove this code path.
|
||||
bootstrap.autoStart();
|
||||
} else {
|
||||
loader.didCreateEngineInitializer(bootstrap.prepareEngineInitializer());
|
||||
}
|
||||
flutter!.loader!.didCreateEngineInitializer(bootstrap.prepareEngineInitializer());
|
||||
|
||||
return configCompleter.future;
|
||||
}
|
||||
|
||||
@ -28,10 +28,10 @@ void testMain() {
|
||||
test('legacy constructor initializes with a Js Object', () async {
|
||||
final FlutterConfiguration config = FlutterConfiguration.legacy(
|
||||
js_util.jsify(<String, Object?>{
|
||||
'canvasKitBaseUrl': 'some_other_url/',
|
||||
'canvasKitBaseUrl': '/some_other_url/',
|
||||
}) as JsFlutterConfiguration);
|
||||
|
||||
expect(config.canvasKitBaseUrl, 'some_other_url/');
|
||||
expect(config.canvasKitBaseUrl, '/some_other_url/');
|
||||
});
|
||||
});
|
||||
|
||||
@ -39,13 +39,13 @@ void testMain() {
|
||||
test('throws assertion error if already initialized from JS', () async {
|
||||
final FlutterConfiguration config = FlutterConfiguration.legacy(
|
||||
js_util.jsify(<String, Object?>{
|
||||
'canvasKitBaseUrl': 'some_other_url/',
|
||||
'canvasKitBaseUrl': '/some_other_url/',
|
||||
}) as JsFlutterConfiguration);
|
||||
|
||||
expect(() {
|
||||
config.setUserConfiguration(
|
||||
js_util.jsify(<String, Object?>{
|
||||
'canvasKitBaseUrl': 'yet_another_url/',
|
||||
'canvasKitBaseUrl': '/yet_another_url/',
|
||||
}) as JsFlutterConfiguration);
|
||||
}, throwsAssertionError);
|
||||
});
|
||||
@ -55,10 +55,10 @@ void testMain() {
|
||||
|
||||
config.setUserConfiguration(
|
||||
js_util.jsify(<String, Object?>{
|
||||
'canvasKitBaseUrl': 'one_more_url/',
|
||||
'canvasKitBaseUrl': '/one_more_url/',
|
||||
}) as JsFlutterConfiguration);
|
||||
|
||||
expect(config.canvasKitBaseUrl, 'one_more_url/');
|
||||
expect(config.canvasKitBaseUrl, '/one_more_url/');
|
||||
});
|
||||
|
||||
test('can receive non-existing properties without crashing', () async {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user