Spaces:
Runtime error
Runtime error
| 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 | |
| }; | |