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 protobufBundle from './project.protobuf.json'; | |
import protobuf from 'protobufjs'; | |
import JSZip from 'jszip'; | |
let protoRoot = protobuf.Root.fromJSON(protobufBundle); | |
let Project = protoRoot.lookupType('project.Project'); | |
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; | |
}); | |
}; | |
function protobufToJson(buffer) { | |
const message = Project.decode(buffer); | |
const json = Project.toObject(message); | |
const newJson = { | |
targets: [], | |
monitors: [], | |
extensionData: {}, | |
extensions: json.extensions, | |
extensionURLs: {}, | |
meta: { | |
semver: json.metaSemver, | |
vm: json.metaVm, | |
agent: json.metaAgent || "" | |
}, | |
customFonts: json.fonts | |
}; | |
for (const target of json.targets) { | |
let newTarget = { | |
isStage: target.isStage || false, | |
name: target.name, | |
variables: {}, | |
lists: {}, | |
broadcasts: {}, | |
customVars: [], | |
blocks: {}, | |
comments: {}, | |
currentCostume: target.currentCostume, | |
costumes: [], | |
sounds: [], | |
id: target.id, | |
volume: target.volume, | |
layerOrder: target.layerOrder, | |
tempo: target.tempo, | |
videoTransparency: target.videoTransparency, | |
videoState: target.videoState, | |
textToSpeechLanguage: target.textToSpeechLanguage || null, | |
visible: target.visible, | |
x: target.x, | |
y: target.y, | |
size: target.size, | |
direction: target.direction, | |
draggable: target.draggable, | |
rotationStyle: target.rotationStyle | |
}; | |
if (newTarget.isStage) { | |
delete newTarget.visible, delete newTarget.size, delete newTarget.direction, delete newTarget.draggable, delete newTarget.rotationStyle; | |
} | |
for (const variable in target.variables) { | |
newTarget.variables[variable] = [target.variables[variable].name, target.variables[variable].value]; | |
} | |
for (const list in target.lists) { | |
newTarget.lists[list] = [target.lists[list].name, target.lists[list].value || []]; | |
} | |
for (const broadcast in target.broadcasts) { | |
newTarget.broadcasts[broadcast] = target.broadcasts[broadcast]; | |
} | |
for (const customVar in target.customVars) { | |
newTarget.customVars.push(target.customVars[customVar]); | |
} | |
for (const block in target.blocks) { | |
if (target.blocks[block].is_variable_reporter) { | |
newTarget.blocks[block] = [ | |
target.blocks[block].varReporterBlock.first_num, | |
target.blocks[block].varReporterBlock.name, | |
target.blocks[block].varReporterBlock.id, | |
target.blocks[block].varReporterBlock.second_num, | |
target.blocks[block].varReporterBlock.third_num, | |
] | |
continue; | |
} | |
newTarget.blocks[block] = { | |
opcode: target.blocks[block].opcode, | |
next: target.blocks[block].next || null, | |
parent: target.blocks[block].parent || null, | |
inputs: {}, | |
fields: {}, | |
shadow: target.blocks[block].shadow, | |
topLevel: target.blocks[block].topLevel, | |
x: target.blocks[block].x, | |
y: target.blocks[block].y | |
} | |
if (target.blocks[block].mutation) { | |
newTarget.blocks[block].mutation = { | |
tagName: target.blocks[block].mutation.tagName, | |
proccode: target.blocks[block].mutation.proccode, | |
argumentids: target.blocks[block].mutation.argumentids, | |
argumentnames: target.blocks[block].mutation.argumentnames, | |
argumentdefaults: target.blocks[block].mutation.argumentdefaults, | |
warp: target.blocks[block].mutation.warp, | |
returns: target.blocks[block].mutation._returns, | |
edited: target.blocks[block].mutation.edited, | |
optype: target.blocks[block].mutation.optype, | |
color: target.blocks[block].mutation.color, | |
hasnext: target.blocks[block].next ? true : false, | |
children: [] | |
} | |
} | |
for (const input in target.blocks[block].inputs) { | |
newTarget.blocks[block].inputs[input] = JSON.parse(target.blocks[block].inputs[input]); | |
} | |
for (const field in target.blocks[block].fields) { | |
newTarget.blocks[block].fields[field] = JSON.parse(target.blocks[block].fields[field]); | |
} | |
} | |
for (const comment in target.comments) { | |
newTarget.comments[comment] = target.comments[comment]; | |
} | |
for (const costume in target.costumes) { | |
newTarget.costumes[costume] = target.costumes[costume]; | |
} | |
for (const sound in target.sounds) { | |
newTarget.sounds[sound] = target.sounds[sound]; | |
} | |
newJson.targets.push(newTarget); | |
} | |
for (const monitor in json.monitors) { | |
let newMonitor = { | |
id: json.monitors[monitor].id, | |
mode: json.monitors[monitor].mode, | |
opcode: json.monitors[monitor].opcode, | |
params: json.monitors[monitor].params, | |
spriteName: json.monitors[monitor].spriteName || null, | |
value: json.monitors[monitor].value, | |
width: json.monitors[monitor].width, | |
height: json.monitors[monitor].height, | |
x: json.monitors[monitor].x, | |
y: json.monitors[monitor].y, | |
visible: json.monitors[monitor].visible, | |
sliderMin: json.monitors[monitor].sliderMin, | |
sliderMax: json.monitors[monitor].sliderMax, | |
isDiscrete: json.monitors[monitor].isDiscrete | |
} | |
newJson.monitors.push(newMonitor); | |
} | |
for (const extensionData in json.antiSigmaExtensionData) { | |
// "legacy" shit | |
newJson.extensionData[extensionData] = json.antiSigmaExtensionData[extensionData]; | |
} | |
for (const extensionData in json.extensionData) { | |
if (json.extensionData[extensionData].parse) { | |
newJson.extensionData[extensionData] = JSON.parse(json.extensionData[extensionData].data); | |
} else { | |
newJson.extensionData[extensionData] = json.extensionData[extensionData].data; | |
} | |
} | |
for (const extensionURL in json.extensionURLs) { | |
newJson.extensionURLs[extensionURL] = json.extensionURLs[extensionURL]; | |
} | |
return newJson; | |
} | |
/* 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 => { | |
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 => { | |
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 | |
}; | |