[web] Reland: (Add `crossOrigin` property to `<img>` tag used for decoding)++ (#57228)

Relands https://github.com/flutter/engine/pull/54961 with a few more changes and tests.

Fixes https://github.com/flutter/flutter/issues/160127
diff --git a/lib/web_ui/lib/src/engine/canvaskit/image.dart b/lib/web_ui/lib/src/engine/canvaskit/image.dart
index 5b4edb6..306ff75 100644
--- a/lib/web_ui/lib/src/engine/canvaskit/image.dart
+++ b/lib/web_ui/lib/src/engine/canvaskit/image.dart
@@ -161,7 +161,7 @@
 }
 
 class CkImageElementCodec extends HtmlImageElementCodec {
-  CkImageElementCodec(super.src);
+  CkImageElementCodec(super.src, {super.chunkCallback});
 
   @override
   ui.Image createImageFromHTMLImageElement(
@@ -170,7 +170,7 @@
 }
 
 class CkImageBlobCodec extends HtmlBlobCodec {
-  CkImageBlobCodec(super.blob);
+  CkImageBlobCodec(super.blob, {super.chunkCallback});
 
   @override
   ui.Image createImageFromHTMLImageElement(
@@ -326,7 +326,7 @@
 /// requesting from URI.
 Future<ui.Codec> skiaInstantiateWebImageCodec(
     String url, ui_web.ImageCodecChunkCallback? chunkCallback) async {
-  final CkImageElementCodec imageElementCodec = CkImageElementCodec(url);
+  final CkImageElementCodec imageElementCodec = CkImageElementCodec(url, chunkCallback: chunkCallback);
   try {
     await imageElementCodec.decode();
     return imageElementCodec;
@@ -339,7 +339,7 @@
           data: list, contentType: imageType.mimeType, debugSource: url);
     } else {
       final DomBlob blob = createDomBlob(<ByteBuffer>[list.buffer]);
-      final CkImageBlobCodec codec = CkImageBlobCodec(blob);
+      final CkImageBlobCodec codec = CkImageBlobCodec(blob, chunkCallback: chunkCallback);
 
       try {
         await codec.decode();
diff --git a/lib/web_ui/lib/src/engine/dom.dart b/lib/web_ui/lib/src/engine/dom.dart
index caf4888..336ddad 100644
--- a/lib/web_ui/lib/src/engine/dom.dart
+++ b/lib/web_ui/lib/src/engine/dom.dart
@@ -990,6 +990,22 @@
   external set _height(JSNumber? value);
   set height(double? value) => _height = value?.toJS;
 
+  @JS('crossOrigin')
+  external JSString? get _crossOrigin;
+  String? get crossOrigin => _crossOrigin?.toDart;
+
+  @JS('crossOrigin')
+  external set _crossOrigin(JSString? value);
+  set crossOrigin(String? value) => _crossOrigin = value?.toJS;
+
+  @JS('decoding')
+  external JSString? get _decoding;
+  String? get decoding => _decoding?.toDart;
+
+  @JS('decoding')
+  external set _decoding(JSString? value);
+  set decoding(String? value) => _decoding = value?.toJS;
+
   @JS('decode')
   external JSPromise<JSAny?> _decode();
   Future<Object?> decode() => js_util.promiseToFuture<Object?>(_decode());
diff --git a/lib/web_ui/lib/src/engine/html_image_element_codec.dart b/lib/web_ui/lib/src/engine/html_image_element_codec.dart
index 2bd6cca..9784345 100644
--- a/lib/web_ui/lib/src/engine/html_image_element_codec.dart
+++ b/lib/web_ui/lib/src/engine/html_image_element_codec.dart
@@ -43,8 +43,13 @@
     // builders to create UI.
     chunkCallback?.call(0, 100);
     imgElement = createDomHTMLImageElement();
-    imgElement!.src = src;
-    setJsProperty<String>(imgElement!, 'decoding', 'async');
+    if (renderer is! HtmlRenderer) {
+      imgElement!.crossOrigin = 'anonymous';
+    }
+    imgElement!
+      ..decoding = 'async'
+      ..src = src;
+
 
     // Ignoring the returned future on purpose because we're communicating
     // through the `completer`.
@@ -91,7 +96,7 @@
 }
 
 abstract class HtmlBlobCodec extends HtmlImageElementCodec {
-  HtmlBlobCodec(this.blob)
+  HtmlBlobCodec(this.blob, {super.chunkCallback})
       : super(
           domWindow.URL.createObjectURL(blob),
           debugSource: 'encoded image bytes',
diff --git a/lib/web_ui/test/canvaskit/image_golden_test.dart b/lib/web_ui/test/canvaskit/image_golden_test.dart
index b5c5c60..e0681ce 100644
--- a/lib/web_ui/test/canvaskit/image_golden_test.dart
+++ b/lib/web_ui/test/canvaskit/image_golden_test.dart
@@ -253,6 +253,19 @@
       }
     });
 
+    test('crossOrigin requests cause an error', () async {
+      final String otherOrigin =
+          domWindow.location.origin.replaceAll('localhost', '127.0.0.1');
+      bool gotError = false;
+      try {
+        final ui.Codec _ = await renderer.instantiateImageCodecFromUrl(
+            Uri.parse('$otherOrigin/test_images/1x1.png'));
+      } catch (e) {
+        gotError = true;
+      }
+      expect(gotError, isTrue, reason: 'Should have got CORS error');
+    });
+
     _testCkAnimatedImage();
 
     test('isAvif', () {
diff --git a/lib/web_ui/test/html/image/html_image_element_codec_test.dart b/lib/web_ui/test/ui/image/html_image_element_codec_test.dart
similarity index 79%
rename from lib/web_ui/test/html/image/html_image_element_codec_test.dart
rename to lib/web_ui/test/ui/image/html_image_element_codec_test.dart
index bbbf9ed..772aeeb 100644
--- a/lib/web_ui/test/html/image/html_image_element_codec_test.dart
+++ b/lib/web_ui/test/ui/image/html_image_element_codec_test.dart
@@ -7,12 +7,15 @@
 
 import 'package:test/bootstrap/browser.dart';
 import 'package:test/test.dart';
+import 'package:ui/src/engine/canvaskit/image.dart';
+import 'package:ui/src/engine/dom.dart';
 import 'package:ui/src/engine/html/image.dart';
 import 'package:ui/src/engine/html_image_element_codec.dart';
 import 'package:ui/ui.dart' as ui;
 import 'package:ui/ui_web/src/ui_web.dart' as ui_web;
 
 import '../../common/test_initialization.dart';
+import '../../ui/utils.dart';
 
 void main() {
   internalBootstrapBrowserTest(() => testMain);
@@ -60,16 +63,20 @@
       expect(image.height, height);
     });
     test('loads sample image', () async {
-      final HtmlImageElementCodec codec =
-          HtmlRendererImageCodec('sample_image1.png');
+      final HtmlImageElementCodec codec = createImageElementCodec('sample_image1.png');
       final ui.FrameInfo frameInfo = await codec.getNextFrame();
+
+      expect(codec.imgElement, isNotNull);
+      expect(codec.imgElement!.src, contains('sample_image1.png'));
+      expect(codec.imgElement!.crossOrigin, isHtml ? isNull : 'anonymous');
+      expect(codec.imgElement!.decoding, 'async');
+
       expect(frameInfo.image, isNotNull);
       expect(frameInfo.image.width, 100);
       expect(frameInfo.image.toString(), '[100×100]');
     });
     test('dispose image image', () async {
-      final HtmlImageElementCodec codec =
-          HtmlRendererImageCodec('sample_image1.png');
+      final HtmlImageElementCodec codec = createImageElementCodec('sample_image1.png');
       final ui.FrameInfo frameInfo = await codec.getNextFrame();
       expect(frameInfo.image, isNotNull);
       expect(frameInfo.image.debugDisposed, isFalse);
@@ -78,7 +85,7 @@
     });
     test('provides image loading progress', () async {
       final StringBuffer buffer = StringBuffer();
-      final HtmlImageElementCodec codec = HtmlRendererImageCodec(
+      final HtmlImageElementCodec codec = createImageElementCodec(
           'sample_image1.png', chunkCallback: (int loaded, int total) {
         buffer.write('$loaded/$total,');
       });
@@ -89,7 +96,7 @@
     /// Regression test for Firefox
     /// https://github.com/flutter/flutter/issues/66412
     test('Returns nonzero natural width/height', () async {
-      final HtmlImageElementCodec codec = HtmlRendererImageCodec(
+      final HtmlImageElementCodec codec = createImageElementCodec(
           'data:image/svg+xml;base64,PHN2ZyByb2xlPSJpbWciIHZpZXdCb3g9I'
           'jAgMCAyNCAyNCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48dG'
           'l0bGU+QWJzdHJhY3QgaWNvbjwvdGl0bGU+PHBhdGggZD0iTTEyIDBjOS42MDEgMCAx'
@@ -103,7 +110,7 @@
       final ui.FrameInfo frameInfo = await codec.getNextFrame();
       expect(frameInfo.image.width, isNot(0));
     });
-  });
+  }, skip: isSkwasm);
 
   group('ImageCodecUrl', () {
     test('loads sample image from web', () async {
@@ -111,6 +118,12 @@
       final HtmlImageElementCodec codec =
           await ui_web.createImageCodecFromUrl(uri) as HtmlImageElementCodec;
       final ui.FrameInfo frameInfo = await codec.getNextFrame();
+
+      expect(codec.imgElement, isNotNull);
+      expect(codec.imgElement!.src, contains('sample_image1.png'));
+      expect(codec.imgElement!.crossOrigin, isHtml ? isNull : 'anonymous');
+      expect(codec.imgElement!.decoding, 'async');
+
       expect(frameInfo.image, isNotNull);
       expect(frameInfo.image.width, 100);
     });
@@ -124,5 +137,14 @@
       await codec.getNextFrame();
       expect(buffer.toString(), '0/100,100/100,');
     });
-  });
+  }, skip: isSkwasm);
+}
+
+HtmlImageElementCodec createImageElementCodec(
+  String src, {
+  ui_web.ImageCodecChunkCallback? chunkCallback,
+}) {
+  return isHtml
+      ? HtmlRendererImageCodec(src, chunkCallback: chunkCallback)
+      : CkImageElementCodec(src, chunkCallback: chunkCallback);
 }
diff --git a/lib/web_ui/test/html/image/sample_image1.png b/lib/web_ui/test/ui/image/sample_image1.png
similarity index 100%
rename from lib/web_ui/test/html/image/sample_image1.png
rename to lib/web_ui/test/ui/image/sample_image1.png
Binary files differ