mirror of
https://github.com/4ian/GDevelop.git
synced 2025-10-15 10:19:04 +00:00
Compare commits
13 Commits
d474c2a47e
...
cursor/imp
Author | SHA1 | Date | |
---|---|---|---|
![]() |
8bc243171a | ||
![]() |
747571958d | ||
![]() |
47cd3425ef | ||
![]() |
cac2b8f6e5 | ||
![]() |
47c75bab99 | ||
![]() |
a05b0cd0ef | ||
![]() |
c87b83806e | ||
![]() |
96ca91e35e | ||
![]() |
d41998ec1b | ||
![]() |
f917d1597e | ||
![]() |
b6cad244fc | ||
![]() |
90cadac9d8 | ||
![]() |
03cd24d723 |
@@ -33,6 +33,49 @@ namespace gdjs {
|
||||
claimSecret?: string;
|
||||
};
|
||||
|
||||
// Rolling window rate limiting
|
||||
// Implements rate limiting to prevent abuse:
|
||||
// - Maximum 12 successful successful entries per minute across all leaderboards
|
||||
// - Maximum 6 successful successful entries per minute per individual leaderboard
|
||||
// - Works in addition to existing 500ms cooldown between entry tentatives
|
||||
let _successfulEntriesGlobal: number[] = []; // Timestamps of successful entries across all leaderboards
|
||||
|
||||
const GLOBAL_RATE_LIMIT_COUNT = 12;
|
||||
const PER_LEADERBOARD_RATE_LIMIT_COUNT = 6;
|
||||
const RATE_LIMIT_WINDOW_MS = 60 * 1000; // 1 minute in milliseconds
|
||||
|
||||
/**
|
||||
* Clean old entries from the rolling window (older than 1 minute)
|
||||
*/
|
||||
const cleanOldEntries = (
|
||||
entries: number[],
|
||||
currentTime: number
|
||||
): number[] => {
|
||||
return entries.filter(
|
||||
(timestamp) => currentTime - timestamp < RATE_LIMIT_WINDOW_MS
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if adding a new entry would exceed global rate limits.
|
||||
*/
|
||||
const wouldExceedGlobalSuccessRateLimit = (): boolean => {
|
||||
const currentTime = Date.now();
|
||||
_successfulEntriesGlobal = cleanOldEntries(
|
||||
_successfulEntriesGlobal,
|
||||
currentTime
|
||||
);
|
||||
return _successfulEntriesGlobal.length >= GLOBAL_RATE_LIMIT_COUNT;
|
||||
};
|
||||
|
||||
/**
|
||||
* Record a successful entry for global rate limiting tracking.
|
||||
*/
|
||||
const recordGlobalSuccessfulEntry = (): void => {
|
||||
const currentTime = Date.now();
|
||||
_successfulEntriesGlobal.push(currentTime);
|
||||
};
|
||||
|
||||
/**
|
||||
* Hold the state of the save of a score for a leaderboard.
|
||||
*/
|
||||
@@ -43,6 +86,9 @@ namespace gdjs {
|
||||
/** The promise that will be resolved when the score saving is done (successfully or not). */
|
||||
lastSavingPromise: Promise<void> | null = null;
|
||||
|
||||
/** Timestamps of successful entries for this leaderboard (for rate limiting) */
|
||||
private _successfulEntries: number[] = [];
|
||||
|
||||
// Score that is being saved:
|
||||
private _currentlySavingScore: number | null = null;
|
||||
private _currentlySavingPlayerName: string | null = null;
|
||||
@@ -107,13 +153,36 @@ namespace gdjs {
|
||||
);
|
||||
}
|
||||
|
||||
private _isTooSoonToSaveAnotherScore(): boolean {
|
||||
private _wouldExceedPerLeaderboardTentativeRateLimit(): boolean {
|
||||
// Prevent entries within 500ms of each other (per leaderboard)
|
||||
// as this would indicate surely a score saved every frame.
|
||||
//
|
||||
// Note that is on lastScoreSavingStartedAt, not lastScoreSavingSucceededAt,
|
||||
// which means we limit tentatives here (and not successes).
|
||||
return (
|
||||
!!this.lastScoreSavingStartedAt &&
|
||||
Date.now() - this.lastScoreSavingStartedAt < 500
|
||||
);
|
||||
}
|
||||
|
||||
private _wouldExceedPerLeaderboardSuccessRateLimit(): boolean {
|
||||
const currentTime = Date.now();
|
||||
this._successfulEntries = cleanOldEntries(
|
||||
this._successfulEntries,
|
||||
currentTime
|
||||
);
|
||||
return (
|
||||
this._successfulEntries.length >= PER_LEADERBOARD_RATE_LIMIT_COUNT
|
||||
);
|
||||
}
|
||||
|
||||
private _recordPerLeaderboardAndGlobalSuccessfulEntry(): void {
|
||||
const currentTime = Date.now();
|
||||
this._successfulEntries.push(currentTime);
|
||||
|
||||
recordGlobalSuccessfulEntry();
|
||||
}
|
||||
|
||||
startSaving({
|
||||
playerName,
|
||||
playerId,
|
||||
@@ -141,7 +210,7 @@ namespace gdjs {
|
||||
throw new Error('Ignoring this saving request.');
|
||||
}
|
||||
|
||||
if (this._isTooSoonToSaveAnotherScore()) {
|
||||
if (this._wouldExceedPerLeaderboardTentativeRateLimit()) {
|
||||
logger.warn(
|
||||
'Last entry was sent too little time ago. Ignoring this one.'
|
||||
);
|
||||
@@ -154,6 +223,24 @@ namespace gdjs {
|
||||
throw new Error('Ignoring this saving request.');
|
||||
}
|
||||
|
||||
// Rolling window rate limiting check for successful entries.
|
||||
if (wouldExceedGlobalSuccessRateLimit()) {
|
||||
logger.warn(
|
||||
'Rate limit exceeded. Too many entries have been successfully sent recently across all leaderboards. Ignoring this one.'
|
||||
);
|
||||
this._setError('GLOBAL_RATE_LIMIT_EXCEEDED');
|
||||
|
||||
throw new Error('Ignoring this saving request.');
|
||||
}
|
||||
if (this._wouldExceedPerLeaderboardSuccessRateLimit()) {
|
||||
logger.warn(
|
||||
'Rate limit exceeded. Too many entries have been successfully sent recently for this leaderboard. Ignoring this one.'
|
||||
);
|
||||
this._setError('LEADERBOARD_RATE_LIMIT_EXCEEDED');
|
||||
|
||||
throw new Error('Ignoring this saving request.');
|
||||
}
|
||||
|
||||
let resolveSavingPromise: () => void;
|
||||
const savingPromise = new Promise<void>((resolve) => {
|
||||
resolveSavingPromise = resolve;
|
||||
@@ -169,6 +256,9 @@ namespace gdjs {
|
||||
|
||||
return {
|
||||
closeSaving: (leaderboardEntry) => {
|
||||
// Record successful entry for rolling window rate limiting.
|
||||
this._recordPerLeaderboardAndGlobalSuccessfulEntry();
|
||||
|
||||
if (savingPromise !== this.lastSavingPromise) {
|
||||
logger.info(
|
||||
'Score saving result received, but another save was launched in the meantime - ignoring the result of this one.'
|
||||
@@ -396,7 +486,10 @@ namespace gdjs {
|
||||
|
||||
try {
|
||||
const { closeSaving, closeSavingWithError } =
|
||||
scoreSavingState.startSaving({ playerName, score });
|
||||
scoreSavingState.startSaving({
|
||||
playerName,
|
||||
score,
|
||||
});
|
||||
|
||||
try {
|
||||
const leaderboardEntry = await saveScore({
|
||||
@@ -440,7 +533,10 @@ namespace gdjs {
|
||||
|
||||
try {
|
||||
const { closeSaving, closeSavingWithError } =
|
||||
scoreSavingState.startSaving({ playerId, score });
|
||||
scoreSavingState.startSaving({
|
||||
playerId,
|
||||
score,
|
||||
});
|
||||
|
||||
try {
|
||||
const leaderboardEntryId = await saveScore({
|
||||
|
1
newIDE/app/.gitignore
vendored
1
newIDE/app/.gitignore
vendored
@@ -1,5 +1,6 @@
|
||||
# Generated files
|
||||
src/Version/VersionMetadata.js
|
||||
public/service-worker.js
|
||||
|
||||
# dependencies
|
||||
node_modules
|
||||
|
55
newIDE/app/package-lock.json
generated
55
newIDE/app/package-lock.json
generated
@@ -33,7 +33,7 @@
|
||||
"path-browserify": "^1.0.1",
|
||||
"pixi-spine": "4.0.4",
|
||||
"pixi.js-legacy": "7.4.2",
|
||||
"posthog-js": "^1.57.2",
|
||||
"posthog-js": "1.275.2",
|
||||
"prop-types": "^15.5.10",
|
||||
"qr-creator": "^1.0.0",
|
||||
"react": "16.14.0",
|
||||
@@ -5770,6 +5770,11 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/@posthog/core": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.3.0.tgz",
|
||||
"integrity": "sha512-hxLL8kZNHH098geedcxCz8y6xojkNYbmJEW+1vFXsmPcExyCXIUUJ/34X6xa9GcprKxd0Wsx3vfJQLQX4iVPhw=="
|
||||
},
|
||||
"node_modules/@protobufjs/aspromise": {
|
||||
"version": "1.1.2",
|
||||
"license": "BSD-3-Clause"
|
||||
@@ -25646,16 +25651,43 @@
|
||||
}
|
||||
},
|
||||
"node_modules/posthog-js": {
|
||||
"version": "1.57.2",
|
||||
"license": "MIT",
|
||||
"version": "1.275.2",
|
||||
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.275.2.tgz",
|
||||
"integrity": "sha512-g1fnV/GAcEdwwk4EVbJ1HMZhlhgKYxG1Z5KPGvr+q5re0ltyVq8jFA2PsF333jvOlI8R01LLdpYSIgU8sBiZfg==",
|
||||
"dependencies": {
|
||||
"fflate": "^0.4.1",
|
||||
"rrweb-snapshot": "^1.1.14"
|
||||
"@posthog/core": "1.3.0",
|
||||
"core-js": "^3.38.1",
|
||||
"fflate": "^0.4.8",
|
||||
"preact": "^10.19.3",
|
||||
"web-vitals": "^4.2.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@rrweb/types": "2.0.0-alpha.17",
|
||||
"rrweb-snapshot": "2.0.0-alpha.17"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@rrweb/types": {
|
||||
"optional": true
|
||||
},
|
||||
"rrweb-snapshot": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/posthog-js/node_modules/core-js": {
|
||||
"version": "3.46.0",
|
||||
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.46.0.tgz",
|
||||
"integrity": "sha512-vDMm9B0xnqqZ8uSBpZ8sNtRtOdmfShrvT6h2TuQGLs0Is+cR0DYbj/KWP6ALVNbWPpqA/qPLoOuppJN07humpA==",
|
||||
"hasInstallScript": true,
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/core-js"
|
||||
}
|
||||
},
|
||||
"node_modules/preact": {
|
||||
"version": "10.13.1",
|
||||
"license": "MIT",
|
||||
"version": "10.27.2",
|
||||
"resolved": "https://registry.npmjs.org/preact/-/preact-10.27.2.tgz",
|
||||
"integrity": "sha512-5SYSgFKSyhCbk6SrXyMpqjb5+MQBgfvEKE/OC+PujcY34sOpqtr+0AZQtPYx5IA6VxynQ7rUPCtKzyovpj9Bpg==",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/preact"
|
||||
@@ -30091,10 +30123,6 @@
|
||||
"node": ">= 10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/rrweb-snapshot": {
|
||||
"version": "1.1.14",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/rtl-css-js": {
|
||||
"version": "1.14.0",
|
||||
"license": "MIT",
|
||||
@@ -33106,6 +33134,11 @@
|
||||
"defaults": "^1.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/web-vitals": {
|
||||
"version": "4.2.4",
|
||||
"resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-4.2.4.tgz",
|
||||
"integrity": "sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw=="
|
||||
},
|
||||
"node_modules/webidl-conversions": {
|
||||
"version": "6.1.0",
|
||||
"dev": true,
|
||||
|
@@ -69,7 +69,7 @@
|
||||
"path-browserify": "^1.0.1",
|
||||
"pixi-spine": "4.0.4",
|
||||
"pixi.js-legacy": "7.4.2",
|
||||
"posthog-js": "^1.57.2",
|
||||
"posthog-js": "1.275.2",
|
||||
"prop-types": "^15.5.10",
|
||||
"qr-creator": "^1.0.0",
|
||||
"react": "16.14.0",
|
||||
|
@@ -2,7 +2,7 @@ const fs = require('fs');
|
||||
const path = require('path');
|
||||
const shell = require('shelljs');
|
||||
const workboxBuild = require('workbox-build');
|
||||
const buildPath = '../build';
|
||||
const buildPath = '../public';
|
||||
const VersionMetadata = require('../src/Version/VersionMetadata');
|
||||
|
||||
/**
|
||||
@@ -46,7 +46,7 @@ const buildSW = () => {
|
||||
return workboxBuild
|
||||
.injectManifest({
|
||||
swSrc: 'service-worker-template/service-worker-template.js',
|
||||
swDest: '../build/service-worker.js',
|
||||
swDest: '../public/service-worker.js',
|
||||
globDirectory: buildPath,
|
||||
globPatterns: [
|
||||
// Application:
|
||||
@@ -85,13 +85,13 @@ const buildSW = () => {
|
||||
.then(({ count, size, warnings }) => {
|
||||
if (
|
||||
!replaceInFile(
|
||||
'../build/service-worker.js',
|
||||
'../public/service-worker.js',
|
||||
'VersionMetadata = {}',
|
||||
'VersionMetadata = ' + JSON.stringify(VersionMetadata)
|
||||
)
|
||||
) {
|
||||
console.error(
|
||||
'Error while trying to replace version metadata in build/service-worker.js.'
|
||||
'Error while trying to replace version metadata in public/service-worker.js.'
|
||||
);
|
||||
shell.exit(1);
|
||||
}
|
||||
|
@@ -1,64 +1,248 @@
|
||||
if (typeof importScripts === 'function') {
|
||||
importScripts(
|
||||
'https://storage.googleapis.com/workbox-cdn/releases/3.5.0/workbox-sw.js'
|
||||
);
|
||||
/* global workbox */
|
||||
if (workbox) {
|
||||
// Will be replaced by make-service-worker.js to include the proper version.
|
||||
const VersionMetadata = {};
|
||||
/* eslint-disable no-restricted-globals */
|
||||
|
||||
// Contrary to other static assets (JS, CSS, HTML), libGD.js/wasm are not
|
||||
// versioned in their filenames. Instead, we version using a query string
|
||||
// (see src/index.js where it's loaded with the same query string).
|
||||
workbox.precaching.precacheAndRoute([
|
||||
{
|
||||
url: `libGD.js?cache-buster=${VersionMetadata.versionWithHash}`,
|
||||
revision: null, // Revision is null because versioning included in the URL.
|
||||
},
|
||||
{
|
||||
url: `libGD.wasm?cache-buster=${VersionMetadata.versionWithHash}`,
|
||||
revision: null, // Revision is null because versioning included in the URL.
|
||||
},
|
||||
]);
|
||||
// ============================================================================
|
||||
// Browser Service Worker powered Preview ("Browser SW Preview"), using IndexedDB.
|
||||
// ============================================================================
|
||||
|
||||
/* injection point for manifest files. */
|
||||
workbox.precaching.precacheAndRoute([]);
|
||||
console.log('[ServiceWorker] Service worker file executed');
|
||||
|
||||
/* custom cache rules*/
|
||||
workbox.routing.registerNavigationRoute('/index.html', {
|
||||
blacklist: [/^\/_/, /\/[^\/]+\.[^\/]+$/],
|
||||
});
|
||||
const swURL = new URL(self.location.href);
|
||||
const isDev = swURL.searchParams.has('dev');
|
||||
|
||||
// Cache resources from GDevelop cloudfront server (CORS enabled).
|
||||
workbox.routing.registerRoute(
|
||||
/https:\/\/resources\.gdevelop-app\.com\/.*$/,
|
||||
workbox.strategies.networkFirst({
|
||||
cacheName: 'gdevelop-resources-cache',
|
||||
plugins: [
|
||||
new workbox.expiration.Plugin({
|
||||
maxEntries: 500,
|
||||
}),
|
||||
],
|
||||
})
|
||||
);
|
||||
// If updated, also update the BrowserSWIndexedDB module.
|
||||
const DB_NAME = 'gdevelop-browser-sw-preview';
|
||||
const STORE_NAME = 'files';
|
||||
const DB_VERSION = 1;
|
||||
|
||||
// TODO: this should be useless?
|
||||
workbox.routing.registerRoute(
|
||||
/\.(?:png|gif|jpg|jpeg)$/,
|
||||
workbox.strategies.networkFirst({
|
||||
cacheName: 'images',
|
||||
plugins: [
|
||||
new workbox.expiration.Plugin({
|
||||
maxEntries: 150,
|
||||
}),
|
||||
],
|
||||
})
|
||||
);
|
||||
} else {
|
||||
console.log('Workbox could not be loaded - no offline support');
|
||||
}
|
||||
} else {
|
||||
console.log(
|
||||
'importScripts does not exist on this browser - no offline support'
|
||||
);
|
||||
/**
|
||||
* Opens the IndexedDB database for browser SW preview files.
|
||||
*/
|
||||
function openBrowserSWPreviewDB() {
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
|
||||
request.onerror = () => {
|
||||
const error = request.error || new Error('Failed to open IndexedDB');
|
||||
console.error('[ServiceWorker] Error opening preview database:', error);
|
||||
reject(error);
|
||||
};
|
||||
|
||||
request.onsuccess = () => {
|
||||
const db = request.result;
|
||||
resolve(db);
|
||||
};
|
||||
|
||||
request.onupgradeneeded = () => {
|
||||
console.log('[ServiceWorker] Upgrading preview database schema...');
|
||||
const db = request.result;
|
||||
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
||||
db.createObjectStore(STORE_NAME);
|
||||
console.log('[ServiceWorker] Created object store:', STORE_NAME);
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[ServiceWorker] Exception while opening preview database:', error);
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a preview file from IndexedDB.
|
||||
*/
|
||||
async function getBrowserSWPreviewFile(path) {
|
||||
try {
|
||||
const db = await openBrowserSWPreviewDB();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
const safeResolve = (v) => { if (!settled) { settled = true; resolve(v); } };
|
||||
const safeReject = (e) => { if (!settled) { settled = true; reject(e); } };
|
||||
|
||||
try {
|
||||
// Sanity-check the store exists (avoids InvalidStateError).
|
||||
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
||||
const err = new Error(`Object store "${STORE_NAME}" not found`);
|
||||
console.error('[ServiceWorker] Missing object store while getting file:', path, err);
|
||||
return safeReject(err);
|
||||
}
|
||||
|
||||
const tx = db.transaction(STORE_NAME, 'readonly');
|
||||
|
||||
// If the transaction aborts (quota, deadlock, explicit abort, etc.), reject.
|
||||
tx.onabort = () => {
|
||||
const error = tx.error || new Error('Transaction aborted');
|
||||
console.error('[ServiceWorker] Transaction aborted while getting file:', path, error);
|
||||
safeReject(error);
|
||||
};
|
||||
|
||||
// `onerror` at the transaction level can fire even if request handlers didn’t.
|
||||
tx.onerror = () => {
|
||||
const error = tx.error || new Error('Transaction failed');
|
||||
console.error('[ServiceWorker] Transaction error while getting file:', path, error);
|
||||
safeReject(error);
|
||||
};
|
||||
|
||||
const store = tx.objectStore(STORE_NAME);
|
||||
const req = store.get(path);
|
||||
|
||||
req.onsuccess = () => {
|
||||
const result = req.result;
|
||||
safeResolve(result || null);
|
||||
};
|
||||
|
||||
req.onerror = () => {
|
||||
const error = req.error || new Error('Get operation failed');
|
||||
console.error('[ServiceWorker] Error retrieving file from IndexedDB:', path, error);
|
||||
safeReject(error);
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[ServiceWorker] Exception during get operation:', path, error);
|
||||
safeReject(error);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[ServiceWorker] Failed to get file from IndexedDB:', path, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles fetch events for browser SW preview files served from IndexedDB.
|
||||
*/
|
||||
self.addEventListener('fetch', (event) => {
|
||||
const url = new URL(event.request.url);
|
||||
|
||||
// Check if this is a request for a browser SW preview file
|
||||
if (url.pathname.startsWith('/browser_sw_preview/')) {
|
||||
const relativePath = url.pathname.replace('/browser_sw_preview', '');
|
||||
|
||||
event.respondWith((async () => {
|
||||
try {
|
||||
// Try to get the file from IndexedDB
|
||||
const fileRecord = await getBrowserSWPreviewFile(relativePath);
|
||||
|
||||
if (!fileRecord) {
|
||||
console.warn('[ServiceWorker] File not found in IndexedDB:', relativePath);
|
||||
return new Response('File not found in browser SW preview storage', {
|
||||
status: 404,
|
||||
headers: {
|
||||
'Content-Type': 'text/plain',
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Return the file with appropriate headers
|
||||
return new Response(fileRecord.bytes, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': fileRecord.contentType || 'application/octet-stream',
|
||||
// Prevent caching to ensure latest version is always served
|
||||
'Cache-Control': 'no-store, no-cache, must-revalidate',
|
||||
'Pragma': 'no-cache',
|
||||
'Expires': '0',
|
||||
// CORS headers for cross-origin requests if needed
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[ServiceWorker] Error serving browser SW preview file:', relativePath, error);
|
||||
return new Response('Error loading file from browser SW preview storage: ' + error.message, {
|
||||
status: 500,
|
||||
headers: {
|
||||
'Content-Type': 'text/plain',
|
||||
}
|
||||
});
|
||||
}
|
||||
})());
|
||||
|
||||
// Return early to prevent falling through to workbox routes
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
self.addEventListener('install', (event) => {
|
||||
console.log('[ServiceWorker] Installing service worker...');
|
||||
if (isDev) {
|
||||
// In development, immediately use a new service worker.
|
||||
self.skipWaiting();
|
||||
}
|
||||
});
|
||||
|
||||
self.addEventListener('activate', (event) => {
|
||||
console.log('[ServiceWorker] Activating service worker...');
|
||||
|
||||
if (isDev) {
|
||||
event.waitUntil(self.clients.claim());
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Standard Workbox Configuration (for "semi-offline"/caching of GDevelop static files and resources)
|
||||
// ============================================================================
|
||||
|
||||
self.__WB_DISABLE_DEV_LOGS = true;
|
||||
|
||||
// eslint-disable-next-line no-undef
|
||||
importScripts(
|
||||
'https://storage.googleapis.com/workbox-cdn/releases/3.5.0/workbox-sw.js'
|
||||
);
|
||||
|
||||
/* global workbox */
|
||||
if (workbox) {
|
||||
console.log('[ServiceWorker] Workbox loaded successfully');
|
||||
|
||||
// Will be replaced by make-service-worker.js to include the proper version.
|
||||
const VersionMetadata = {};
|
||||
|
||||
// Contrary to other static assets (JS, CSS, HTML), libGD.js/wasm are not
|
||||
// versioned in their filenames. Instead, we version using a query string
|
||||
// (see src/index.js where it's loaded with the same query string).
|
||||
workbox.precaching.precacheAndRoute([
|
||||
{
|
||||
url: `libGD.js?cache-buster=${VersionMetadata.versionWithHash}`,
|
||||
revision: null, // Revision is null because versioning included in the URL.
|
||||
},
|
||||
{
|
||||
url: `libGD.wasm?cache-buster=${VersionMetadata.versionWithHash}`,
|
||||
revision: null, // Revision is null because versioning included in the URL.
|
||||
},
|
||||
]);
|
||||
|
||||
/* injection point for manifest files. */
|
||||
workbox.precaching.precacheAndRoute([]);
|
||||
|
||||
/* custom cache rules*/
|
||||
workbox.routing.registerNavigationRoute('/index.html', {
|
||||
blacklist: [/^\/_/, /\/[^\/]+\.[^\/]+$/, /^\/browser_sw_preview\//],
|
||||
});
|
||||
|
||||
// Cache resources from GDevelop cloudfront server (CORS enabled).
|
||||
workbox.routing.registerRoute(
|
||||
/https:\/\/resources\.gdevelop-app\.com\/.*$/,
|
||||
workbox.strategies.networkFirst({
|
||||
cacheName: 'gdevelop-resources-cache',
|
||||
plugins: [
|
||||
new workbox.expiration.Plugin({
|
||||
maxEntries: 500,
|
||||
}),
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
// TODO: this should be useless?
|
||||
workbox.routing.registerRoute(
|
||||
/\.(?:png|gif|jpg|jpeg)$/,
|
||||
workbox.strategies.networkFirst({
|
||||
cacheName: 'images',
|
||||
plugins: [
|
||||
new workbox.expiration.Plugin({
|
||||
maxEntries: 150,
|
||||
}),
|
||||
],
|
||||
})
|
||||
);
|
||||
} else {
|
||||
console.log('[ServiceWorker] Workbox could not be loaded - no offline support');
|
||||
}
|
||||
|
@@ -9,6 +9,7 @@ import './UI/icomoon-font.css'; // Styles for Icomoon font.
|
||||
// Import for browser only IDE
|
||||
import browserResourceSources from './ResourcesList/BrowserResourceSources';
|
||||
import browserResourceExternalEditors from './ResourcesList/BrowserResourceExternalEditors';
|
||||
import BrowserSWPreviewLauncher from './ExportAndShare/BrowserExporters/BrowserSWPreviewLauncher';
|
||||
import BrowserS3PreviewLauncher from './ExportAndShare/BrowserExporters/BrowserS3PreviewLauncher';
|
||||
import {
|
||||
browserAutomatedExporters,
|
||||
@@ -18,6 +19,7 @@ import {
|
||||
import makeExtensionsLoader from './JsExtensionsLoader/BrowserJsExtensionsLoader';
|
||||
import ObjectsEditorService from './ObjectEditor/ObjectsEditorService';
|
||||
import ObjectsRenderingService from './ObjectsRendering/ObjectsRenderingService';
|
||||
import { makeBrowserSWEventsFunctionCodeWriter } from './EventsFunctionsExtensionsLoader/CodeWriters/BrowserSWEventsFunctionCodeWriter';
|
||||
import { makeBrowserS3EventsFunctionCodeWriter } from './EventsFunctionsExtensionsLoader/CodeWriters/BrowserS3EventsFunctionCodeWriter';
|
||||
import Providers from './MainFrame/Providers';
|
||||
import ProjectStorageProviders from './ProjectsStorage/ProjectStorageProviders';
|
||||
@@ -30,6 +32,7 @@ import BrowserResourceFetcher from './ProjectsStorage/ResourceFetcher/BrowserRes
|
||||
import BrowserEventsFunctionsExtensionOpener from './EventsFunctionsExtensionsLoader/Storage/BrowserEventsFunctionsExtensionOpener';
|
||||
import BrowserEventsFunctionsExtensionWriter from './EventsFunctionsExtensionsLoader/Storage/BrowserEventsFunctionsExtensionWriter';
|
||||
import BrowserLoginProvider from './LoginProvider/BrowserLoginProvider';
|
||||
import { isServiceWorkerSupported } from './ServiceWorkerSetup';
|
||||
|
||||
export const create = (authentication: Authentication) => {
|
||||
Window.setUpContextMenu();
|
||||
@@ -38,12 +41,17 @@ export const create = (authentication: Authentication) => {
|
||||
|
||||
let app = null;
|
||||
const appArguments = Window.getArguments();
|
||||
const canUseBrowserSW = isServiceWorkerSupported();
|
||||
|
||||
app = (
|
||||
<Providers
|
||||
authentication={authentication}
|
||||
disableCheckForUpdates={!!appArguments['disable-update-check']}
|
||||
makeEventsFunctionCodeWriter={makeBrowserS3EventsFunctionCodeWriter}
|
||||
makeEventsFunctionCodeWriter={
|
||||
canUseBrowserSW
|
||||
? makeBrowserSWEventsFunctionCodeWriter
|
||||
: makeBrowserS3EventsFunctionCodeWriter
|
||||
}
|
||||
eventsFunctionsExtensionWriter={BrowserEventsFunctionsExtensionWriter}
|
||||
eventsFunctionsExtensionOpener={BrowserEventsFunctionsExtensionOpener}
|
||||
>
|
||||
@@ -67,9 +75,13 @@ export const create = (authentication: Authentication) => {
|
||||
}) => (
|
||||
<MainFrame
|
||||
i18n={i18n}
|
||||
renderPreviewLauncher={(props, ref) => (
|
||||
<BrowserS3PreviewLauncher {...props} ref={ref} />
|
||||
)}
|
||||
renderPreviewLauncher={(props, ref) =>
|
||||
canUseBrowserSW ? (
|
||||
<BrowserSWPreviewLauncher {...props} ref={ref} />
|
||||
) : (
|
||||
<BrowserS3PreviewLauncher {...props} ref={ref} />
|
||||
)
|
||||
}
|
||||
renderShareDialog={props => (
|
||||
<ShareDialog
|
||||
project={props.project}
|
||||
@@ -104,7 +116,9 @@ export const create = (authentication: Authentication) => {
|
||||
filterExamples: !Window.isDev(),
|
||||
})}
|
||||
initialFileMetadataToOpen={initialFileMetadataToOpen}
|
||||
initialExampleSlugToOpen={appArguments['create-from-example'] || null}
|
||||
initialExampleSlugToOpen={
|
||||
appArguments['create-from-example'] || null
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</ProjectStorageProviders>
|
||||
|
@@ -0,0 +1,138 @@
|
||||
// @flow
|
||||
import {
|
||||
type EventsFunctionCodeWriter,
|
||||
type EventsFunctionCodeWriterCallbacks,
|
||||
} from '..';
|
||||
import {
|
||||
deleteFilesWithPrefix,
|
||||
putFile,
|
||||
getBrowserSWPreviewBaseUrl,
|
||||
} from '../../ExportAndShare/BrowserExporters/BrowserSWPreviewLauncher/BrowserSWPreviewIndexedDB';
|
||||
import slugs from 'slugs';
|
||||
import debounce from 'lodash/debounce';
|
||||
|
||||
let batchedWrites: Array<{
|
||||
path: string,
|
||||
content: string,
|
||||
onSuccess: () => void,
|
||||
onError: (error: Error) => void,
|
||||
}> = [];
|
||||
|
||||
const flushBatchedWrites = debounce(async () => {
|
||||
const writes = [...batchedWrites];
|
||||
console.info(
|
||||
`[BrowserSWEventsFunctionCodeWriter] Storing a batch of ${
|
||||
writes.length
|
||||
} extension generated files in IndexedDB...`
|
||||
);
|
||||
|
||||
batchedWrites = [];
|
||||
|
||||
// Write all files to IndexedDB in parallel
|
||||
const results = await Promise.allSettled(
|
||||
writes.map(async write => {
|
||||
const encoder = new TextEncoder();
|
||||
const bytes = encoder.encode(write.content).buffer;
|
||||
await putFile(write.path, bytes, 'text/javascript; charset=utf-8');
|
||||
return write;
|
||||
})
|
||||
);
|
||||
|
||||
// Process results and call callbacks
|
||||
results.forEach((result, index) => {
|
||||
const write = writes[index];
|
||||
if (result.status === 'fulfilled') {
|
||||
write.onSuccess();
|
||||
} else {
|
||||
console.error(
|
||||
`[BrowserSWEventsFunctionCodeWriter] Failed to store: ${write.path}`,
|
||||
result.reason
|
||||
);
|
||||
write.onError(result.reason);
|
||||
}
|
||||
});
|
||||
}, 10); // Wait for up to 10ms, to avoid adding more latency to extension generation.
|
||||
|
||||
/**
|
||||
* Write a file by batching it with other files that are being written.
|
||||
*
|
||||
* Extension generated files are written in batches to avoid making a *lot* of IndexedDB transactions
|
||||
* (games can have from dozens to **hundreds** of extensions and generated files).
|
||||
*/
|
||||
const writeFileInNextBatch = (path: string, content: string) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
batchedWrites.push({
|
||||
path,
|
||||
content,
|
||||
onSuccess: resolve,
|
||||
onError: reject,
|
||||
});
|
||||
flushBatchedWrites();
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Create the EventsFunctionCodeWriter that writes generated code for events functions
|
||||
* to IndexedDB for service worker serving.
|
||||
*/
|
||||
export const makeBrowserSWEventsFunctionCodeWriter = ({
|
||||
onWriteFile,
|
||||
}: EventsFunctionCodeWriterCallbacks): EventsFunctionCodeWriter => {
|
||||
const baseUrl = getBrowserSWPreviewBaseUrl();
|
||||
const extensionsCodeUrl = baseUrl + '/extensions-code';
|
||||
|
||||
// At startup, clean up the old generated files for extensions code.
|
||||
(async () => {
|
||||
try {
|
||||
// TODO: This could break multiple tabs! Consider doing a folder per tab/session?
|
||||
const relativePath = extensionsCodeUrl.replace(baseUrl, '');
|
||||
await deleteFilesWithPrefix(relativePath + '/');
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[BrowserSWEventsFunctionCodeWriter] Failed to clean generated files for extensions code in "${extensionsCodeUrl}/".`,
|
||||
error
|
||||
);
|
||||
}
|
||||
})();
|
||||
|
||||
const getPathFor = (codeNamespace: string) => {
|
||||
return `${extensionsCodeUrl}/${slugs(codeNamespace)}.js`;
|
||||
};
|
||||
|
||||
return {
|
||||
getIncludeFileFor: (codeNamespace: string) => getPathFor(codeNamespace),
|
||||
|
||||
writeFunctionCode: (
|
||||
functionCodeNamespace: string,
|
||||
code: string
|
||||
): Promise<void> => {
|
||||
const path = getPathFor(functionCodeNamespace);
|
||||
onWriteFile({ includeFile: path, content: code });
|
||||
const relativePath = path.replace(baseUrl, '');
|
||||
|
||||
return writeFileInNextBatch(relativePath, code);
|
||||
},
|
||||
|
||||
writeBehaviorCode: (
|
||||
behaviorCodeNamespace: string,
|
||||
code: string
|
||||
): Promise<void> => {
|
||||
const path = getPathFor(behaviorCodeNamespace);
|
||||
onWriteFile({ includeFile: path, content: code });
|
||||
const relativePath = path.replace(baseUrl, '');
|
||||
|
||||
return writeFileInNextBatch(relativePath, code);
|
||||
},
|
||||
|
||||
writeObjectCode: (
|
||||
objectCodeNamespace: string,
|
||||
code: string
|
||||
): Promise<void> => {
|
||||
const path = getPathFor(objectCodeNamespace);
|
||||
onWriteFile({ includeFile: path, content: code });
|
||||
const relativePath = path.replace(baseUrl, '');
|
||||
|
||||
return writeFileInNextBatch(relativePath, code);
|
||||
},
|
||||
};
|
||||
};
|
@@ -38,10 +38,6 @@ const isURL = (filename: string) => {
|
||||
// Search for "pathPosix" in the codebase for other places where this is used.
|
||||
const pathPosix = path.posix || path;
|
||||
|
||||
// TODO: Merge BrowserS3FileSystem into this? The way URLs are handled
|
||||
// is different though (BrowserS3FileSystem is not downloading anything
|
||||
// from URLs, while this file system does download files).
|
||||
|
||||
/**
|
||||
* An in-memory "file system" that can be used for GDevelop exports.
|
||||
*/
|
||||
|
@@ -59,19 +59,21 @@ const setupWindowClosedPolling = () => {
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
const PREVIEWS_ORIGIN = 'https://game-previews.gdevelop.io';
|
||||
let previewOrigin = null;
|
||||
|
||||
/**
|
||||
* A debugger server implemented using the ability to send/receive messages
|
||||
* from popup windows in the browser.
|
||||
*/
|
||||
export const browserPreviewDebuggerServer: PreviewDebuggerServer = {
|
||||
startServer: async () => {
|
||||
startServer: async ({ origin }) => {
|
||||
if (debuggerServerState === 'started') return;
|
||||
debuggerServerState = 'started';
|
||||
|
||||
previewOrigin = origin;
|
||||
|
||||
window.addEventListener('message', event => {
|
||||
if (event.origin !== PREVIEWS_ORIGIN) return;
|
||||
if (event.origin !== previewOrigin) return;
|
||||
|
||||
const id = getDebuggerIdForPreviewWindow(event.source);
|
||||
if (id === null) return; // Could not find the id of this preview window.
|
||||
@@ -98,7 +100,7 @@ export const browserPreviewDebuggerServer: PreviewDebuggerServer = {
|
||||
if (!previewWindow) return;
|
||||
|
||||
try {
|
||||
previewWindow.postMessage(message, PREVIEWS_ORIGIN);
|
||||
previewWindow.postMessage(message, previewOrigin);
|
||||
} catch (error) {
|
||||
console.error('Unable to send a message to the preview window:', error);
|
||||
}
|
@@ -0,0 +1,34 @@
|
||||
// @flow
|
||||
import { displayBlackLoadingScreenOrThrow } from '../../../Utils/BrowserExternalWindowUtils';
|
||||
|
||||
let nextPreviewWindowId = 0;
|
||||
|
||||
/**
|
||||
* Open a window showing a black "loading..." screen. It's important this is done
|
||||
* NOT in an asynchronous way but JUST after a click. Otherwise, browsers like Safari
|
||||
* will block the window opening.
|
||||
*/
|
||||
export const immediatelyOpenNewPreviewWindow = (
|
||||
project: gdProject
|
||||
): WindowProxy => {
|
||||
const width = project.getGameResolutionWidth();
|
||||
const height = project.getGameResolutionHeight();
|
||||
const left = window.screenX + window.innerWidth / 2 - width / 2;
|
||||
const top = window.screenY + window.innerHeight / 2 - height / 2;
|
||||
|
||||
const targetId = 'GDevelopPreview' + nextPreviewWindowId++;
|
||||
const previewWindow = window.open(
|
||||
'about:blank',
|
||||
targetId,
|
||||
`width=${width},height=${height},left=${left},top=${top}`
|
||||
);
|
||||
if (!previewWindow) {
|
||||
throw new Error(
|
||||
"Can't open the preview window because of browser restrictions."
|
||||
);
|
||||
}
|
||||
|
||||
displayBlackLoadingScreenOrThrow(previewWindow);
|
||||
|
||||
return previewWindow;
|
||||
};
|
@@ -1,6 +1,6 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import BrowserPreviewErrorDialog from './BrowserPreviewErrorDialog';
|
||||
import BrowserPreviewErrorDialog from '../BrowserPreview/BrowserPreviewErrorDialog';
|
||||
import BrowserS3FileSystem from '../BrowserS3FileSystem';
|
||||
import { findGDJS } from '../../../GameEngineFinder/BrowserS3GDJSFinder';
|
||||
import assignIn from 'lodash/assignIn';
|
||||
@@ -14,56 +14,23 @@ import {
|
||||
browserPreviewDebuggerServer,
|
||||
getExistingPreviewWindowForDebuggerId,
|
||||
registerNewPreviewWindow,
|
||||
} from './BrowserPreviewDebuggerServer';
|
||||
} from '../BrowserPreview/BrowserPreviewDebuggerServer';
|
||||
import Window from '../../../Utils/Window';
|
||||
import { displayBlackLoadingScreenOrThrow } from '../../../Utils/BrowserExternalWindowUtils';
|
||||
import { getGDevelopResourceJwtToken } from '../../../Utils/GDevelopServices/Project';
|
||||
import { isNativeMobileApp } from '../../../Utils/Platform';
|
||||
import { getIDEVersionWithHash } from '../../../Version';
|
||||
import { immediatelyOpenNewPreviewWindow } from '../BrowserPreview/BrowserPreviewWindow';
|
||||
const gd: libGDevelop = global.gd;
|
||||
|
||||
type State = {|
|
||||
error: ?Error,
|
||||
|};
|
||||
|
||||
let nextPreviewWindowId = 0;
|
||||
|
||||
/**
|
||||
* Open a window showing a black "loading..." screen. It's important this is done
|
||||
* NOT in an asynchronous way but JUST after a click. Otherwise, browsers like Safari
|
||||
* will block the window opening.
|
||||
*/
|
||||
export const immediatelyOpenNewPreviewWindow = (
|
||||
project: gdProject
|
||||
): WindowProxy => {
|
||||
const width = project.getGameResolutionWidth();
|
||||
const height = project.getGameResolutionHeight();
|
||||
const left = window.screenX + window.innerWidth / 2 - width / 2;
|
||||
const top = window.screenY + window.innerHeight / 2 - height / 2;
|
||||
|
||||
const targetId = 'GDevelopPreview' + nextPreviewWindowId++;
|
||||
const previewWindow = window.open(
|
||||
'about:blank',
|
||||
targetId,
|
||||
`width=${width},height=${height},left=${left},top=${top}`
|
||||
);
|
||||
if (!previewWindow) {
|
||||
throw new Error(
|
||||
"Can't open the preview window because of browser restrictions."
|
||||
);
|
||||
}
|
||||
|
||||
displayBlackLoadingScreenOrThrow(previewWindow);
|
||||
|
||||
return previewWindow;
|
||||
};
|
||||
|
||||
export default class BrowserS3PreviewLauncher extends React.Component<
|
||||
PreviewLauncherProps,
|
||||
State
|
||||
> {
|
||||
canDoNetworkPreview = () => false;
|
||||
canDoHotReload = () => false;
|
||||
|
||||
state = {
|
||||
error: null,
|
||||
@@ -135,7 +102,9 @@ export default class BrowserS3PreviewLauncher extends React.Component<
|
||||
}).filter(Boolean);
|
||||
|
||||
try {
|
||||
await this.getPreviewDebuggerServer().startServer();
|
||||
await this.getPreviewDebuggerServer().startServer({
|
||||
origin: new URL(getBaseUrl()).origin,
|
||||
});
|
||||
} catch (err) {
|
||||
// Ignore any error when running the debugger server - the preview
|
||||
// can still work without it.
|
||||
|
@@ -0,0 +1,246 @@
|
||||
// @flow
|
||||
import path from 'path-browserify';
|
||||
import {
|
||||
deleteFilesWithPrefix,
|
||||
putFile,
|
||||
} from './BrowserSWPreviewLauncher/BrowserSWPreviewIndexedDB';
|
||||
const gd: libGDevelop = global.gd;
|
||||
|
||||
export type TextFileDescriptor = {|
|
||||
filePath: string,
|
||||
text: string,
|
||||
|};
|
||||
|
||||
type ConstructorArgs = {|
|
||||
filesContent: Array<TextFileDescriptor>,
|
||||
baseUrl: string,
|
||||
|};
|
||||
|
||||
const isURL = (filename: string) => {
|
||||
return (
|
||||
filename.startsWith('http://') ||
|
||||
filename.startsWith('https://') ||
|
||||
filename.startsWith('ftp://') ||
|
||||
filename.startsWith('blob:') ||
|
||||
filename.startsWith('data:')
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Determines the content type based on file extension.
|
||||
*/
|
||||
const getContentType = (filePath: string): string => {
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
const mimeTypes = {
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
'.js': 'text/javascript; charset=utf-8',
|
||||
'.mjs': 'text/javascript; charset=utf-8',
|
||||
'.css': 'text/css; charset=utf-8',
|
||||
'.json': 'application/json; charset=utf-8',
|
||||
'.wasm': 'application/wasm',
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.gif': 'image/gif',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.txt': 'text/plain; charset=utf-8',
|
||||
};
|
||||
return mimeTypes[ext] || 'application/octet-stream';
|
||||
};
|
||||
|
||||
/**
|
||||
* An in-memory "file system" that stores files in IndexedDB
|
||||
* and serves them via a service worker for GDevelop previews.
|
||||
*/
|
||||
export default class BrowserSWFileSystem {
|
||||
baseUrl: string;
|
||||
|
||||
// Store the content of some files.
|
||||
_indexedFilesContent: { [string]: TextFileDescriptor };
|
||||
|
||||
// Store all the files that should be written to IndexedDB.
|
||||
_pendingFiles: Array<{|
|
||||
path: string,
|
||||
content: string,
|
||||
contentType: string,
|
||||
|}> = [];
|
||||
|
||||
_pendingDeleteOperations: Array<Promise<any>> = [];
|
||||
|
||||
// Store a set of all external URLs copied so that we can simulate
|
||||
// readDir result.
|
||||
_allCopiedExternalUrls = new Set<string>();
|
||||
|
||||
constructor({ filesContent, baseUrl }: ConstructorArgs) {
|
||||
this.baseUrl = baseUrl;
|
||||
|
||||
this._indexedFilesContent = {};
|
||||
filesContent.forEach(textFileDescriptor => {
|
||||
this._indexedFilesContent[
|
||||
textFileDescriptor.filePath
|
||||
] = textFileDescriptor;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads all pending files to IndexedDB.
|
||||
*/
|
||||
applyPendingOperations = async () => {
|
||||
try {
|
||||
await Promise.all(this._pendingDeleteOperations);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'[BrowserSWFileSystem] Error while deleting files in IndexedDB. Ignoring.',
|
||||
error
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(
|
||||
`[BrowserSWFileSystem] Storing ${
|
||||
this._pendingFiles.length
|
||||
} files in IndexedDB for preview...`
|
||||
);
|
||||
|
||||
let totalBytes = 0;
|
||||
const uploadPromises = this._pendingFiles.map(async file => {
|
||||
const fullPath = `/${file.path}`;
|
||||
const encoder = new TextEncoder();
|
||||
const bytes = encoder.encode(file.content).buffer;
|
||||
|
||||
totalBytes += bytes.byteLength;
|
||||
|
||||
await putFile(fullPath, bytes, file.contentType);
|
||||
});
|
||||
|
||||
await Promise.all(uploadPromises);
|
||||
|
||||
console.log(
|
||||
`[BrowserSWFileSystem] Successfully stored all ${
|
||||
this._pendingFiles.length
|
||||
} preview files in IndexedDB (${Math.ceil(totalBytes / 1000)} kB).`
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"[BrowserSWFileSystem] Can't store all files in IndexedDB:",
|
||||
error
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
mkDir = (path: string) => {
|
||||
// Assume required directories always exist in a virtual file system.
|
||||
};
|
||||
|
||||
dirExists = (path: string) => {
|
||||
// Assume required directories always exist.
|
||||
return true;
|
||||
};
|
||||
|
||||
clearDir = (path: string) => {
|
||||
console.info(`[BrowserSWFileSystem] Clearing directory: ${path}...`);
|
||||
this._pendingDeleteOperations.push(deleteFilesWithPrefix(path));
|
||||
};
|
||||
|
||||
getTempDir = () => {
|
||||
return '/virtual-unused-tmp-dir';
|
||||
};
|
||||
|
||||
fileNameFrom = (fullpath: string) => {
|
||||
if (isURL(fullpath)) return fullpath;
|
||||
return path.basename(fullpath);
|
||||
};
|
||||
|
||||
dirNameFrom = (fullpath: string) => {
|
||||
if (isURL(fullpath)) return '';
|
||||
return path.dirname(fullpath);
|
||||
};
|
||||
|
||||
makeAbsolute = (filename: string, baseDirectory: string) => {
|
||||
if (isURL(filename)) return filename;
|
||||
|
||||
if (!this.isAbsolute(baseDirectory))
|
||||
baseDirectory = path.resolve(baseDirectory);
|
||||
|
||||
return path.resolve(baseDirectory, path.normalize(filename));
|
||||
};
|
||||
|
||||
makeRelative = (filename: string, baseDirectory: string) => {
|
||||
if (isURL(filename)) return filename;
|
||||
return path.relative(baseDirectory, path.normalize(filename));
|
||||
};
|
||||
|
||||
isAbsolute = (fullpath: string) => {
|
||||
if (isURL(fullpath)) return true;
|
||||
if (fullpath.length === 0) return true;
|
||||
return (
|
||||
(fullpath.length > 0 && fullpath.charAt(0) === '/') ||
|
||||
(fullpath.length > 1 && fullpath.charAt(1) === ':')
|
||||
);
|
||||
};
|
||||
|
||||
copyFile = (source: string, dest: string) => {
|
||||
// URLs are not copied, just tracked.
|
||||
if (isURL(source)) {
|
||||
this._allCopiedExternalUrls.add(source);
|
||||
return true;
|
||||
}
|
||||
|
||||
console.warn(
|
||||
'[BrowserSWFileSystem] Copy not done from',
|
||||
source,
|
||||
'to',
|
||||
dest
|
||||
);
|
||||
return true;
|
||||
};
|
||||
|
||||
writeToFile = (fullPath: string, contents: string) => {
|
||||
// Remove the base URL to get the relative path
|
||||
const relativePath = fullPath.replace(this.baseUrl, '');
|
||||
const contentType = getContentType(fullPath);
|
||||
|
||||
// Queue the file to be written to IndexedDB
|
||||
this._pendingFiles.push({
|
||||
path: relativePath,
|
||||
content: contents,
|
||||
contentType,
|
||||
});
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
readFile = (file: string) => {
|
||||
if (!!this._indexedFilesContent[file])
|
||||
return this._indexedFilesContent[file].text;
|
||||
|
||||
console.error(
|
||||
`[BrowserSWFileSystem] Unknown file ${file}, returning an empty string`
|
||||
);
|
||||
return '';
|
||||
};
|
||||
|
||||
readDir = (path: string, ext: string) => {
|
||||
ext = ext.toUpperCase();
|
||||
var output = new gd.VectorString();
|
||||
|
||||
// Simulate ReadDir by returning all external URLs
|
||||
// with the filename matching the extension.
|
||||
this._allCopiedExternalUrls.forEach(url => {
|
||||
const upperCaseUrl = url.toUpperCase();
|
||||
if (upperCaseUrl.indexOf(ext) === upperCaseUrl.length - ext.length) {
|
||||
output.push_back(url);
|
||||
}
|
||||
});
|
||||
|
||||
return output;
|
||||
};
|
||||
|
||||
fileExists = (filename: string) => {
|
||||
if (isURL(filename)) return true;
|
||||
|
||||
// Assume all files asked for exist.
|
||||
return true;
|
||||
};
|
||||
}
|
@@ -0,0 +1,240 @@
|
||||
// @flow
|
||||
|
||||
// If updated, also update the service worker template.
|
||||
const DB_NAME = 'gdevelop-browser-sw-preview';
|
||||
const STORE_NAME = 'files';
|
||||
const DB_VERSION = 1;
|
||||
|
||||
type FileRecord = {|
|
||||
bytes: ArrayBuffer,
|
||||
contentType: string,
|
||||
|};
|
||||
|
||||
let dbInstance: ?IDBDatabase = null;
|
||||
|
||||
/**
|
||||
* Gets the base URL for browser service worker previews.
|
||||
* This URL should be handled by the service worker to serve files from IndexedDB.
|
||||
*/
|
||||
export const getBrowserSWPreviewBaseUrl = (): string => {
|
||||
// Use the current origin to ensure the service worker can intercept requests
|
||||
const origin = window.location.origin;
|
||||
return `${origin}/browser_sw_preview`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Opens or returns the existing IndexedDB database connection.
|
||||
* Handles database upgrades and version management.
|
||||
*/
|
||||
const openBrowserSWPreviewIndexedDB = (): Promise<IDBDatabase> => {
|
||||
if (dbInstance && dbInstance.version === DB_VERSION) {
|
||||
return Promise.resolve(dbInstance);
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
// $FlowFixMe - indexedDB is available in all browsers
|
||||
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
|
||||
request.onerror = () => {
|
||||
const error = request.error || new Error('Failed to open IndexedDB');
|
||||
console.error('[BrowserSWIndexedDB] Error opening database:', error);
|
||||
reject(error);
|
||||
};
|
||||
|
||||
request.onsuccess = () => {
|
||||
dbInstance = request.result;
|
||||
console.log('[BrowserSWIndexedDB] Database opened successfully');
|
||||
|
||||
// Handle unexpected close
|
||||
dbInstance.onclose = () => {
|
||||
console.warn(
|
||||
'[BrowserSWIndexedDB] Database connection closed unexpectedly'
|
||||
);
|
||||
dbInstance = null;
|
||||
};
|
||||
|
||||
// Handle version change (e.g., if another tab upgrades the DB)
|
||||
dbInstance.onversionchange = () => {
|
||||
console.warn(
|
||||
'[BrowserSWIndexedDB] Database version changed, closing connection'
|
||||
);
|
||||
if (dbInstance) {
|
||||
dbInstance.close();
|
||||
dbInstance = null;
|
||||
}
|
||||
};
|
||||
|
||||
resolve(dbInstance);
|
||||
};
|
||||
|
||||
request.onupgradeneeded = event => {
|
||||
console.log('[BrowserSWIndexedDB] Upgrading database schema...');
|
||||
const db = request.result;
|
||||
|
||||
// Create object store if it doesn't exist
|
||||
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
||||
db.createObjectStore(STORE_NAME);
|
||||
console.log('[BrowserSWIndexedDB] Created object store:', STORE_NAME);
|
||||
}
|
||||
};
|
||||
|
||||
request.onblocked = () => {
|
||||
console.warn(
|
||||
'[BrowserSWIndexedDB] Database upgrade blocked by another connection'
|
||||
);
|
||||
reject(
|
||||
new Error(
|
||||
'Database upgrade blocked. Please close other tabs using this application.'
|
||||
)
|
||||
);
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'[BrowserSWIndexedDB] Exception while opening database:',
|
||||
error
|
||||
);
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Stores a "file" in IndexedDB.
|
||||
*/
|
||||
export const putFile = async (
|
||||
path: string,
|
||||
bytes: ArrayBuffer,
|
||||
contentType: string
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const db = await openBrowserSWPreviewIndexedDB();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
const transaction = db.transaction(STORE_NAME, 'readwrite');
|
||||
|
||||
transaction.onerror = () => {
|
||||
const error = transaction.error || new Error('Transaction failed');
|
||||
console.error(
|
||||
'[BrowserSWIndexedDB] Transaction error while putting file:',
|
||||
path,
|
||||
error
|
||||
);
|
||||
reject(error);
|
||||
};
|
||||
|
||||
transaction.onabort = () => {
|
||||
const error = transaction.error || new Error('Transaction aborted');
|
||||
console.error(
|
||||
'[BrowserSWIndexedDB] Transaction aborted while putting file:',
|
||||
path,
|
||||
error
|
||||
);
|
||||
reject(error);
|
||||
};
|
||||
|
||||
transaction.oncomplete = () => {
|
||||
resolve();
|
||||
};
|
||||
|
||||
const objectStore = transaction.objectStore(STORE_NAME);
|
||||
const record: FileRecord = { bytes, contentType };
|
||||
|
||||
const request = objectStore.put(record, path);
|
||||
|
||||
request.onerror = () => {
|
||||
const error = request.error || new Error('Put operation failed');
|
||||
console.error(
|
||||
'[BrowserSWIndexedDB] Error storing file:',
|
||||
path,
|
||||
error
|
||||
);
|
||||
reject(error);
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'[BrowserSWIndexedDB] Exception during put operation:',
|
||||
path,
|
||||
error
|
||||
);
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[BrowserSWIndexedDB] Failed to put file:', path, error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Deletes all "files" stored in IndexedDB with a given path prefix.
|
||||
*
|
||||
* @param pathPrefix - The path prefix to match.
|
||||
*/
|
||||
export const deleteFilesWithPrefix = async (
|
||||
pathPrefix: string
|
||||
): Promise<number> => {
|
||||
try {
|
||||
const db = await openBrowserSWPreviewIndexedDB();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
const transaction = db.transaction(STORE_NAME, 'readwrite');
|
||||
let deletedCount = 0;
|
||||
|
||||
transaction.onerror = () => {
|
||||
const error = transaction.error || new Error('Transaction failed');
|
||||
console.error(
|
||||
'[BrowserSWIndexedDB] Transaction error while deleting files with prefix:',
|
||||
pathPrefix,
|
||||
error
|
||||
);
|
||||
reject(error);
|
||||
};
|
||||
|
||||
transaction.oncomplete = () => {
|
||||
resolve(deletedCount);
|
||||
};
|
||||
|
||||
const objectStore = transaction.objectStore(STORE_NAME);
|
||||
const request = objectStore.openCursor();
|
||||
|
||||
request.onsuccess = event => {
|
||||
const cursor = event.target.result;
|
||||
if (cursor) {
|
||||
const key = cursor.key;
|
||||
if (typeof key === 'string' && key.startsWith(pathPrefix)) {
|
||||
cursor.delete();
|
||||
deletedCount++;
|
||||
}
|
||||
cursor.continue();
|
||||
}
|
||||
};
|
||||
|
||||
request.onerror = () => {
|
||||
const error = request.error || new Error('Cursor operation failed');
|
||||
console.error(
|
||||
'[BrowserSWIndexedDB] Error during cursor operation:',
|
||||
error
|
||||
);
|
||||
reject(error);
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'[BrowserSWIndexedDB] Exception during delete with prefix:',
|
||||
pathPrefix,
|
||||
error
|
||||
);
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'[BrowserSWIndexedDB] Failed to delete files with prefix:',
|
||||
pathPrefix,
|
||||
error
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
};
|
@@ -0,0 +1,281 @@
|
||||
// @flow
|
||||
import * as React from 'react';
|
||||
import BrowserPreviewErrorDialog from '../BrowserPreview/BrowserPreviewErrorDialog';
|
||||
import BrowserSWFileSystem from '../BrowserSWFileSystem';
|
||||
import { findGDJS } from '../../../GameEngineFinder/BrowserS3GDJSFinder';
|
||||
import assignIn from 'lodash/assignIn';
|
||||
import {
|
||||
type PreviewOptions,
|
||||
type PreviewLauncherProps,
|
||||
} from '../../PreviewLauncher.flow';
|
||||
import {
|
||||
browserPreviewDebuggerServer,
|
||||
registerNewPreviewWindow,
|
||||
} from '../BrowserPreview/BrowserPreviewDebuggerServer';
|
||||
import Window from '../../../Utils/Window';
|
||||
import { getGDevelopResourceJwtToken } from '../../../Utils/GDevelopServices/Project';
|
||||
import { isNativeMobileApp } from '../../../Utils/Platform';
|
||||
import { getIDEVersionWithHash } from '../../../Version';
|
||||
import { getBrowserSWPreviewBaseUrl } from './BrowserSWPreviewIndexedDB';
|
||||
import { immediatelyOpenNewPreviewWindow } from '../BrowserPreview/BrowserPreviewWindow';
|
||||
const gd: libGDevelop = global.gd;
|
||||
|
||||
type State = {|
|
||||
error: ?Error,
|
||||
|};
|
||||
|
||||
export default class BrowserSWPreviewLauncher extends React.Component<
|
||||
PreviewLauncherProps,
|
||||
State
|
||||
> {
|
||||
canDoNetworkPreview = () => false;
|
||||
|
||||
state = {
|
||||
error: null,
|
||||
};
|
||||
|
||||
_prepareExporter = (): Promise<{|
|
||||
outputDir: string,
|
||||
exporter: gdjsExporter,
|
||||
browserSWFileSystem: BrowserSWFileSystem,
|
||||
|}> => {
|
||||
return findGDJS('preview').then(({ gdjsRoot, filesContent }) => {
|
||||
console.info('[BrowserSWPreviewLauncher] GDJS found in', gdjsRoot);
|
||||
|
||||
const isForInGameEdition = false; // TODO: adapt for the 3D editor branch.
|
||||
|
||||
const baseUrl = getBrowserSWPreviewBaseUrl();
|
||||
const outputDir = `${baseUrl}/${
|
||||
isForInGameEdition ? 'in-game-editor-preview' : 'preview'
|
||||
}`;
|
||||
|
||||
console.log(
|
||||
'[BrowserSWPreviewLauncher] Preview will be served from:',
|
||||
outputDir
|
||||
);
|
||||
|
||||
const browserSWFileSystem = new BrowserSWFileSystem({
|
||||
filesContent,
|
||||
baseUrl: `${baseUrl}/`,
|
||||
});
|
||||
const fileSystem = assignIn(
|
||||
new gd.AbstractFileSystemJS(),
|
||||
browserSWFileSystem
|
||||
);
|
||||
const exporter = new gd.Exporter(fileSystem, gdjsRoot);
|
||||
exporter.setCodeOutputDirectory(outputDir);
|
||||
|
||||
return {
|
||||
exporter,
|
||||
outputDir,
|
||||
browserSWFileSystem,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
launchPreview = async (previewOptions: PreviewOptions): Promise<any> => {
|
||||
const { project, layout, externalLayout, numberOfWindows } = previewOptions;
|
||||
this.setState({
|
||||
error: null,
|
||||
});
|
||||
|
||||
const debuggerIds = this.getPreviewDebuggerServer().getExistingDebuggerIds();
|
||||
const shouldHotReload = previewOptions.hotReload && !!debuggerIds.length;
|
||||
|
||||
// Immediately open windows (otherwise Safari will block the window opening if done after
|
||||
// an asynchronous operation).
|
||||
const previewWindows = shouldHotReload
|
||||
? []
|
||||
: Array.from({ length: numberOfWindows }, () => {
|
||||
try {
|
||||
return immediatelyOpenNewPreviewWindow(project);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'[BrowserSWPreviewLauncher] Unable to open a new preview window - this window will be ignored:',
|
||||
error
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}).filter(Boolean);
|
||||
|
||||
try {
|
||||
await this.getPreviewDebuggerServer().startServer({
|
||||
origin: new URL(getBrowserSWPreviewBaseUrl()).origin,
|
||||
});
|
||||
} catch (err) {
|
||||
// Ignore any error when running the debugger server - the preview
|
||||
// can still work without it.
|
||||
console.error(
|
||||
'[BrowserSWPreviewLauncher] Unable to start the Debugger Server for the preview:',
|
||||
err
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const {
|
||||
exporter,
|
||||
outputDir,
|
||||
browserSWFileSystem,
|
||||
} = await this._prepareExporter();
|
||||
|
||||
const previewExportOptions = new gd.PreviewExportOptions(
|
||||
project,
|
||||
outputDir
|
||||
);
|
||||
previewExportOptions.setLayoutName(layout.getName());
|
||||
previewExportOptions.setIsDevelopmentEnvironment(Window.isDev());
|
||||
if (externalLayout) {
|
||||
previewExportOptions.setExternalLayoutName(externalLayout.getName());
|
||||
}
|
||||
|
||||
previewExportOptions.useWindowMessageDebuggerClient();
|
||||
|
||||
const includeFileHashs = this.props.getIncludeFileHashs();
|
||||
for (const includeFile in includeFileHashs) {
|
||||
const hash = includeFileHashs[includeFile];
|
||||
previewExportOptions.setIncludeFileHash(includeFile, hash);
|
||||
}
|
||||
|
||||
previewExportOptions.setProjectDataOnlyExport(
|
||||
// Only export project data if asked and if a hot-reloading is being done.
|
||||
shouldHotReload && previewOptions.projectDataOnlyExport
|
||||
);
|
||||
|
||||
previewExportOptions.setFullLoadingScreen(
|
||||
previewOptions.fullLoadingScreen
|
||||
);
|
||||
|
||||
previewExportOptions.setNativeMobileApp(isNativeMobileApp());
|
||||
previewExportOptions.setGDevelopVersionWithHash(getIDEVersionWithHash());
|
||||
previewExportOptions.setCrashReportUploadLevel(
|
||||
this.props.crashReportUploadLevel
|
||||
);
|
||||
previewExportOptions.setPreviewContext(this.props.previewContext);
|
||||
previewExportOptions.setProjectTemplateSlug(project.getTemplateSlug());
|
||||
previewExportOptions.setSourceGameId(this.props.sourceGameId);
|
||||
|
||||
if (previewOptions.inAppTutorialMessageInPreview) {
|
||||
previewExportOptions.setInAppTutorialMessageInPreview(
|
||||
previewOptions.inAppTutorialMessageInPreview,
|
||||
previewOptions.inAppTutorialMessagePositionInPreview
|
||||
);
|
||||
}
|
||||
|
||||
if (previewOptions.fallbackAuthor) {
|
||||
previewExportOptions.setFallbackAuthor(
|
||||
previewOptions.fallbackAuthor.id,
|
||||
previewOptions.fallbackAuthor.username
|
||||
);
|
||||
}
|
||||
if (previewOptions.authenticatedPlayer) {
|
||||
previewExportOptions.setAuthenticatedPlayer(
|
||||
previewOptions.authenticatedPlayer.playerId,
|
||||
previewOptions.authenticatedPlayer.playerUsername,
|
||||
previewOptions.authenticatedPlayer.playerToken
|
||||
);
|
||||
}
|
||||
if (previewOptions.captureOptions.screenshots) {
|
||||
previewOptions.captureOptions.screenshots.forEach(screenshot => {
|
||||
previewExportOptions.addScreenshotCapture(
|
||||
screenshot.delayTimeInSeconds,
|
||||
screenshot.signedUrl,
|
||||
screenshot.publicUrl
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// The token, if any, to be used to read resources on GDevelop Cloud buckets.
|
||||
const gdevelopResourceToken = getGDevelopResourceJwtToken();
|
||||
if (gdevelopResourceToken)
|
||||
previewExportOptions.setGDevelopResourceToken(gdevelopResourceToken);
|
||||
|
||||
console.log(
|
||||
'[BrowserSWPreviewLauncher] Exporting project for preview...'
|
||||
);
|
||||
exporter.exportProjectForPixiPreview(previewExportOptions);
|
||||
previewExportOptions.delete();
|
||||
exporter.delete();
|
||||
|
||||
console.log(
|
||||
'[BrowserSWPreviewLauncher] Storing preview files in IndexedDB...'
|
||||
);
|
||||
await browserSWFileSystem.applyPendingOperations();
|
||||
|
||||
if (shouldHotReload) {
|
||||
console.log('[BrowserSWPreviewLauncher] Triggering hot reload...');
|
||||
debuggerIds.forEach(debuggerId => {
|
||||
this.getPreviewDebuggerServer().sendMessage(debuggerId, {
|
||||
command: 'hotReload',
|
||||
});
|
||||
});
|
||||
} else {
|
||||
console.log(
|
||||
'[BrowserSWPreviewLauncher] Opening new preview window(s)...'
|
||||
);
|
||||
previewWindows.forEach((previewWindow: WindowProxy) => {
|
||||
// Change the HTML file displayed by the preview window so that it starts loading
|
||||
// the game.
|
||||
previewWindow.location = outputDir + '/index.html';
|
||||
try {
|
||||
previewWindow.focus();
|
||||
} catch (e) {}
|
||||
|
||||
// Register the window so that it can be accessed
|
||||
// by the debugger and for the captures to be detected when it closes.
|
||||
const debuggerId = registerNewPreviewWindow(previewWindow);
|
||||
browserPreviewDebuggerServer.registerCallbacks({
|
||||
onErrorReceived: () => {},
|
||||
onServerStateChanged: () => {},
|
||||
onConnectionClosed: async ({ id }) => {
|
||||
if (id !== debuggerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (previewOptions.captureOptions) {
|
||||
await this.props.onCaptureFinished(
|
||||
previewOptions.captureOptions
|
||||
);
|
||||
}
|
||||
},
|
||||
onConnectionOpened: () => {},
|
||||
onConnectionErrored: () => {},
|
||||
onHandleParsedMessage: () => {},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
console.log('[BrowserSWPreviewLauncher] Preview launched successfully!');
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'[BrowserSWPreviewLauncher] Error launching preview:',
|
||||
error
|
||||
);
|
||||
this.setState({
|
||||
error,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
getPreviewDebuggerServer() {
|
||||
return browserPreviewDebuggerServer;
|
||||
}
|
||||
|
||||
render() {
|
||||
const { error } = this.state;
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<BrowserPreviewErrorDialog
|
||||
error={error}
|
||||
onClose={() =>
|
||||
this.setState({
|
||||
error: null,
|
||||
})
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
@@ -52,7 +52,6 @@ export default class LocalPreviewLauncher extends React.Component<
|
||||
State
|
||||
> {
|
||||
canDoNetworkPreview = () => true;
|
||||
canDoHotReload = () => true;
|
||||
|
||||
state = {
|
||||
networkPreviewDialogOpen: false,
|
||||
@@ -204,7 +203,7 @@ export default class LocalPreviewLauncher extends React.Component<
|
||||
// useful if the user opens the Debugger editor later, or want to
|
||||
// hot reload.
|
||||
return this.getPreviewDebuggerServer()
|
||||
.startServer()
|
||||
.startServer({})
|
||||
.catch(err => {
|
||||
// Ignore any error when running the debugger server - the preview
|
||||
// can still work without it.
|
||||
|
@@ -91,7 +91,7 @@ export type ServerAddress = {
|
||||
|
||||
/** Interface to run a debugger server for previews. */
|
||||
export type PreviewDebuggerServer = {|
|
||||
startServer: () => Promise<void>,
|
||||
startServer: ({ origin?: string }) => Promise<void>,
|
||||
getServerState: () => 'started' | 'stopped',
|
||||
getExistingDebuggerIds: () => Array<DebuggerId>,
|
||||
sendMessage: (id: DebuggerId, message: Object) => void,
|
||||
@@ -112,7 +112,6 @@ export type HotReloaderLog = {|
|
||||
export type PreviewLauncherInterface = {
|
||||
launchPreview: (previewOptions: PreviewOptions) => Promise<any>,
|
||||
canDoNetworkPreview: () => boolean,
|
||||
canDoHotReload: () => boolean,
|
||||
+closePreview?: (windowId: number) => void,
|
||||
+getPreviewDebuggerServer: () => ?PreviewDebuggerServer,
|
||||
};
|
||||
|
73
newIDE/app/src/ServiceWorkerSetup.js
Normal file
73
newIDE/app/src/ServiceWorkerSetup.js
Normal file
@@ -0,0 +1,73 @@
|
||||
// @flow
|
||||
import optionalRequire from './Utils/OptionalRequire';
|
||||
import { isNativeMobileApp } from './Utils/Platform';
|
||||
|
||||
const PUBLIC_URL: string = process.env.PUBLIC_URL || '';
|
||||
const isDev = process.env.NODE_ENV !== 'production';
|
||||
|
||||
const electron = optionalRequire('electron');
|
||||
const serviceWorker = navigator.serviceWorker;
|
||||
|
||||
export function isServiceWorkerSupported() {
|
||||
return !!serviceWorker;
|
||||
}
|
||||
|
||||
export function registerServiceWorker() {
|
||||
if (isNativeMobileApp() || !!electron) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!serviceWorker) {
|
||||
console.warn(
|
||||
'Service Worker not supported on this deployment (probably: not HTTPS and not localhost).'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
window.addEventListener('load', () => {
|
||||
// Use a cache-buster for development so that the service worker is
|
||||
// always reloaded when the app is reloaded.
|
||||
const swUrl = isDev
|
||||
? `${PUBLIC_URL}/service-worker.js?dev=${Date.now()}`
|
||||
: `${PUBLIC_URL}/service-worker.js`;
|
||||
|
||||
serviceWorker
|
||||
.register(swUrl)
|
||||
.then(registration => {
|
||||
registration.onupdatefound = () => {
|
||||
const installingWorker = registration.installing;
|
||||
if (installingWorker == null) {
|
||||
return;
|
||||
}
|
||||
installingWorker.onstatechange = () => {
|
||||
if (installingWorker.state === 'installed') {
|
||||
const alreadyHasAServiceWorker = !!serviceWorker.controller;
|
||||
if (alreadyHasAServiceWorker) {
|
||||
// At this point, the updated precached content has been fetched,
|
||||
// but the previous service worker will still serve the older
|
||||
// content until all client tabs are closed.
|
||||
console.log(
|
||||
'New content is available and will be used when all tabs for this page are closed.'
|
||||
);
|
||||
} else {
|
||||
// Service worker has been installed for the first time.
|
||||
console.log('Content is cached for offline use.');
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error during service worker registration:', error);
|
||||
});
|
||||
|
||||
if (isDev) {
|
||||
serviceWorker.ready.then(registration => {
|
||||
// Forces a check right now for a newer service worker script in development.
|
||||
// If there is one, it will be installed (see the service worker script to verify how in development
|
||||
// a new service worker script does a `self.skipWaiting()` and `self.clients.claim()`).
|
||||
registration.update();
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
@@ -7,7 +7,7 @@ import {
|
||||
sendProgramOpening,
|
||||
installAnalyticsEvents,
|
||||
} from './Utils/Analytics/EventSender';
|
||||
import { register } from './serviceWorker';
|
||||
import { registerServiceWorker } from './ServiceWorkerSetup';
|
||||
import './UI/icomoon-font.css'; // Styles for Icomoon font.
|
||||
import optionalRequire from './Utils/OptionalRequire';
|
||||
import { loadScript } from './Utils/LoadScript';
|
||||
@@ -168,5 +168,4 @@ if (rootElement) {
|
||||
ReactDOM.render(<Bootstrapper />, rootElement);
|
||||
} else console.error('No root element defined in index.html');
|
||||
|
||||
// registerServiceWorker();
|
||||
register();
|
||||
registerServiceWorker();
|
||||
|
@@ -1,150 +0,0 @@
|
||||
// This optional code is used to register a service worker.
|
||||
// register() is not called by default.
|
||||
|
||||
// This lets the app load faster on subsequent visits in production, and gives
|
||||
// it offline capabilities. However, it also means that developers (and users)
|
||||
// will only see deployed updates on subsequent visits to a page, after all the
|
||||
// existing tabs open on the page have been closed, since previously cached
|
||||
// resources are updated in the background.
|
||||
|
||||
// To learn more about the benefits of this model and instructions on how to
|
||||
// opt-in, read https://bit.ly/CRA-PWA
|
||||
|
||||
const isLocalhost = Boolean(
|
||||
window.location.hostname === 'localhost' ||
|
||||
// [::1] is the IPv6 localhost address.
|
||||
window.location.hostname === '[::1]' ||
|
||||
// 127.0.0.0/8 are considered localhost for IPv4.
|
||||
window.location.hostname.match(
|
||||
/^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/
|
||||
)
|
||||
);
|
||||
|
||||
const PUBLIC_URL = process.env.PUBLIC_URL;
|
||||
|
||||
export function register(config) {
|
||||
if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
|
||||
// The URL constructor is available in all browsers that support SW.
|
||||
const publicUrl = new URL(PUBLIC_URL, window.location.href);
|
||||
if (publicUrl.origin !== window.location.origin) {
|
||||
// Our service worker won't work if PUBLIC_URL is on a different origin
|
||||
// from what our page is served on. This might happen if a CDN is used to
|
||||
// serve assets; see https://github.com/facebook/create-react-app/issues/2374
|
||||
console.log(
|
||||
`The origin in PUBLIC_URL (${
|
||||
publicUrl.origin
|
||||
}) is different than the current origin (${
|
||||
window.location.origin
|
||||
}) - Service Worker disabled`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
window.addEventListener('load', () => {
|
||||
const swUrl = `${PUBLIC_URL}/service-worker.js`;
|
||||
|
||||
if (isLocalhost) {
|
||||
// This is running on localhost. Let's check if a service worker still exists or not.
|
||||
checkValidServiceWorker(swUrl, config);
|
||||
|
||||
// Add some additional logging to localhost, pointing developers to the
|
||||
// service worker/PWA documentation.
|
||||
navigator.serviceWorker.ready.then(() => {
|
||||
console.log(
|
||||
'This web app is being served cache-first by a service ' +
|
||||
'worker. To learn more, visit https://bit.ly/CRA-PWA'
|
||||
);
|
||||
});
|
||||
} else {
|
||||
// Is not localhost. Just register service worker
|
||||
registerValidSW(swUrl, config);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
console.log(
|
||||
'Development build (or unsupported browser) - Service Worker disabled'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function registerValidSW(swUrl, config) {
|
||||
navigator.serviceWorker
|
||||
.register(swUrl)
|
||||
.then(registration => {
|
||||
registration.onupdatefound = () => {
|
||||
const installingWorker = registration.installing;
|
||||
if (installingWorker == null) {
|
||||
return;
|
||||
}
|
||||
installingWorker.onstatechange = () => {
|
||||
if (installingWorker.state === 'installed') {
|
||||
if (navigator.serviceWorker.controller) {
|
||||
// At this point, the updated precached content has been fetched,
|
||||
// but the previous service worker will still serve the older
|
||||
// content until all client tabs are closed.
|
||||
console.log(
|
||||
'New content is available and will be used when all ' +
|
||||
'tabs for this page are closed. See https://bit.ly/CRA-PWA.'
|
||||
);
|
||||
|
||||
// Execute callback
|
||||
if (config && config.onUpdate) {
|
||||
config.onUpdate(registration);
|
||||
}
|
||||
} else {
|
||||
// At this point, everything has been precached.
|
||||
// It's the perfect time to display a
|
||||
// "Content is cached for offline use." message.
|
||||
console.log('Content is cached for offline use.');
|
||||
|
||||
// Execute callback
|
||||
if (config && config.onSuccess) {
|
||||
config.onSuccess(registration);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error during service worker registration:', error);
|
||||
});
|
||||
}
|
||||
|
||||
function checkValidServiceWorker(swUrl, config) {
|
||||
// Check if the service worker can be found. If it can't reload the page.
|
||||
fetch(swUrl, {
|
||||
headers: { 'Service-Worker': 'script' },
|
||||
})
|
||||
.then(response => {
|
||||
// Ensure service worker exists, and that we really are getting a JS file.
|
||||
const contentType = response.headers.get('content-type');
|
||||
if (
|
||||
response.status === 404 ||
|
||||
(contentType != null && contentType.indexOf('javascript') === -1)
|
||||
) {
|
||||
// No service worker found. Probably a different app. Reload the page.
|
||||
navigator.serviceWorker.ready.then(registration => {
|
||||
registration.unregister().then(() => {
|
||||
window.location.reload();
|
||||
});
|
||||
});
|
||||
} else {
|
||||
// Service worker found. Proceed as normal.
|
||||
registerValidSW(swUrl, config);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
console.log(
|
||||
'No internet connection found. App is running in offline mode.'
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function unregister() {
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.ready.then(registration => {
|
||||
registration.unregister();
|
||||
});
|
||||
}
|
||||
}
|
@@ -79,7 +79,7 @@ import EditorMosaicPlayground from './EditorMosaicPlayground';
|
||||
import EditorNavigator from '../UI/EditorMosaic/EditorNavigator';
|
||||
import PropertiesEditor from '../PropertiesEditor';
|
||||
import { OpenConfirmDialog } from '../ProjectsStorage/OpenConfirmDialog';
|
||||
import BrowserPreviewErrorDialog from '../ExportAndShare/BrowserExporters/BrowserS3PreviewLauncher/BrowserPreviewErrorDialog';
|
||||
import BrowserPreviewErrorDialog from '../ExportAndShare/BrowserExporters/BrowserPreview/BrowserPreviewErrorDialog';
|
||||
import RaisedButton from '../UI/RaisedButton';
|
||||
import Text from '../UI/Text';
|
||||
import IconButton from '../UI/IconButton';
|
||||
|
Reference in New Issue
Block a user