Spaces:
Running
Running
import React from 'react'; | |
import PropTypes from 'prop-types'; | |
import { intlShape, injectIntl } from 'react-intl'; | |
import bindAll from 'lodash.bindall'; | |
import { connect } from 'react-redux'; | |
import JSZip from 'jszip'; | |
import { protobufToJson } from 'pmp-protobuf' | |
import { setProjectUnchanged } from '../reducers/project-changed'; | |
import { | |
LoadingStates, | |
getIsCreatingNew, | |
getIsFetchingWithId, | |
getIsLoading, | |
getIsShowingProject, | |
onFetchedProjectData, | |
projectError, | |
setProjectId | |
} from '../reducers/project-state'; | |
import { | |
activateTab, | |
BLOCKS_TAB_INDEX | |
} from '../reducers/editor-tab'; | |
import log from './log'; | |
import storage from './storage'; | |
import { MISSING_PROJECT_ID } from './tw-missing-project'; | |
import VM from 'scratch-vm'; | |
import * as progressMonitor from '../components/loader/tw-progress-monitor'; | |
// TW: Temporary hack for project tokens | |
const fetchProjectToken = projectId => { | |
if (projectId === '0') { | |
return Promise.resolve(null); | |
} | |
// Parse ?token=abcdef | |
const searchParams = new URLSearchParams(location.search); | |
if (searchParams.has('token')) { | |
return Promise.resolve(searchParams.get('token')); | |
} | |
// Parse #1?token=abcdef | |
const hashParams = new URLSearchParams(location.hash.split('?')[1]); | |
if (hashParams.has('token')) { | |
return Promise.resolve(hashParams.get('token')); | |
} | |
return fetch(`https://projects.penguinmod.com/api/v1/projects/getproject?projectID=${projectId}&requestType=metadata`) | |
.then(r => { | |
if (!r.ok) return null; | |
return r.json(); | |
}) | |
.then(dataOrNull => { | |
const token = dataOrNull ? dataOrNull.id : null; | |
return token; | |
}) | |
.catch(err => { | |
log.error(err); | |
return null; | |
}); | |
}; | |
/* Higher Order Component to provide behavior for loading projects by id. If | |
* there's no id, the default project is loaded. | |
* @param {React.Component} WrappedComponent component to receive projectData prop | |
* @returns {React.Component} component with project loading behavior | |
*/ | |
const ProjectFetcherHOC = function (WrappedComponent) { | |
class ProjectFetcherComponent extends React.Component { | |
constructor(props) { | |
super(props); | |
bindAll(this, [ | |
'fetchProject' | |
]); | |
storage.setProjectHost(props.projectHost); | |
storage.setProjectToken(props.projectToken); | |
storage.setAssetHost(props.assetHost); | |
storage.setTranslatorFunction(props.intl.formatMessage); | |
// props.projectId might be unset, in which case we use our default; | |
// or it may be set by an even higher HOC, and passed to us. | |
// Either way, we now know what the initial projectId should be, so | |
// set it in the redux store. | |
if ( | |
props.projectId !== '' && | |
props.projectId !== null && | |
typeof props.projectId !== 'undefined' | |
) { | |
this.props.setProjectId(props.projectId.toString()); | |
} | |
} | |
componentDidUpdate(prevProps) { | |
if (prevProps.projectHost !== this.props.projectHost) { | |
storage.setProjectHost(this.props.projectHost); | |
} | |
if (prevProps.projectToken !== this.props.projectToken) { | |
storage.setProjectToken(this.props.projectToken); | |
} | |
if (prevProps.assetHost !== this.props.assetHost) { | |
storage.setAssetHost(this.props.assetHost); | |
} | |
if (this.props.isFetchingWithId && !prevProps.isFetchingWithId) { | |
this.fetchProject(this.props.reduxProjectId, this.props.loadingState); | |
} | |
if (this.props.isShowingProject && !prevProps.isShowingProject) { | |
this.props.onProjectUnchanged(); | |
} | |
if (this.props.isShowingProject && (prevProps.isLoadingProject || prevProps.isCreatingNew)) { | |
this.props.onActivateTab(BLOCKS_TAB_INDEX); | |
} | |
} | |
fetchProject(projectId, loadingState) { | |
// tw: clear and stop the VM before fetching | |
// these will also happen later after the project is fetched, but fetching may take a while and | |
// the project shouldn't be running while fetching the new project | |
this.props.vm.clear(); | |
this.props.vm.stop(); | |
let assetPromise; | |
// In case running in node... | |
let projectUrl = typeof URLSearchParams === 'undefined' ? | |
null : | |
new URLSearchParams(location.search).get('project_url'); | |
if (projectUrl) { | |
if (!projectUrl.startsWith('http:') && !projectUrl.startsWith('https:')) { | |
projectUrl = `https://${projectUrl}`; | |
} | |
assetPromise = progressMonitor.fetchWithProgress(projectUrl) | |
.then(r => { | |
if (this.props.vm.runtime.renderer?.setPrivateSkinAccess) | |
this.props.vm.runtime.renderer.setPrivateSkinAccess(false); | |
if (!r.ok) { | |
throw new Error(`Request returned status ${r.status}`); | |
} | |
return r.arrayBuffer(); | |
}) | |
.then(buffer => ({ data: buffer })); | |
} else { | |
// patch for default project | |
if (projectId === '0') { | |
storage.setProjectToken(projectId); | |
assetPromise = storage.load(storage.AssetType.Project, projectId, storage.DataFormat.JSON); | |
} else { | |
projectUrl = `https://projects.penguinmod.com/api/v1/projects/getprojectwrapper?safe=true&projectId=${projectId}`; | |
assetPromise = progressMonitor.fetchWithProgress(projectUrl) | |
.then(async r => { | |
if (this.props.vm.runtime.renderer?.setPrivateSkinAccess) | |
this.props.vm.runtime.renderer.setPrivateSkinAccess(false); | |
if (!r.ok) { | |
throw new Error(`Request returned status ${r.status}`); | |
} | |
const project = await r.json(); | |
const json = protobufToJson(new Uint8Array(project.project.data)); | |
// now get the assets | |
let zip = new JSZip(); | |
zip.file("project.json", JSON.stringify(json)); | |
for (const asset of project.assets) { | |
zip.file(asset.id, new Uint8Array(asset.buffer.data).buffer); | |
} | |
const arrayBuffer = await zip.generateAsync({ type: "arraybuffer" }); | |
return arrayBuffer; | |
}) | |
.then(buffer => ({ data: buffer })) | |
.catch(error => { | |
console.log(error) | |
}) | |
} | |
} | |
return assetPromise | |
.then(projectAsset => { | |
// tw: If the project data appears to be HTML, then the result is probably an nginx 404 page, | |
// and the "missing project" project should be loaded instead. | |
// See: https://projects.scratch.mit.edu/9999999999999999999999 | |
if (projectAsset && projectAsset.data) { | |
const firstChar = projectAsset.data[0]; | |
if (firstChar === '<' || firstChar === '<'.charCodeAt(0)) { | |
return storage.load(storage.AssetType.Project, MISSING_PROJECT_ID, storage.DataFormat.JSON); | |
} | |
} | |
return projectAsset; | |
}) | |
.then(projectAsset => { | |
if (projectAsset) { | |
this.props.onFetchedProjectData(projectAsset.data, loadingState); | |
} else { | |
// pm: Failed to grab data, use the "fetch" API as a backup | |
// we shouldnt be interrupted by the fetch replacement in tw-progress-monitor | |
// as it uses projects.scratch.mit.edu still | |
fetch(projectUrl).then(async res => { | |
if (!res.ok) { | |
// Treat failure to load as an error | |
// Throw to be caught by catch later on | |
throw new Error('Could not find project; ' + projectUrl); | |
} | |
const project = await res.json(); | |
const json = protobufToJson(new Uint8Array(project.project.data)); | |
// now get the assets | |
let zip = new JSZip(); | |
zip.file("project.json", JSON.stringify(json)); | |
for (const asset of project.assets) { | |
zip.file(asset.id, new Uint8Array(asset.buffer.data).buffer); | |
} | |
const arrayBuffer = await zip.generateAsync({ type: "arraybuffer" }); | |
this.props.onFetchedProjectData(arrayBuffer, loadingState); | |
}).catch(err => { | |
throw new Error('Could not find project; ' + err); | |
}) | |
} | |
}) | |
.catch(err => { | |
this.props.onError(err); | |
log.error(err); | |
}); | |
} | |
render() { | |
const { | |
/* eslint-disable no-unused-vars */ | |
assetHost, | |
intl, | |
isLoadingProject: isLoadingProjectProp, | |
loadingState, | |
onActivateTab, | |
onError: onErrorProp, | |
onFetchedProjectData: onFetchedProjectDataProp, | |
onProjectUnchanged, | |
projectHost, | |
projectId, | |
reduxProjectId, | |
setProjectId: setProjectIdProp, | |
/* eslint-enable no-unused-vars */ | |
isFetchingWithId: isFetchingWithIdProp, | |
...componentProps | |
} = this.props; | |
return ( | |
<WrappedComponent | |
fetchingProject={isFetchingWithIdProp} | |
{...componentProps} | |
/> | |
); | |
} | |
} | |
ProjectFetcherComponent.propTypes = { | |
assetHost: PropTypes.string, | |
canSave: PropTypes.bool, | |
intl: intlShape.isRequired, | |
isCreatingNew: PropTypes.bool, | |
isFetchingWithId: PropTypes.bool, | |
isLoadingProject: PropTypes.bool, | |
isShowingProject: PropTypes.bool, | |
loadingState: PropTypes.oneOf(LoadingStates), | |
onActivateTab: PropTypes.func, | |
onError: PropTypes.func, | |
onFetchedProjectData: PropTypes.func, | |
onProjectUnchanged: PropTypes.func, | |
projectHost: PropTypes.string, | |
projectToken: PropTypes.string, | |
projectId: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), | |
reduxProjectId: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), | |
setProjectId: PropTypes.func, | |
vm: PropTypes.instanceOf(VM) | |
}; | |
ProjectFetcherComponent.defaultProps = { | |
assetHost: 'https://assets.scratch.mit.edu', | |
projectHost: 'https://projects.scratch.mit.edu' | |
}; | |
const mapStateToProps = state => ({ | |
isCreatingNew: getIsCreatingNew(state.scratchGui.projectState.loadingState), | |
isFetchingWithId: getIsFetchingWithId(state.scratchGui.projectState.loadingState), | |
isLoadingProject: getIsLoading(state.scratchGui.projectState.loadingState), | |
isShowingProject: getIsShowingProject(state.scratchGui.projectState.loadingState), | |
loadingState: state.scratchGui.projectState.loadingState, | |
reduxProjectId: state.scratchGui.projectState.projectId, | |
vm: state.scratchGui.vm | |
}); | |
const mapDispatchToProps = dispatch => ({ | |
onActivateTab: tab => dispatch(activateTab(tab)), | |
onError: error => dispatch(projectError(error)), | |
onFetchedProjectData: (projectData, loadingState) => | |
dispatch(onFetchedProjectData(projectData, loadingState)), | |
setProjectId: projectId => dispatch(setProjectId(projectId)), | |
onProjectUnchanged: () => dispatch(setProjectUnchanged()) | |
}); | |
// Allow incoming props to override redux-provided props. Used to mock in tests. | |
const mergeProps = (stateProps, dispatchProps, ownProps) => Object.assign( | |
{}, stateProps, dispatchProps, ownProps | |
); | |
return injectIntl(connect( | |
mapStateToProps, | |
mapDispatchToProps, | |
mergeProps | |
)(ProjectFetcherComponent)); | |
}; | |
export { | |
ProjectFetcherHOC as default | |
}; |