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/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; + } + }); + } + ); } diff --git a/newIDE/app/src/ResourcesList/ResourceUrlEncoding.spec.js b/newIDE/app/src/ResourcesList/ResourceUrlEncoding.spec.js new file mode 100644 index 000000000000..c41d3cdbd5ea --- /dev/null +++ b/newIDE/app/src/ResourcesList/ResourceUrlEncoding.spec.js @@ -0,0 +1,60 @@ +// @flow +import ResourcesLoader from '../ResourcesLoader'; + +jest.mock('../Utils/OptionalRequire'); +jest.mock('../Utils/CrossOrigin', () => ({ + addGDevelopResourceTokenIfRequired: url => url, +})); + +describe('ResourceUrlEncoding', () => { + let dateNowSpy; + + const makeProject = (ptr: number): any => ({ + ptr, + getProjectFile: () => 'E:\\GDevelop\\Weekend Jam #1\\game.json', + getResourcesManager: () => ({ + hasResource: () => true, + getResource: () => ({ + getFile: () => 'Parts/hero #1.png', + }), + }), + }); + + beforeEach(() => { + ResourcesLoader.burstAllUrlsCache(); + dateNowSpy = jest.spyOn(Date, 'now').mockReturnValue(1234); + }); + + afterEach(() => { + dateNowSpy.mockRestore(); + }); + + it('encodes hashes in local resource urls', () => { + const project = makeProject(1); + + const fullUrl = ResourcesLoader.getResourceFullUrl( + project, + 'Parts/hero #1.png', + { disableCacheBurst: true } + ); + + expect(fullUrl).toContain('%23'); + expect(fullUrl).not.toContain('#'); + expect(fullUrl).not.toContain('?cache='); + }); + + it('adds cache busting after the encoded local resource url', () => { + const project = makeProject(2); + + const fullUrl = ResourcesLoader.getResourceFullUrl( + project, + 'Parts/hero #1.png', + {} + ); + + 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.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..2732485cfcdf 100644 --- a/newIDE/app/src/ResourcesList/ResourceUtils.spec.js +++ b/newIDE/app/src/ResourcesList/ResourceUtils.spec.js @@ -1,9 +1,17 @@ // @flow import { + getLocalResourceFullPath, parseLocalFilePathOrExtensionFromMetadata, renameResourcesInProject, updateResourceJsonMetadata, } from './ResourceUtils'; +jest.mock('../ResourcesLoader', () => ({ + __esModule: true, + default: { + getResourceFullUrl: jest.fn(), + }, +})); +const ResourcesLoader = require('../ResourcesLoader').default; const gd: libGDevelop = global.gd; const addNewAnimationWithImageToSpriteObject = ( @@ -22,6 +30,22 @@ 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([ + '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', () => { const project = gd.ProjectHelper.createNewGDJSProject(); 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; }