浏览代码

Merge pull request #86089 from adamscott/pwa-coop-coep

Add PWA option to ensure cross-origin isolation headers on web export
Rémi Verschelde 1 年之前
父节点
当前提交
1b55fa15b0

+ 23 - 2
misc/dist/html/full-size.html

@@ -218,8 +218,29 @@ const engine = new Engine(GODOT_CONFIG);
 		threads: GODOT_THREADS_ENABLED,
 		threads: GODOT_THREADS_ENABLED,
 	});
 	});
 	if (missing.length !== 0) {
 	if (missing.length !== 0) {
-		const missingMsg = 'Error\nThe following features required to run Godot projects on the Web are missing:\n';
-		displayFailureNotice(missingMsg + missing.join('\n'));
+		if (GODOT_CONFIG['serviceWorker'] && GODOT_CONFIG['ensureCrossOriginIsolationHeaders'] && 'serviceWorker' in navigator) {
+			// There's a chance that installing the service worker would fix the issue
+			Promise.race([
+				navigator.serviceWorker.getRegistration().then((registration) => {
+					if (registration != null) {
+						return Promise.reject(new Error('Service worker already exists.'));
+					}
+					return registration;
+				}).then(() => engine.installServiceWorker()),
+				// For some reason, `getRegistration()` can stall
+				new Promise((resolve) => {
+					setTimeout(() => resolve(), 2000);
+				}),
+			]).catch((err) => {
+				console.error('Error while registering service worker:', err);
+			}).then(() => {
+				window.location.reload();
+			});
+		} else {
+			// Display the message as usual
+			const missingMsg = 'Error\nThe following features required to run Godot projects on the Web are missing:\n';
+			displayFailureNotice(missingMsg + missing.join('\n'));
+		}
 	} else {
 	} else {
 		setStatusMode('indeterminate');
 		setStatusMode('indeterminate');
 		engine.startGame({
 		engine.startGame({

+ 117 - 55
misc/dist/html/service-worker.js

@@ -3,101 +3,163 @@
 // that they need an Internet connection to run the project if desired.
 // that they need an Internet connection to run the project if desired.
 // Incrementing CACHE_VERSION will kick off the install event and force
 // Incrementing CACHE_VERSION will kick off the install event and force
 // previously cached resources to be updated from the network.
 // previously cached resources to be updated from the network.
-const CACHE_VERSION = "___GODOT_VERSION___";
-const CACHE_PREFIX = "___GODOT_NAME___-sw-cache-";
+/** @type {string} */
+const CACHE_VERSION = '___GODOT_VERSION___';
+/** @type {string} */
+const CACHE_PREFIX = '___GODOT_NAME___-sw-cache-';
 const CACHE_NAME = CACHE_PREFIX + CACHE_VERSION;
 const CACHE_NAME = CACHE_PREFIX + CACHE_VERSION;
-const OFFLINE_URL = "___GODOT_OFFLINE_PAGE___";
+/** @type {string} */
+const OFFLINE_URL = '___GODOT_OFFLINE_PAGE___';
+/** @type {boolean} */
+const ENSURE_CROSSORIGIN_ISOLATION_HEADERS = ___GODOT_ENSURE_CROSSORIGIN_ISOLATION_HEADERS___;
 // Files that will be cached on load.
 // Files that will be cached on load.
+/** @type {string[]} */
 const CACHED_FILES = ___GODOT_CACHE___;
 const CACHED_FILES = ___GODOT_CACHE___;
 // Files that we might not want the user to preload, and will only be cached on first load.
 // Files that we might not want the user to preload, and will only be cached on first load.
+/** @type {string[]} */
 const CACHABLE_FILES = ___GODOT_OPT_CACHE___;
 const CACHABLE_FILES = ___GODOT_OPT_CACHE___;
 const FULL_CACHE = CACHED_FILES.concat(CACHABLE_FILES);
 const FULL_CACHE = CACHED_FILES.concat(CACHABLE_FILES);
 
 
-self.addEventListener("install", (event) => {
-	event.waitUntil(caches.open(CACHE_NAME).then(cache => cache.addAll(CACHED_FILES)));
+self.addEventListener('install', (event) => {
+	event.waitUntil(caches.open(CACHE_NAME).then((cache) => cache.addAll(CACHED_FILES)));
 });
 });
 
 
-self.addEventListener("activate", (event) => {
+self.addEventListener('activate', (event) => {
 	event.waitUntil(caches.keys().then(
 	event.waitUntil(caches.keys().then(
 		function (keys) {
 		function (keys) {
 			// Remove old caches.
 			// Remove old caches.
-			return Promise.all(keys.filter(key => key.startsWith(CACHE_PREFIX) && key != CACHE_NAME).map(key => caches.delete(key)));
-		}).then(function () {
-			// Enable navigation preload if available.
-			return ("navigationPreload" in self.registration) ? self.registration.navigationPreload.enable() : Promise.resolve();
-		})
-	);
+			return Promise.all(keys.filter((key) => key.startsWith(CACHE_PREFIX) && key !== CACHE_NAME).map((key) => caches.delete(key)));
+		}
+	).then(function () {
+		// Enable navigation preload if available.
+		return ('navigationPreload' in self.registration) ? self.registration.navigationPreload.enable() : Promise.resolve();
+	}));
 });
 });
 
 
-async function fetchAndCache(event, cache, isCachable) {
+/**
+ * Ensures that the response has the correct COEP/COOP headers
+ * @param {Response} response
+ * @returns {Response}
+ */
+function ensureCrossOriginIsolationHeaders(response) {
+	if (response.headers.get('Cross-Origin-Embedder-Policy') === 'require-corp'
+		&& response.headers.get('Cross-Origin-Opener-Policy') === 'same-origin') {
+		return response;
+	}
+
+	const crossOriginIsolatedHeaders = new Headers(response.headers);
+	crossOriginIsolatedHeaders.set('Cross-Origin-Embedder-Policy', 'require-corp');
+	crossOriginIsolatedHeaders.set('Cross-Origin-Opener-Policy', 'same-origin');
+	const newResponse = new Response(response.body, {
+		status: response.status,
+		statusText: response.statusText,
+		headers: crossOriginIsolatedHeaders,
+	});
+
+	return newResponse;
+}
+
+/**
+ * Calls fetch and cache the result if it is cacheable
+ * @param {FetchEvent} event
+ * @param {Cache} cache
+ * @param {boolean} isCacheable
+ * @returns {Response}
+ */
+async function fetchAndCache(event, cache, isCacheable) {
 	// Use the preloaded response, if it's there
 	// Use the preloaded response, if it's there
+	/** @type { Response } */
 	let response = await event.preloadResponse;
 	let response = await event.preloadResponse;
-	if (!response) {
+	if (response == null) {
 		// Or, go over network.
 		// Or, go over network.
 		response = await self.fetch(event.request);
 		response = await self.fetch(event.request);
 	}
 	}
-	if (isCachable) {
+
+	if (ENSURE_CROSSORIGIN_ISOLATION_HEADERS) {
+		response = ensureCrossOriginIsolationHeaders(response);
+	}
+
+	if (isCacheable) {
 		// And update the cache
 		// And update the cache
 		cache.put(event.request, response.clone());
 		cache.put(event.request, response.clone());
 	}
 	}
+
 	return response;
 	return response;
 }
 }
 
 
-self.addEventListener("fetch", (event) => {
-	const isNavigate = event.request.mode === "navigate";
-	const url = event.request.url || "";
-	const referrer = event.request.referrer || "";
-	const base = referrer.slice(0, referrer.lastIndexOf("/") + 1);
-	const local = url.startsWith(base) ? url.replace(base, "") : "";
-	const isCachable = FULL_CACHE.some(v => v === local) || (base === referrer && base.endsWith(CACHED_FILES[0]));
-	if (isNavigate || isCachable) {
-		event.respondWith(async function () {
-			// Try to use cache first
-			const cache = await caches.open(CACHE_NAME);
-			if (event.request.mode === "navigate") {
-				// Check if we have full cache during HTML page request.
-				const fullCache = await Promise.all(FULL_CACHE.map(name => cache.match(name)));
-				const missing = fullCache.some(v => v === undefined);
-				if (missing) {
-					try {
-						// Try network if some cached file is missing (so we can display offline page in case).
-						return await fetchAndCache(event, cache, isCachable);
-					} catch (e) {
-						// And return the hopefully always cached offline page in case of network failure.
-						console.error("Network error: ", e);
-						return await caches.match(OFFLINE_URL);
+self.addEventListener(
+	'fetch',
+	/**
+	 * Triggered on fetch
+	 * @param {FetchEvent} event
+	 */
+	(event) => {
+		const isNavigate = event.request.mode === 'navigate';
+		const url = event.request.url || '';
+		const referrer = event.request.referrer || '';
+		const base = referrer.slice(0, referrer.lastIndexOf('/') + 1);
+		const local = url.startsWith(base) ? url.replace(base, '') : '';
+		const isCachable = FULL_CACHE.some((v) => v === local) || (base === referrer && base.endsWith(CACHED_FILES[0]));
+		if (isNavigate || isCachable) {
+			event.respondWith((async () => {
+				// Try to use cache first
+				const cache = await caches.open(CACHE_NAME);
+				if (isNavigate) {
+					// Check if we have full cache during HTML page request.
+					/** @type {Response[]} */
+					const fullCache = await Promise.all(FULL_CACHE.map((name) => cache.match(name)));
+					const missing = fullCache.some((v) => v === undefined);
+					if (missing) {
+						try {
+							// Try network if some cached file is missing (so we can display offline page in case).
+							const response = await fetchAndCache(event, cache, isCachable);
+							return response;
+						} catch (e) {
+							// And return the hopefully always cached offline page in case of network failure.
+							console.error('Network error: ', e); // eslint-disable-line no-console
+							return caches.match(OFFLINE_URL);
+						}
 					}
 					}
 				}
 				}
-			}
-			const cached = await cache.match(event.request);
-			if (cached) {
-				return cached;
-			} else {
+				let cached = await cache.match(event.request);
+				if (cached != null) {
+					if (ENSURE_CROSSORIGIN_ISOLATION_HEADERS) {
+						cached = ensureCrossOriginIsolationHeaders(cached);
+					}
+					return cached;
+				}
 				// Try network if don't have it in cache.
 				// Try network if don't have it in cache.
-				return await fetchAndCache(event, cache, isCachable);
-			}
-		}());
+				const response = await fetchAndCache(event, cache, isCachable);
+				return response;
+			})());
+		} else if (ENSURE_CROSSORIGIN_ISOLATION_HEADERS) {
+			event.respondWith((async () => {
+				let response = await fetch(event.request);
+				response = ensureCrossOriginIsolationHeaders(response);
+				return response;
+			})());
+		}
 	}
 	}
-});
+);
 
 
-self.addEventListener("message", (event) => {
+self.addEventListener('message', (event) => {
 	// No cross origin
 	// No cross origin
-	if (event.origin != self.origin) {
+	if (event.origin !== self.origin) {
 		return;
 		return;
 	}
 	}
-	const id = event.source.id || "";
-	const msg = event.data || "";
+	const id = event.source.id || '';
+	const msg = event.data || '';
 	// Ensure it's one of our clients.
 	// Ensure it's one of our clients.
 	self.clients.get(id).then(function (client) {
 	self.clients.get(id).then(function (client) {
 		if (!client) {
 		if (!client) {
 			return; // Not a valid client.
 			return; // Not a valid client.
 		}
 		}
-		if (msg === "claim") {
+		if (msg === 'claim') {
 			self.skipWaiting().then(() => self.clients.claim());
 			self.skipWaiting().then(() => self.clients.claim());
-		} else if (msg === "clear") {
+		} else if (msg === 'clear') {
 			caches.delete(CACHE_NAME);
 			caches.delete(CACHE_NAME);
-		} else if (msg === "update") {
-			self.skipWaiting().then(() => self.clients.claim()).then(() => self.clients.matchAll()).then(all => all.forEach(c => c.navigate(c.url)));
+		} else if (msg === 'update') {
+			self.skipWaiting().then(() => self.clients.claim()).then(() => self.clients.matchAll()).then((all) => all.forEach((c) => c.navigate(c.url)));
 		} else {
 		} else {
 			onClientMessage(event);
 			onClientMessage(event);
 		}
 		}

+ 14 - 0
platform/web/.eslintrc.sw.js

@@ -0,0 +1,14 @@
+module.exports = {
+	"extends": [
+		"./.eslintrc.js",
+	],
+	"rules": {
+		"no-restricted-globals": 0,
+	},
+	"globals": {
+		"onClientMessage": true,
+		"___GODOT_ENSURE_CROSSORIGIN_ISOLATION_HEADERS___": true,
+		"___GODOT_CACHE___": true,
+		"___GODOT_OPT_CACHE___": true,
+	},
+};

+ 4 - 0
platform/web/doc_classes/EditorExportPlatformWeb.xml

@@ -55,6 +55,10 @@
 		<member name="progressive_web_app/enabled" type="bool" setter="" getter="">
 		<member name="progressive_web_app/enabled" type="bool" setter="" getter="">
 			If [code]true[/code], turns this web build into a [url=https://en.wikipedia.org/wiki/Progressive_web_app]progressive web application[/url] (PWA).
 			If [code]true[/code], turns this web build into a [url=https://en.wikipedia.org/wiki/Progressive_web_app]progressive web application[/url] (PWA).
 		</member>
 		</member>
+		<member name="progressive_web_app/ensure_cross_origin_isolation_headers" type="bool" setter="" getter="">
+			When enabled, the progressive web app will make sure that each request has cross-origin isolation headers (COEP/COOP).
+			This can simplify the setup to serve the exported game.
+		</member>
 		<member name="progressive_web_app/icon_144x144" type="String" setter="" getter="">
 		<member name="progressive_web_app/icon_144x144" type="String" setter="" getter="">
 			File path to the smallest icon for this web application. If not defined, defaults to the project icon.
 			File path to the smallest icon for this web application. If not defined, defaults to the project icon.
 			[b]Note:[/b] If the icon is not 144x144, it will be automatically resized for the final build.
 			[b]Note:[/b] If the icon is not 144x144, it will be automatically resized for the final build.

+ 4 - 0
platform/web/export/export_plugin.cpp

@@ -150,6 +150,7 @@ void EditorExportPlatformWeb::_fix_html(Vector<uint8_t> &p_html, const Ref<Edito
 	config["executable"] = p_name;
 	config["executable"] = p_name;
 	config["args"] = args;
 	config["args"] = args;
 	config["fileSizes"] = p_file_sizes;
 	config["fileSizes"] = p_file_sizes;
+	config["ensureCrossOriginIsolationHeaders"] = (bool)p_preset->get("progressive_web_app/ensure_cross_origin_isolation_headers");
 
 
 	String head_include;
 	String head_include;
 	if (p_preset->get("html/export_icon")) {
 	if (p_preset->get("html/export_icon")) {
@@ -222,10 +223,12 @@ Error EditorExportPlatformWeb::_build_pwa(const Ref<EditorExportPreset> &p_prese
 	const String dir = p_path.get_base_dir();
 	const String dir = p_path.get_base_dir();
 	const String name = p_path.get_file().get_basename();
 	const String name = p_path.get_file().get_basename();
 	bool extensions = (bool)p_preset->get("variant/extensions_support");
 	bool extensions = (bool)p_preset->get("variant/extensions_support");
+	bool ensure_crossorigin_isolation_headers = (bool)p_preset->get("progressive_web_app/ensure_cross_origin_isolation_headers");
 	HashMap<String, String> replaces;
 	HashMap<String, String> replaces;
 	replaces["___GODOT_VERSION___"] = String::num_int64(OS::get_singleton()->get_unix_time()) + "|" + String::num_int64(OS::get_singleton()->get_ticks_usec());
 	replaces["___GODOT_VERSION___"] = String::num_int64(OS::get_singleton()->get_unix_time()) + "|" + String::num_int64(OS::get_singleton()->get_ticks_usec());
 	replaces["___GODOT_NAME___"] = proj_name.substr(0, 16);
 	replaces["___GODOT_NAME___"] = proj_name.substr(0, 16);
 	replaces["___GODOT_OFFLINE_PAGE___"] = name + ".offline.html";
 	replaces["___GODOT_OFFLINE_PAGE___"] = name + ".offline.html";
+	replaces["___GODOT_ENSURE_CROSSORIGIN_ISOLATION_HEADERS___"] = ensure_crossorigin_isolation_headers ? "true" : "false";
 
 
 	// Files cached during worker install.
 	// Files cached during worker install.
 	Array cache_files;
 	Array cache_files;
@@ -353,6 +356,7 @@ void EditorExportPlatformWeb::get_export_options(List<ExportOption> *r_options)
 	r_options->push_back(ExportOption(PropertyInfo(Variant::BOOL, "html/focus_canvas_on_start"), true));
 	r_options->push_back(ExportOption(PropertyInfo(Variant::BOOL, "html/focus_canvas_on_start"), true));
 	r_options->push_back(ExportOption(PropertyInfo(Variant::BOOL, "html/experimental_virtual_keyboard"), false));
 	r_options->push_back(ExportOption(PropertyInfo(Variant::BOOL, "html/experimental_virtual_keyboard"), false));
 	r_options->push_back(ExportOption(PropertyInfo(Variant::BOOL, "progressive_web_app/enabled"), false));
 	r_options->push_back(ExportOption(PropertyInfo(Variant::BOOL, "progressive_web_app/enabled"), false));
+	r_options->push_back(ExportOption(PropertyInfo(Variant::BOOL, "progressive_web_app/ensure_cross_origin_isolation_headers"), true));
 	r_options->push_back(ExportOption(PropertyInfo(Variant::STRING, "progressive_web_app/offline_page", PROPERTY_HINT_FILE, "*.html"), ""));
 	r_options->push_back(ExportOption(PropertyInfo(Variant::STRING, "progressive_web_app/offline_page", PROPERTY_HINT_FILE, "*.html"), ""));
 	r_options->push_back(ExportOption(PropertyInfo(Variant::INT, "progressive_web_app/display", PROPERTY_HINT_ENUM, "Fullscreen,Standalone,Minimal UI,Browser"), 1));
 	r_options->push_back(ExportOption(PropertyInfo(Variant::INT, "progressive_web_app/display", PROPERTY_HINT_ENUM, "Fullscreen,Standalone,Minimal UI,Browser"), 1));
 	r_options->push_back(ExportOption(PropertyInfo(Variant::INT, "progressive_web_app/orientation", PROPERTY_HINT_ENUM, "Any,Landscape,Portrait"), 0));
 	r_options->push_back(ExportOption(PropertyInfo(Variant::INT, "progressive_web_app/orientation", PROPERTY_HINT_ENUM, "Any,Landscape,Portrait"), 0));

+ 13 - 3
platform/web/js/engine/engine.js

@@ -179,9 +179,7 @@ const Engine = (function () {
 							preloader.preloadedFiles.length = 0; // Clear memory
 							preloader.preloadedFiles.length = 0; // Clear memory
 							me.rtenv['callMain'](me.config.args);
 							me.rtenv['callMain'](me.config.args);
 							initPromise = null;
 							initPromise = null;
-							if (me.config.serviceWorker && 'serviceWorker' in navigator) {
-								navigator.serviceWorker.register(me.config.serviceWorker);
-							}
+							me.installServiceWorker();
 							resolve();
 							resolve();
 						});
 						});
 					});
 					});
@@ -242,6 +240,17 @@ const Engine = (function () {
 					this.rtenv['request_quit']();
 					this.rtenv['request_quit']();
 				}
 				}
 			},
 			},
+
+			/**
+			 * Install the progressive-web app service worker.
+			 * @returns {Promise} The service worker registration promise.
+			 */
+			installServiceWorker: function () {
+				if (this.config.serviceWorker && 'serviceWorker' in navigator) {
+					return navigator.serviceWorker.register(this.config.serviceWorker);
+				}
+				return Promise.resolve();
+			},
 		};
 		};
 
 
 		Engine.prototype = proto;
 		Engine.prototype = proto;
@@ -252,6 +261,7 @@ const Engine = (function () {
 		Engine.prototype['startGame'] = Engine.prototype.startGame;
 		Engine.prototype['startGame'] = Engine.prototype.startGame;
 		Engine.prototype['copyToFS'] = Engine.prototype.copyToFS;
 		Engine.prototype['copyToFS'] = Engine.prototype.copyToFS;
 		Engine.prototype['requestQuit'] = Engine.prototype.requestQuit;
 		Engine.prototype['requestQuit'] = Engine.prototype.requestQuit;
+		Engine.prototype['installServiceWorker'] = Engine.prototype.installServiceWorker;
 		// Also expose static methods as instance methods
 		// Also expose static methods as instance methods
 		Engine.prototype['load'] = Engine.load;
 		Engine.prototype['load'] = Engine.load;
 		Engine.prototype['unload'] = Engine.unload;
 		Engine.prototype['unload'] = Engine.unload;

+ 5 - 3
platform/web/package.json

@@ -5,18 +5,20 @@
   "description": "Development and linting setup for Godot's Web platform code",
   "description": "Development and linting setup for Godot's Web platform code",
   "scripts": {
   "scripts": {
     "docs": "jsdoc --template js/jsdoc2rst/ js/engine/engine.js js/engine/config.js js/engine/features.js --destination ''",
     "docs": "jsdoc --template js/jsdoc2rst/ js/engine/engine.js js/engine/config.js js/engine/features.js --destination ''",
-    "lint": "npm run lint:engine && npm run lint:libs && npm run lint:modules && npm run lint:tools && npm run lint:html",
+    "lint": "npm run lint:engine && npm run lint:libs && npm run lint:modules && npm run lint:tools && npm run lint:sw && npm run lint:html",
     "lint:engine": "eslint \"js/engine/*.js\" --no-eslintrc -c .eslintrc.engine.js",
     "lint:engine": "eslint \"js/engine/*.js\" --no-eslintrc -c .eslintrc.engine.js",
+    "lint:sw": "eslint \"../../misc/dist/html/service-worker.js\" --no-eslintrc -c .eslintrc.sw.js",
     "lint:libs": "eslint \"js/libs/*.js\" --no-eslintrc -c .eslintrc.libs.js",
     "lint:libs": "eslint \"js/libs/*.js\" --no-eslintrc -c .eslintrc.libs.js",
     "lint:modules": "eslint \"../../modules/**/*.js\" --no-eslintrc -c .eslintrc.libs.js",
     "lint:modules": "eslint \"../../modules/**/*.js\" --no-eslintrc -c .eslintrc.libs.js",
     "lint:tools": "eslint \"js/jsdoc2rst/**/*.js\" --no-eslintrc -c .eslintrc.engine.js",
     "lint:tools": "eslint \"js/jsdoc2rst/**/*.js\" --no-eslintrc -c .eslintrc.engine.js",
     "lint:html": "eslint \"../../misc/dist/html/*.html\" --no-eslintrc -c .eslintrc.html.js",
     "lint:html": "eslint \"../../misc/dist/html/*.html\" --no-eslintrc -c .eslintrc.html.js",
-    "format": "npm run format:engine && npm run format:libs && npm run format:modules && npm run format:tools && npm run format:html",
+    "format": "npm run format:engine && npm run format:libs && npm run format:modules && npm run format:tools && format:sw && npm run format:html",
     "format:engine": "npm run lint:engine -- --fix",
     "format:engine": "npm run lint:engine -- --fix",
     "format:libs": "npm run lint:libs -- --fix",
     "format:libs": "npm run lint:libs -- --fix",
     "format:modules": "npm run lint:modules -- --fix",
     "format:modules": "npm run lint:modules -- --fix",
     "format:tools": "npm run lint:tools -- --fix",
     "format:tools": "npm run lint:tools -- --fix",
-    "format:html": "npm run lint:html -- --fix"
+    "format:html": "npm run lint:html -- --fix",
+    "format:sw": "npm run lint:sw -- --fix"
   },
   },
   "author": "Godot Engine contributors",
   "author": "Godot Engine contributors",
   "license": "MIT",
   "license": "MIT",