Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add PWA option to ensure cross-origin isolation headers on web export #86089

Merged
merged 1 commit into from
Feb 12, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 23 additions & 2 deletions misc/dist/html/full-size.html
Original file line number Diff line number Diff line change
Expand Up @@ -218,8 +218,29 @@
threads: GODOT_THREADS_ENABLED,
});
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'));
}
Comment on lines +221 to +243
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should move this to a function of platform/js/engine/engine.js like we did for Engine.getMissingFeatures.

This comment was marked as outdated.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll keep the code as is, finally. We would need to pass in parameters to that function the config and the engine variable, which seems a little bit excessive.

} else {
setStatusMode('indeterminate');
engine.startGame({
Expand Down
172 changes: 117 additions & 55 deletions misc/dist/html/service-worker.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,101 +3,163 @@
// that they need an Internet connection to run the project if desired.
// Incrementing CACHE_VERSION will kick off the install event and force
// 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 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.
/** @type {string[]} */
const CACHED_FILES = ___GODOT_CACHE___;
// 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 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(
function (keys) {
// 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
/** @type { Response } */
let response = await event.preloadResponse;
if (!response) {
if (response == null) {
// Or, go over network.
response = await self.fetch(event.request);
}
if (isCachable) {

if (ENSURE_CROSSORIGIN_ISOLATION_HEADERS) {
response = ensureCrossOriginIsolationHeaders(response);
}

if (isCacheable) {
// And update the cache
cache.put(event.request, response.clone());
}

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.
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
if (event.origin != self.origin) {
if (event.origin !== self.origin) {
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.
self.clients.get(id).then(function (client) {
if (!client) {
return; // Not a valid client.
}
if (msg === "claim") {
if (msg === 'claim') {
self.skipWaiting().then(() => self.clients.claim());
} else if (msg === "clear") {
} else if (msg === 'clear') {
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 {
onClientMessage(event);
}
Expand Down
14 changes: 14 additions & 0 deletions platform/web/.eslintrc.sw.js
Original file line number Diff line number Diff line change
@@ -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 changes: 4 additions & 0 deletions platform/web/doc_classes/EditorExportPlatformWeb.xml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@
</member>
<member name="progressive_web_app/enabled" type="bool" setter="" getter="">
</member>
<member name="progressive_web_app/ensure_cross_origin_isolation_headers" type="bool" setter="" getter="">
adamscott marked this conversation as resolved.
Show resolved Hide resolved
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>
<member name="progressive_web_app/icon_180x180" type="String" setter="" getter="">
Expand Down
4 changes: 4 additions & 0 deletions platform/web/export/export_plugin.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ void EditorExportPlatformWeb::_fix_html(Vector<uint8_t> &p_html, const Ref<Edito
config["executable"] = p_name;
config["args"] = args;
config["fileSizes"] = p_file_sizes;
config["ensureCrossOriginIsolationHeaders"] = (bool)p_preset->get("progressive_web_app/ensure_cross_origin_isolation_headers");

String head_include;
if (p_preset->get("html/export_icon")) {
Expand Down Expand Up @@ -222,10 +223,12 @@ Error EditorExportPlatformWeb::_build_pwa(const Ref<EditorExportPreset> &p_prese
const String dir = p_path.get_base_dir();
const String name = p_path.get_file().get_basename();
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;
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_OFFLINE_PAGE___"] = name + ".offline.html";
replaces["___GODOT_ENSURE_CROSSORIGIN_ISOLATION_HEADERS___"] = ensure_crossorigin_isolation_headers ? "true" : "false";

// Files cached during worker install.
Array cache_files;
Expand Down Expand Up @@ -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/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/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::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));
Expand Down
16 changes: 13 additions & 3 deletions platform/web/js/engine/engine.js
Original file line number Diff line number Diff line change
Expand Up @@ -179,9 +179,7 @@ const Engine = (function () {
preloader.preloadedFiles.length = 0; // Clear memory
me.rtenv['callMain'](me.config.args);
initPromise = null;
if (me.config.serviceWorker && 'serviceWorker' in navigator) {
navigator.serviceWorker.register(me.config.serviceWorker);
}
me.installServiceWorker();
resolve();
});
});
Expand Down Expand Up @@ -242,6 +240,17 @@ const Engine = (function () {
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;
Expand All @@ -252,6 +261,7 @@ const Engine = (function () {
Engine.prototype['startGame'] = Engine.prototype.startGame;
Engine.prototype['copyToFS'] = Engine.prototype.copyToFS;
Engine.prototype['requestQuit'] = Engine.prototype.requestQuit;
Engine.prototype['installServiceWorker'] = Engine.prototype.installServiceWorker;
// Also expose static methods as instance methods
Engine.prototype['load'] = Engine.load;
Engine.prototype['unload'] = Engine.unload;
Expand Down
8 changes: 5 additions & 3 deletions platform/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,20 @@
"description": "Development and linting setup for Godot's Web platform code",
"scripts": {
"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: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:modules": "eslint \"../../modules/**/*.js\" --no-eslintrc -c .eslintrc.libs.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",
"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",
Faless marked this conversation as resolved.
Show resolved Hide resolved
"format:engine": "npm run lint:engine -- --fix",
"format:libs": "npm run lint:libs -- --fix",
"format:modules": "npm run lint:modules -- --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",
"license": "MIT",
Expand Down
Loading