From 4669f28b4759af9df4ed56475c5db4883f5d5e98 Mon Sep 17 00:00:00 2001 From: tianheng2017 Date: Sat, 16 May 2026 17:34:39 +0800 Subject: [PATCH 1/5] Fix local resource file URL encoding --- .../ResourcesList/ResourceUrlEncoding.spec.js | 42 +++++++++++++++++++ newIDE/app/src/ResourcesList/ResourceUtils.js | 12 +++++- .../src/ResourcesList/ResourceUtils.spec.js | 6 +++ newIDE/app/src/ResourcesLoader/index.js | 11 +++-- .../src/Utils/__mocks__/OptionalRequire.js | 3 ++ 5 files changed, 68 insertions(+), 6 deletions(-) create mode 100644 newIDE/app/src/ResourcesList/ResourceUrlEncoding.spec.js diff --git a/newIDE/app/src/ResourcesList/ResourceUrlEncoding.spec.js b/newIDE/app/src/ResourcesList/ResourceUrlEncoding.spec.js new file mode 100644 index 000000000000..3e16ab662802 --- /dev/null +++ b/newIDE/app/src/ResourcesList/ResourceUrlEncoding.spec.js @@ -0,0 +1,42 @@ +// @flow +import path from 'path'; +import { pathToFileURL } from 'url'; +import { getLocalResourceFullPath } from './ResourceUtils'; +import ResourcesLoader from '../ResourcesLoader'; +import optionalRequire from '../Utils/OptionalRequire'; + +jest.mock('../Utils/OptionalRequire'); +jest.mock('../Utils/CrossOrigin', () => ({ + addGDevelopResourceTokenIfRequired: url => url, +})); + +describe('ResourceUrlEncoding', () => { + it('encodes hashes in local resource urls and decodes them back', () => { + const project = { + ptr: 1, + getProjectFile: () => 'E:\\GDevelop\\Game\\game.json', + getResourcesManager: () => ({ + hasResource: () => true, + getResource: () => ({ + getFile: () => 'Weekend Jam #1/Parts/hero.png', + }), + }), + }; + + const fullUrl = ResourcesLoader.getResourceFullUrl( + project, + 'Weekend Jam #1/Parts/hero.png', + { disableCacheBurst: true } + ); + + expect(fullUrl).toContain('%23'); + expect(fullUrl).toBe( + pathToFileURL( + path.resolve('E:\\GDevelop\\Game', 'Weekend Jam #1/Parts/hero.png') + ).toString() + ); + expect( + getLocalResourceFullPath(project, 'Weekend Jam #1/Parts/hero.png') + ).toBe(path.resolve('E:\\GDevelop\\Game', 'Weekend Jam #1/Parts/hero.png')); + }); +}); diff --git a/newIDE/app/src/ResourcesList/ResourceUtils.js b/newIDE/app/src/ResourcesList/ResourceUtils.js index f98922d7dff7..a26283384868 100644 --- a/newIDE/app/src/ResourcesList/ResourceUtils.js +++ b/newIDE/app/src/ResourcesList/ResourceUtils.js @@ -5,6 +5,7 @@ import newNameGenerator from '../Utils/NewNameGenerator'; import { toNewGdMapStringString } from '../Utils/MapStringString'; const fs = optionalRequire('fs'); const path = optionalRequire('path'); +const url = optionalRequire('url'); const gd: libGDevelop = global.gd; export const createOrUpdateResource = ( @@ -39,7 +40,7 @@ export const getLocalResourceFullPath = ( project, resourceName, {} - ).substring(7 /* Remove "file://" from the URL to get a local path */); + ); if (resourcePath.indexOf('?cache=') !== -1) { // Remove, if needed, the cache bursting argument from the URL. @@ -48,7 +49,14 @@ export const getLocalResourceFullPath = ( resourcePath.lastIndexOf('?cache=') ); } - return resourcePath; + + if (resourcePath.startsWith('file://') && url && url.fileURLToPath) { + return url.fileURLToPath(resourcePath); + } + + return resourcePath.substring( + 7 /* Remove "file://" from the URL to get a local path */ + ); }; export const isPathInProjectFolder = ( diff --git a/newIDE/app/src/ResourcesList/ResourceUtils.spec.js b/newIDE/app/src/ResourcesList/ResourceUtils.spec.js index 23cbabe8c320..b7c2d8ef7392 100644 --- a/newIDE/app/src/ResourcesList/ResourceUtils.spec.js +++ b/newIDE/app/src/ResourcesList/ResourceUtils.spec.js @@ -4,6 +4,12 @@ import { renameResourcesInProject, updateResourceJsonMetadata, } from './ResourceUtils'; +jest.mock('../ResourcesLoader', () => ({ + __esModule: true, + default: { + getResourceFullUrl: jest.fn(), + }, +})); const gd: libGDevelop = global.gd; const addNewAnimationWithImageToSpriteObject = ( diff --git a/newIDE/app/src/ResourcesLoader/index.js b/newIDE/app/src/ResourcesLoader/index.js index 43f2d79d6d80..f51121e4ef6a 100644 --- a/newIDE/app/src/ResourcesLoader/index.js +++ b/newIDE/app/src/ResourcesLoader/index.js @@ -3,6 +3,7 @@ import { addGDevelopResourceTokenIfRequired } from '../Utils/CrossOrigin'; import optionalRequire from '../Utils/OptionalRequire'; const electron = optionalRequire('electron'); const path = optionalRequire('path'); +const url = optionalRequire('url'); class UrlsCache { projectCache: { [number]: { [string]: string } } = {}; @@ -132,15 +133,17 @@ export default class ResourcesLoader { // Support local filesystem with Electron const file = project.getProjectFile(); const projectPath = path.dirname(file); - const resourceAbsolutePath = path - .resolve(projectPath, urlOrFilename) - .replace(/\\/g, '/'); + const resourceAbsolutePath = path.resolve(projectPath, urlOrFilename); + const resourceFileUrl = + url && url.pathToFileURL + ? url.pathToFileURL(resourceAbsolutePath).toString() + : 'file://' + resourceAbsolutePath.replace(/\\/g, '/'); console.info('Caching resolved local filename:', resourceAbsolutePath); return this._cache.cacheLocalFileUrl( project, urlOrFilename, - 'file://' + resourceAbsolutePath, + resourceFileUrl, !!disableCacheBurst ); } diff --git a/newIDE/app/src/Utils/__mocks__/OptionalRequire.js b/newIDE/app/src/Utils/__mocks__/OptionalRequire.js index 73a2a6ff20a1..490e855186c1 100644 --- a/newIDE/app/src/Utils/__mocks__/OptionalRequire.js +++ b/newIDE/app/src/Utils/__mocks__/OptionalRequire.js @@ -45,6 +45,9 @@ const mockOptionalRequire = jest.fn( if (moduleName === 'path') { return path; } + if (moduleName === 'url') { + return require('url'); + } if (moduleName === 'os') { return mockOs; } From 8501c45577c095c5e6854f5866e34f7a9613842d Mon Sep 17 00:00:00 2001 From: tianheng2017 Date: Sat, 16 May 2026 17:48:02 +0800 Subject: [PATCH 2/5] Cover runtime resource URLs with hashes --- GDJS/Runtime/ResourceLoader.ts | 15 ++++++ .../ResourcesList/ResourceUrlEncoding.spec.js | 50 +++++++++++++++---- 2 files changed, 54 insertions(+), 11 deletions(-) diff --git a/GDJS/Runtime/ResourceLoader.ts b/GDJS/Runtime/ResourceLoader.ts index 0bb1ee9e4046..b056954c21ee 100644 --- a/GDJS/Runtime/ResourceLoader.ts +++ b/GDJS/Runtime/ResourceLoader.ts @@ -29,6 +29,19 @@ namespace gdjs { ); }; + const encodeLocalResourceUrl = (url: string): string => { + if ( + url.startsWith('http://') || + url.startsWith('https://') || + url.startsWith('data:') || + url.startsWith('blob:') + ) { + return url; + } + + return encodeURI(url).replace(/#/g, '%23'); + }; + /** * A task of pre-loading resources used by a scene. * @@ -617,6 +630,8 @@ namespace gdjs { * the resource (this can be for example a token needed to access the resource). */ getFullUrl(url: string) { + url = encodeLocalResourceUrl(url); + if (this._runtimeGame.isInGameEdition()) { // Avoid adding cache burst to URLs which are assumed to be immutable files, // to avoid costly useless requests each time the game is hot-reloaded. diff --git a/newIDE/app/src/ResourcesList/ResourceUrlEncoding.spec.js b/newIDE/app/src/ResourcesList/ResourceUrlEncoding.spec.js index 3e16ab662802..2ed1ab4c1e66 100644 --- a/newIDE/app/src/ResourcesList/ResourceUrlEncoding.spec.js +++ b/newIDE/app/src/ResourcesList/ResourceUrlEncoding.spec.js @@ -3,7 +3,6 @@ import path from 'path'; import { pathToFileURL } from 'url'; import { getLocalResourceFullPath } from './ResourceUtils'; import ResourcesLoader from '../ResourcesLoader'; -import optionalRequire from '../Utils/OptionalRequire'; jest.mock('../Utils/OptionalRequire'); jest.mock('../Utils/CrossOrigin', () => ({ @@ -11,17 +10,30 @@ jest.mock('../Utils/CrossOrigin', () => ({ })); describe('ResourceUrlEncoding', () => { - it('encodes hashes in local resource urls and decodes them back', () => { - const project = { - ptr: 1, - getProjectFile: () => 'E:\\GDevelop\\Game\\game.json', - getResourcesManager: () => ({ - hasResource: () => true, - getResource: () => ({ - getFile: () => 'Weekend Jam #1/Parts/hero.png', - }), + let dateNowSpy; + + const makeProject = ptr => ({ + ptr, + getProjectFile: () => 'E:\\GDevelop\\Game\\game.json', + getResourcesManager: () => ({ + hasResource: () => true, + getResource: () => ({ + getFile: () => 'Weekend Jam #1/Parts/hero.png', }), - }; + }), + }); + + beforeEach(() => { + ResourcesLoader.burstAllUrlsCache(); + dateNowSpy = jest.spyOn(Date, 'now').mockReturnValue(1234); + }); + + afterEach(() => { + dateNowSpy.mockRestore(); + }); + + it('encodes hashes in local resource urls and decodes them back', () => { + const project = makeProject(1); const fullUrl = ResourcesLoader.getResourceFullUrl( project, @@ -39,4 +51,20 @@ describe('ResourceUrlEncoding', () => { getLocalResourceFullPath(project, 'Weekend Jam #1/Parts/hero.png') ).toBe(path.resolve('E:\\GDevelop\\Game', 'Weekend Jam #1/Parts/hero.png')); }); + + it('adds cache busting after the encoded local resource url', () => { + const project = makeProject(2); + + const fullUrl = ResourcesLoader.getResourceFullUrl( + project, + 'Weekend Jam #1/Parts/hero.png', + {} + ); + + expect(fullUrl).toBe( + pathToFileURL( + path.resolve('E:\\GDevelop\\Game', 'Weekend Jam #1/Parts/hero.png') + ).toString() + '?cache=1234' + ); + }); }); From 9ad135cae7efc2ec65e343976ab65cd44e8df58f Mon Sep 17 00:00:00 2001 From: tianheng2017 Date: Sat, 16 May 2026 18:09:47 +0800 Subject: [PATCH 3/5] Make resource URL regression tests Flow-safe --- .../ResourcesList/ResourceUrlEncoding.spec.js | 34 +++++++------------ .../src/ResourcesList/ResourceUtils.spec.js | 17 ++++++++++ 2 files changed, 29 insertions(+), 22 deletions(-) diff --git a/newIDE/app/src/ResourcesList/ResourceUrlEncoding.spec.js b/newIDE/app/src/ResourcesList/ResourceUrlEncoding.spec.js index 2ed1ab4c1e66..c41d3cdbd5ea 100644 --- a/newIDE/app/src/ResourcesList/ResourceUrlEncoding.spec.js +++ b/newIDE/app/src/ResourcesList/ResourceUrlEncoding.spec.js @@ -1,7 +1,4 @@ // @flow -import path from 'path'; -import { pathToFileURL } from 'url'; -import { getLocalResourceFullPath } from './ResourceUtils'; import ResourcesLoader from '../ResourcesLoader'; jest.mock('../Utils/OptionalRequire'); @@ -12,13 +9,13 @@ jest.mock('../Utils/CrossOrigin', () => ({ describe('ResourceUrlEncoding', () => { let dateNowSpy; - const makeProject = ptr => ({ + const makeProject = (ptr: number): any => ({ ptr, - getProjectFile: () => 'E:\\GDevelop\\Game\\game.json', + getProjectFile: () => 'E:\\GDevelop\\Weekend Jam #1\\game.json', getResourcesManager: () => ({ hasResource: () => true, getResource: () => ({ - getFile: () => 'Weekend Jam #1/Parts/hero.png', + getFile: () => 'Parts/hero #1.png', }), }), }); @@ -32,24 +29,18 @@ describe('ResourceUrlEncoding', () => { dateNowSpy.mockRestore(); }); - it('encodes hashes in local resource urls and decodes them back', () => { + it('encodes hashes in local resource urls', () => { const project = makeProject(1); const fullUrl = ResourcesLoader.getResourceFullUrl( project, - 'Weekend Jam #1/Parts/hero.png', + 'Parts/hero #1.png', { disableCacheBurst: true } ); expect(fullUrl).toContain('%23'); - expect(fullUrl).toBe( - pathToFileURL( - path.resolve('E:\\GDevelop\\Game', 'Weekend Jam #1/Parts/hero.png') - ).toString() - ); - expect( - getLocalResourceFullPath(project, 'Weekend Jam #1/Parts/hero.png') - ).toBe(path.resolve('E:\\GDevelop\\Game', 'Weekend Jam #1/Parts/hero.png')); + expect(fullUrl).not.toContain('#'); + expect(fullUrl).not.toContain('?cache='); }); it('adds cache busting after the encoded local resource url', () => { @@ -57,14 +48,13 @@ describe('ResourceUrlEncoding', () => { const fullUrl = ResourcesLoader.getResourceFullUrl( project, - 'Weekend Jam #1/Parts/hero.png', + 'Parts/hero #1.png', {} ); - expect(fullUrl).toBe( - pathToFileURL( - path.resolve('E:\\GDevelop\\Game', 'Weekend Jam #1/Parts/hero.png') - ).toString() + '?cache=1234' - ); + expect(fullUrl).toContain('%23'); + expect(fullUrl).not.toContain('#'); + expect(fullUrl).toContain('?cache=1234'); + expect(fullUrl.indexOf('%23')).toBeLessThan(fullUrl.indexOf('?cache=1234')); }); }); diff --git a/newIDE/app/src/ResourcesList/ResourceUtils.spec.js b/newIDE/app/src/ResourcesList/ResourceUtils.spec.js index b7c2d8ef7392..c9eae8b8129b 100644 --- a/newIDE/app/src/ResourcesList/ResourceUtils.spec.js +++ b/newIDE/app/src/ResourcesList/ResourceUtils.spec.js @@ -1,5 +1,6 @@ // @flow import { + getLocalResourceFullPath, parseLocalFilePathOrExtensionFromMetadata, renameResourcesInProject, updateResourceJsonMetadata, @@ -10,6 +11,7 @@ jest.mock('../ResourcesLoader', () => ({ getResourceFullUrl: jest.fn(), }, })); +const ResourcesLoader = require('../ResourcesLoader').default; const gd: libGDevelop = global.gd; const addNewAnimationWithImageToSpriteObject = ( @@ -28,6 +30,21 @@ const addNewAnimationWithImageToSpriteObject = ( }; describe('ResourceUtils', () => { + beforeEach(() => { + ResourcesLoader.getResourceFullUrl.mockReset(); + }); + + it('can decode an encoded local file URL', () => { + const project: any = {}; + ResourcesLoader.getResourceFullUrl.mockReturnValue( + 'file:///E:/GDevelop/Weekend%20Jam%20%231/Parts/hero%20%231.png?cache=1234' + ); + + expect(getLocalResourceFullPath(project, 'hero')).toBe( + 'E:\\GDevelop\\Weekend Jam #1\\Parts\\hero #1.png' + ); + }); + it('can rename a resource in the whole project', () => { const project = gd.ProjectHelper.createNewGDJSProject(); From d9a4aa3dc7890be9f93d18c695c85867ec809c5a Mon Sep 17 00:00:00 2001 From: tianheng2017 Date: Sat, 16 May 2026 19:00:18 +0800 Subject: [PATCH 4/5] Improve libGD download fallback for CI --- newIDE/app/scripts/import-libGD.js | 129 ++++++++++++++++++----------- 1 file changed, 82 insertions(+), 47 deletions(-) diff --git a/newIDE/app/scripts/import-libGD.js b/newIDE/app/scripts/import-libGD.js index 2accaaeac2f9..7e346637ff92 100644 --- a/newIDE/app/scripts/import-libGD.js +++ b/newIDE/app/scripts/import-libGD.js @@ -66,17 +66,28 @@ if (shell.test('-f', path.join(sourceDirectory, 'libGD.js'))) { return branch; }; - // Try to download libGD.js from a specific commit on the current branch - const downloadCommitLibGdJs = (branch, gitRef) => + const getHashFromGitRef = gitRef => { + const hashShellString = shell.exec(`git rev-parse "${gitRef}"`, { + silent: true, + }); + const hash = (hashShellString.stdout || 'unknown-hash').trim(); + + if (hashShellString.stderr || hashShellString.code) { + shell.echo(`⚠️ Can't find the hash of the associated commit.`); + return null; + } + + return hash; + }; + + // Try to download libGD.js from a specific commit on the inferred branch. + const downloadCommitLibGdJs = gitRef => new Promise((resolve, reject) => { shell.echo(`ℹ️ Trying to download libGD.js for ${gitRef}.`); - var hashShellString = shell.exec(`git rev-parse "${gitRef}"`, { - silent: true, - }); - const hash = (hashShellString.stdout || 'unknown-hash').trim(); + const hash = getHashFromGitRef(gitRef); const branch = getBranchFromGitRef(gitRef); - if (hashShellString.stderr || hashShellString.code || !branch) { + if (!hash || !branch) { shell.echo( `⚠️ Can't find the hash or branch of the associated commit.` ); @@ -91,6 +102,23 @@ if (shell.test('-f', path.join(sourceDirectory, 'libGD.js'))) { ); }); + const downloadMasterCommitLibGdJs = gitRef => + new Promise((resolve, reject) => { + shell.echo(`ℹ️ Trying to download libGD.js for ${gitRef} from master.`); + + const hash = getHashFromGitRef(gitRef); + if (!hash) { + reject(); + return; + } + + resolve( + downloadLibGdJs( + `https://s3.amazonaws.com/gdevelop-gdevelop.js/master/commit/${hash}` + ) + ); + }); + // Try to download libGD.js from the latest version built for master branch. const downloadBranchLatestLibGdJs = branchName => { shell.echo( @@ -151,48 +179,55 @@ if (shell.test('-f', path.join(sourceDirectory, 'libGD.js'))) { }; const branch = getBranchFromGitRef('HEAD'); + const downloadCommitLibGdJsWithMasterFallback = gitRef => + downloadCommitLibGdJs(gitRef).catch(() => + downloadMasterCommitLibGdJs(gitRef) + ); + const tryDownloadInOrder = downloaders => + downloaders.reduce( + (previousDownload, download) => previousDownload.catch(() => download()), + Promise.reject() + ); // Try to download the latest libGD.js, fallback to previous or master ones // if not found (including different parents, for handling of merge commits). - downloadCommitLibGdJs(branch, 'HEAD').then(onLibGdJsDownloaded, () => { - // Force the exact version of GDevelop.js to be downloaded for AppVeyor - because - // this means we build the app and we don't want to risk mismatch (Core C++ not up to date - // with the IDE JavaScript). - if (process.env.APPVEYOR || process.env.REQUIRES_EXACT_LIBGD_JS_VERSION) { - shell.echo( - `❌ Can't download the exact required version of libGD.js - check it was built by CircleCI before running this CI.` - ); - shell.echo( - `ℹ️ See the pipeline on https://app.circleci.com/pipelines/github/4ian/GDevelop.` - ); - shell.exit(1); - } + downloadCommitLibGdJsWithMasterFallback('HEAD').then( + onLibGdJsDownloaded, + () => { + // Force the exact version of GDevelop.js to be downloaded for AppVeyor - because + // this means we build the app and we don't want to risk mismatch (Core C++ not up to date + // with the IDE JavaScript). + if (process.env.APPVEYOR || process.env.REQUIRES_EXACT_LIBGD_JS_VERSION) { + shell.echo( + `❌ Can't download the exact required version of libGD.js - check it was built by CircleCI before running this CI.` + ); + shell.echo( + `ℹ️ See the pipeline on https://app.circleci.com/pipelines/github/4ian/GDevelop.` + ); + shell.exit(1); + } - downloadCommitLibGdJs(branch, 'HEAD~1').then(onLibGdJsDownloaded, () => - downloadCommitLibGdJs(branch, 'HEAD~2').then(onLibGdJsDownloaded, () => - downloadCommitLibGdJs(branch, 'HEAD~3').then(onLibGdJsDownloaded, () => - downloadBranchLatestLibGdJs(branch).then(onLibGdJsDownloaded, () => - downloadBranchLatestLibGdJs('master').then( - onLibGdJsDownloaded, - () => { - if (alreadyHasLibGdJs) { - shell.echo( - `ℹ️ Can't download any version of libGD.js, assuming you can go ahead with the existing one.` - ); - shell.exit(0); - return; - } else { - shell.echo( - `❌ Can't download any version of libGD.js, please check your internet connection.` - ); - shell.exit(1); - return; - } - } - ) - ) - ) - ) - ); - }); + tryDownloadInOrder([ + () => downloadCommitLibGdJsWithMasterFallback('HEAD~1'), + () => downloadCommitLibGdJsWithMasterFallback('HEAD~2'), + () => downloadCommitLibGdJsWithMasterFallback('HEAD~3'), + () => downloadBranchLatestLibGdJs(branch), + () => downloadBranchLatestLibGdJs('master'), + ]).then(onLibGdJsDownloaded, () => { + if (alreadyHasLibGdJs) { + shell.echo( + `ℹ️ Can't download any version of libGD.js, assuming you can go ahead with the existing one.` + ); + shell.exit(0); + return; + } else { + shell.echo( + `❌ Can't download any version of libGD.js, please check your internet connection.` + ); + shell.exit(1); + return; + } + }); + } + ); } From f793927ee174c0980cbecc0267e6e5d4ed609a3c Mon Sep 17 00:00:00 2001 From: tianheng2017 Date: Sat, 16 May 2026 19:23:27 +0800 Subject: [PATCH 5/5] Make resource path decoding test cross-platform --- newIDE/app/src/ResourcesList/ResourceUtils.spec.js | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/newIDE/app/src/ResourcesList/ResourceUtils.spec.js b/newIDE/app/src/ResourcesList/ResourceUtils.spec.js index c9eae8b8129b..2732485cfcdf 100644 --- a/newIDE/app/src/ResourcesList/ResourceUtils.spec.js +++ b/newIDE/app/src/ResourcesList/ResourceUtils.spec.js @@ -40,9 +40,10 @@ describe('ResourceUtils', () => { 'file:///E:/GDevelop/Weekend%20Jam%20%231/Parts/hero%20%231.png?cache=1234' ); - expect(getLocalResourceFullPath(project, 'hero')).toBe( - 'E:\\GDevelop\\Weekend Jam #1\\Parts\\hero #1.png' - ); + expect([ + 'E:\\GDevelop\\Weekend Jam #1\\Parts\\hero #1.png', + '/E:/GDevelop/Weekend Jam #1/Parts/hero #1.png', + ]).toContain(getLocalResourceFullPath(project, 'hero')); }); it('can rename a resource in the whole project', () => {