From 029d81b67582a7d8e7df19b2d65c0e2a307f998f Mon Sep 17 00:00:00 2001 From: Adam Sardo Date: Sat, 9 May 2026 21:59:25 +1000 Subject: [PATCH 1/3] Fix resource paths with hash characters --- GDJS/Runtime/ResourceLoader.ts | 15 +++++++++++++++ newIDE/app/src/ResourcesLoader/index.js | 11 +++++++---- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/GDJS/Runtime/ResourceLoader.ts b/GDJS/Runtime/ResourceLoader.ts index 66d8ed777492..edab754bc0c8 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/ResourcesLoader/index.js b/newIDE/app/src/ResourcesLoader/index.js index 43f2d79d6d80..5a4df16ac746 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 nodeUrl = 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 resourceUrl = nodeUrl + ? nodeUrl.pathToFileURL(resourceAbsolutePath).href + : 'file://' + + resourceAbsolutePath.replace(/\\/g, '/').replace(/#/g, '%23'); console.info('Caching resolved local filename:', resourceAbsolutePath); return this._cache.cacheLocalFileUrl( project, urlOrFilename, - 'file://' + resourceAbsolutePath, + resourceUrl, !!disableCacheBurst ); } From f5c52b1c7da13e91bdc3772f92b7d8ea48892e0c Mon Sep 17 00:00:00 2001 From: Adam Sardo Date: Sat, 9 May 2026 22:50:32 +1000 Subject: [PATCH 2/3] Fix optionalRequire mock for url module --- newIDE/app/src/Utils/__mocks__/OptionalRequire.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/newIDE/app/src/Utils/__mocks__/OptionalRequire.js b/newIDE/app/src/Utils/__mocks__/OptionalRequire.js index 73a2a6ff20a1..3b8d98704599 100644 --- a/newIDE/app/src/Utils/__mocks__/OptionalRequire.js +++ b/newIDE/app/src/Utils/__mocks__/OptionalRequire.js @@ -1,4 +1,5 @@ import path from 'path'; +import nodeUrl from 'url'; const mockElectron = { ipcRenderer: { @@ -45,6 +46,9 @@ const mockOptionalRequire = jest.fn( if (moduleName === 'path') { return path; } + if (moduleName === 'url') { + return nodeUrl; + } if (moduleName === 'os') { return mockOs; } From d8c66781a41567d5dba275b466e801bd0095ba3d Mon Sep 17 00:00:00 2001 From: Adam Sardo Date: Sat, 9 May 2026 23:55:12 +1000 Subject: [PATCH 3/3] Add hash resource path regression test --- newIDE/app/src/ResourcesLoader/index.spec.js | 34 ++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 newIDE/app/src/ResourcesLoader/index.spec.js diff --git a/newIDE/app/src/ResourcesLoader/index.spec.js b/newIDE/app/src/ResourcesLoader/index.spec.js new file mode 100644 index 000000000000..51fad9f7d907 --- /dev/null +++ b/newIDE/app/src/ResourcesLoader/index.spec.js @@ -0,0 +1,34 @@ +// @flow +import ResourcesLoader from './index'; + +jest.mock('../Utils/OptionalRequire'); + +describe('ResourcesLoader', () => { + let dateNowSpy; + + beforeEach(() => { + ResourcesLoader.burstAllUrlsCache(); + dateNowSpy = jest.spyOn(Date, 'now').mockReturnValue(1234); + }); + + afterEach(() => { + dateNowSpy.mockRestore(); + }); + + test('encodes hash characters in local file URLs before adding cache parameters', () => { + const project: any = { + ptr: 1, + getProjectFile: () => '/project/Game Jam #1/game.json', + }; + + const resolvedUrl = ResourcesLoader.getFullUrl( + project, + 'Parts/hero #1.png', + {} + ); + + expect(resolvedUrl).toBe( + 'file:///project/Game%20Jam%20%231/Parts/hero%20%231.png?cache=1234' + ); + }); +});