diff --git a/lib/web_ui/lib/src/engine/assets.dart b/lib/web_ui/lib/src/engine/assets.dart index 3daa73cfc21e3..b200f824e4145 100644 --- a/lib/web_ui/lib/src/engine/assets.dart +++ b/lib/web_ui/lib/src/engine/assets.dart @@ -24,26 +24,48 @@ const Map testFontUrls = { /// This class downloads assets over the network. /// -/// The assets are resolved relative to [assetsDir] inside the directory -/// containing the currently executing JS script. +/// Assets are resolved relative to [assetsDir] inside the absolute base +/// specified by [assetBase] (optional). +/// +/// By default, URLs are relative to the `` of the current website. class AssetManager { - /// Initializes [AssetManager] with path to assets relative to baseUrl. - const AssetManager({this.assetsDir = _defaultAssetsDir}); + /// Initializes [AssetManager] with paths. + AssetManager({ + this.assetsDir = _defaultAssetsDir, + String? assetBase, + }) : assert( + assetBase == null || assetBase.endsWith('/'), + '`assetBase` must end with a `/` character.', + ), + _assetBase = assetBase; static const String _defaultAssetsDir = 'assets'; /// The directory containing the assets. final String assetsDir; - String? get _baseUrl { - return domWindow.document - .querySelectorAll('meta') - .where((DomElement domNode) => domInstanceOfString(domNode, - 'HTMLMetaElement')) - .map((DomElement domNode) => domNode as DomHTMLMetaElement) - .firstWhereOrNull( - (DomHTMLMetaElement element) => element.name == 'assetBase') - ?.content; + /// The absolute base URL for assets. + String? _assetBase; + + // Cache a value for `_assetBase` so we don't hit the DOM multiple times. + String get _baseUrl => _assetBase ??= _deprecatedAssetBase ?? ''; + + // Retrieves the `assetBase` value from the DOM. + // + // This warns the user and points them to the new initializeEngine style. + String? get _deprecatedAssetBase { + final DomHTMLMetaElement? meta = domWindow.document + .querySelector('meta[name=assetBase]') as DomHTMLMetaElement?; + + final String? fallbackBaseUrl = meta?.content; + + if (fallbackBaseUrl != null) { + // Warn users that they're using a deprecated configuration style... + domWindow.console.warn('The `assetBase` meta tag is now deprecated.\n' + 'Use engineInitializer.initializeEngine(config) instead.\n' + 'See: https://docs.flutter.dev/development/platform-integration/web/initialization'); + } + return fallbackBaseUrl; } /// Returns the URL to load the asset from, given the asset key. @@ -67,7 +89,7 @@ class AssetManager { if (Uri.parse(asset).hasScheme) { return Uri.encodeFull(asset); } - return Uri.encodeFull('${_baseUrl ?? ''}$assetsDir/$asset'); + return Uri.encodeFull('$_baseUrl$assetsDir/$asset'); } /// Loads an asset and returns the server response. @@ -90,7 +112,7 @@ class AssetManager { } /// An asset manager that gives fake empty responses for assets. -class WebOnlyMockAssetManager implements AssetManager { +class WebOnlyMockAssetManager extends AssetManager { /// Mock asset directory relative to base url. String defaultAssetsDir = ''; @@ -113,9 +135,6 @@ class WebOnlyMockAssetManager implements AssetManager { @override String get assetsDir => defaultAssetsDir; - @override - String get _baseUrl => ''; - @override String getAssetUrl(String asset) => asset; @@ -127,7 +146,7 @@ class WebOnlyMockAssetManager implements AssetManager { status: 200, payload: MockHttpFetchPayload( byteBuffer: _toByteData(utf8.encode(defaultAssetManifest)).buffer, - ) + ), ); } if (asset == getAssetUrl('FontManifest.json')) { @@ -136,7 +155,7 @@ class WebOnlyMockAssetManager implements AssetManager { status: 200, payload: MockHttpFetchPayload( byteBuffer: _toByteData(utf8.encode(defaultFontManifest)).buffer, - ) + ), ); } diff --git a/lib/web_ui/lib/src/engine/configuration.dart b/lib/web_ui/lib/src/engine/configuration.dart index 71944deff9eee..4261e9ea4f705 100644 --- a/lib/web_ui/lib/src/engine/configuration.dart +++ b/lib/web_ui/lib/src/engine/configuration.dart @@ -158,6 +158,32 @@ class FlutterConfiguration { // runtime. Runtime-supplied values take precedence over environment // variables. + /// The absolute base URL of the location of the `assets` directory of the app. + /// + /// This value is useful when Flutter web assets are deployed to a separate + /// domain (or subdirectory) from which the index.html is served, for example: + /// + /// * Application: https://www.my-app.com/ + /// * Flutter Assets: https://cdn.example.com/my-app/build-hash/assets/ + /// + /// The `assetBase` value would be set to: + /// + /// * `'https://cdn.example.com/my-app/build-hash/'` + /// + /// It is also useful in the case that a Flutter web application is embedded + /// into another web app, in a way that the `` tag of the index.html + /// cannot be set (because it'd break the host app), for example: + /// + /// * Application: https://www.my-app.com/ + /// * Flutter Assets: https://www.my-app.com/static/companion/flutter/assets/ + /// + /// The `assetBase` would be set to: + /// + /// * `'/static/companion/flutter/'` + /// + /// Do not confuse this configuration value with [canvasKitBaseUrl]. + String? get assetBase => _configuration?.assetBase; + /// The base URL to use when downloading the CanvasKit script and associated /// wasm. /// @@ -267,6 +293,10 @@ external JsFlutterConfiguration? get _jsConfiguration; class JsFlutterConfiguration {} extension JsFlutterConfigurationExtension on JsFlutterConfiguration { + @JS('assetBase') + external JSString? get _assetBase; + String? get assetBase => _assetBase?.toDart; + @JS('canvasKitBaseUrl') external JSString? get _canvasKitBaseUrl; String? get canvasKitBaseUrl => _canvasKitBaseUrl?.toDart; diff --git a/lib/web_ui/lib/src/engine/initialization.dart b/lib/web_ui/lib/src/engine/initialization.dart index 664bb43ac54e8..7db4df0821b93 100644 --- a/lib/web_ui/lib/src/engine/initialization.dart +++ b/lib/web_ui/lib/src/engine/initialization.dart @@ -211,7 +211,7 @@ Future initializeEngineServices({ } }; - assetManager ??= const AssetManager(); + assetManager ??= AssetManager(assetBase: configuration.assetBase); _setAssetManager(assetManager); Future initializeRendererCallback () async => renderer.initialize(); diff --git a/lib/web_ui/test/engine/assets_test.dart b/lib/web_ui/test/engine/assets_test.dart new file mode 100644 index 0000000000000..c4602f24345c1 --- /dev/null +++ b/lib/web_ui/test/engine/assets_test.dart @@ -0,0 +1,142 @@ +// Copyright 2013 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. + +@TestOn('browser') +library; + +import 'package:test/bootstrap/browser.dart'; +import 'package:test/test.dart'; +import 'package:ui/src/engine.dart'; + +import '../common/matchers.dart'; + +void main() { + internalBootstrapBrowserTest(() => testMain); +} + +void testMain() { + group('AssetManager getAssetUrl', () { + setUp(() { + // Remove the meta-tag from the environment before each test. + removeAssetBaseMeta(); + }); + + test('initializes with default values', () { + final AssetManager assets = AssetManager(); + + expect( + assets.getAssetUrl('asset.txt'), + 'assets/asset.txt', + reason: 'Default `assetsDir` is "assets".', + ); + }); + + test('assetsDir changes the directory where assets are stored', () { + final AssetManager assets = AssetManager(assetsDir: 'static'); + + expect(assets.getAssetUrl('asset.txt'), 'static/asset.txt'); + }); + + test('assetBase must end with slash', () { + expect(() { + AssetManager(assetBase: '/deployment'); + }, throwsAssertionError); + }); + + test('assetBase can be relative', () { + final AssetManager assets = AssetManager(assetBase: 'base/'); + + expect(assets.getAssetUrl('asset.txt'), 'base/assets/asset.txt'); + }); + + test('assetBase can be absolute', () { + final AssetManager assets = AssetManager( + assetBase: 'https://www.gstatic.com/my-app/', + ); + + expect( + assets.getAssetUrl('asset.txt'), + 'https://www.gstatic.com/my-app/assets/asset.txt', + ); + }); + + test('assetBase in conjunction with assetsDir, fully custom paths', () { + final AssetManager assets = AssetManager( + assetBase: '/asset/base/', + assetsDir: 'static', + ); + + expect(assets.getAssetUrl('asset.txt'), '/asset/base/static/asset.txt'); + }); + + test('Fully-qualified asset URLs are untouched', () { + final AssetManager assets = AssetManager(); + + expect( + assets.getAssetUrl('https://static.my-app.com/favicon.ico'), + 'https://static.my-app.com/favicon.ico', + ); + }); + + test('Fully-qualified asset URLs are untouched (even with assetBase)', () { + final AssetManager assets = AssetManager( + assetBase: 'https://static.my-app.com/', + ); + + expect( + assets.getAssetUrl('https://static.my-app.com/favicon.ico'), + 'https://static.my-app.com/favicon.ico', + ); + }); + }); + + group('AssetManager getAssetUrl with tag', () { + setUp(() { + removeAssetBaseMeta(); + addAssetBaseMeta('/dom/base/'); + }); + + test('reads value from DOM', () { + final AssetManager assets = AssetManager(); + + expect(assets.getAssetUrl('asset.txt'), '/dom/base/assets/asset.txt'); + }); + + test('reads value from DOM (only once!)', () { + final AssetManager firstManager = AssetManager(); + expect( + firstManager.getAssetUrl('asset.txt'), + '/dom/base/assets/asset.txt', + ); + + removeAssetBaseMeta(); + final AssetManager anotherManager = AssetManager(); + + expect( + firstManager.getAssetUrl('asset.txt'), + '/dom/base/assets/asset.txt', + reason: 'The old value of the assetBase meta should be cached.', + ); + expect(anotherManager.getAssetUrl('asset.txt'), 'assets/asset.txt'); + }); + }); +} + +/// Removes all meta-tags with name=assetBase. +void removeAssetBaseMeta() { + domWindow.document + .querySelectorAll('meta[name=assetBase]') + .forEach((DomElement element) { + element.remove(); + }); +} + +/// Adds a meta-tag with name=assetBase and the passed-in [value]. +void addAssetBaseMeta(String value) { + final DomHTMLMetaElement meta = createDomHTMLMetaElement() + ..name = 'assetBase' + ..content = value; + + domDocument.head!.append(meta); +}