diff --git a/package.json b/package.json index 37a9de0..6d00796 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ "lodash.bindall": "^4.4.0", "lodash.defaultsdeep": "^4.6.1", "minilog": "^3.1.0", + "minimist": "^1.2.5", "mkdirp": "^1.0.4", "nets": "^3.2.0", "react": "16.2.0", diff --git a/src/main/argv.js b/src/main/argv.js new file mode 100644 index 0000000..25be323 --- /dev/null +++ b/src/main/argv.js @@ -0,0 +1,23 @@ +import minimist from 'minimist'; + +// inspired by yargs' process-argv +export const isElectronApp = () => !!process.versions.electron; +export const isElectronBundledApp = () => isElectronApp() && !process.defaultApp; + +export const parseAndTrimArgs = argv => { + // bundled Electron app: ignore 1 from "my-app arg1 arg2" + // unbundled Electron app: ignore 2 from "electron main/index.js arg1 arg2" + // node.js app: ignore 2 from "node src/index.js arg1 arg2" + const ignoreCount = isElectronBundledApp() ? 1 : 2; + + const parsed = minimist(argv); + + // ignore arguments AFTER parsing to handle cases like "electron --inspect=42 my.js arg1 arg2" + parsed._ = parsed._.slice(ignoreCount); + + return parsed; +}; + +const argv = parseAndTrimArgs(process.argv); + +export default argv; diff --git a/src/main/index.js b/src/main/index.js index 9b0f04b..1688313 100644 --- a/src/main/index.js +++ b/src/main/index.js @@ -2,7 +2,9 @@ import {BrowserWindow, Menu, app, dialog, ipcMain, systemPreferences} from 'elec import fs from 'fs-extra'; import path from 'path'; import {URL} from 'url'; +import {promisify} from 'util'; +import argv from './argv'; import {getFilterForExtension} from './FileFilters'; import telemetry from './ScratchDesktopTelemetry'; import MacOSMenu from './MacOSMenu'; @@ -374,3 +376,29 @@ app.on('ready', () => { ipcMain.on('open-about-window', () => { _windows.about.show(); }); + +// start loading initial project data before the GUI needs it so the load seems faster +const initialProjectDataPromise = (async () => { + if (argv._.length === 0) { + // no command line argument means no initial project data + return; + } + if (argv._.length > 1) { + log.warn(`Expected 1 command line argument but received ${argv._.length}.`); + } + const projectPath = argv._[argv._.length - 1]; + try { + const projectData = await promisify(fs.readFile)(projectPath, null); + return projectData; + } catch (e) { + dialog.showMessageBox(_windows.main, { + type: 'error', + title: 'Failed to load project', + message: `Could not load project from file:\n${projectPath}`, + detail: e.message + }); + } + // load failed: initial project data undefined +})(); // IIFE + +ipcMain.handle('get-initial-project-data', () => initialProjectDataPromise); diff --git a/src/renderer/app.jsx b/src/renderer/app.jsx index 49ff284..1fe1a37 100644 --- a/src/renderer/app.jsx +++ b/src/renderer/app.jsx @@ -1,16 +1,33 @@ -import {ipcRenderer, shell} from 'electron'; +import {ipcRenderer, remote, shell} from 'electron'; import bindAll from 'lodash.bindall'; +import omit from 'lodash.omit'; +import PropTypes from 'prop-types'; import React from 'react'; import ReactDOM from 'react-dom'; +import {connect} from 'react-redux'; import {compose} from 'redux'; -import GUI, {AppStateHOC} from 'scratch-gui'; +import GUI from 'scratch-gui/src/index'; +import VM from 'scratch-vm'; + +import AppStateHOC from 'scratch-gui/src/lib/app-state-hoc.jsx'; +import { + LoadingStates, + onFetchedProjectData, + onLoadedProject, + defaultProjectId, + requestNewProject, + requestProjectUpload, + setProjectId +} from 'scratch-gui/src/reducers/project-state'; +import { + openLoadingProject, + closeLoadingProject +} from 'scratch-gui/src/reducers/modals'; import ElectronStorageHelper from '../common/ElectronStorageHelper'; import styles from './app.css'; -const defaultProjectId = 0; - // override window.open so that it uses the OS's default browser, not an electron browser window.open = function (url, target) { if (target === '_blank') { @@ -38,9 +55,38 @@ const ScratchDesktopHOC = function (WrappedComponent) { 'handleTelemetryModalOptOut', 'handleUpdateProjectTitle' ]); - this.state = { - projectTitle: null - }; + this.props.onLoadingStarted(); + ipcRenderer.invoke('get-initial-project-data').then(initialProjectData => { + const hasInitialProject = initialProjectData && (initialProjectData.length > 0); + this.props.onHasInitialProject(hasInitialProject, this.props.loadingState); + if (!hasInitialProject) { + this.props.onLoadingCompleted(); + return; + } + this.props.vm.loadProject(initialProjectData).then( + () => { + this.props.onLoadingCompleted(); + this.props.onLoadedProject(this.props.loadingState, true); + }, + e => { + this.props.onLoadingCompleted(); + this.props.onLoadedProject(this.props.loadingState, false); + remote.dialog.showMessageBox(remote.getCurrentWindow(), { + type: 'error', + title: 'Failed to load project', + message: 'Invalid or corrupt project file.', + detail: e.message + }); + + // this effectively sets the default project ID + // TODO: maybe setting the default project ID should be implicit in `requestNewProject` + this.props.onHasInitialProject(false, this.props.loadingState); + + // restart as if we didn't have an initial project to load + this.props.onRequestNewProject(); + } + ); + }); } componentDidMount () { ipcRenderer.on('setTitleFromSave', this.handleSetTitleFromSave); @@ -71,11 +117,13 @@ const ScratchDesktopHOC = function (WrappedComponent) { } render () { const shouldShowTelemetryModal = (typeof ipcRenderer.sendSync('getTelemetryDidOptIn') !== 'boolean'); + + const childProps = omit(this.props, Object.keys(ScratchDesktopComponent.propTypes)); + return (); } } - return ScratchDesktopComponent; + ScratchDesktopComponent.propTypes = { + loadingState: PropTypes.oneOf(LoadingStates), + onFetchedInitialProjectData: PropTypes.func, + onHasInitialProject: PropTypes.func, + onLoadedProject: PropTypes.func, + onLoadingCompleted: PropTypes.func, + onLoadingStarted: PropTypes.func, + onRequestNewProject: PropTypes.func, + vm: PropTypes.instanceOf(VM).isRequired + }; + const mapStateToProps = state => { + const loadingState = state.scratchGui.projectState.loadingState; + return { + loadingState: loadingState, + vm: state.scratchGui.vm + }; + }; + const mapDispatchToProps = dispatch => ({ + onLoadingStarted: () => dispatch(openLoadingProject()), + onLoadingCompleted: () => dispatch(closeLoadingProject()), + onHasInitialProject: (hasInitialProject, loadingState) => { + if (hasInitialProject) { + // emulate sb-file-uploader + return dispatch(requestProjectUpload(loadingState)); + } + + // `createProject()` might seem more appropriate but it's not a valid state transition here + // setting the default project ID is a valid transition from NOT_LOADED and acts like "create new" + return dispatch(setProjectId(defaultProjectId)); + }, + onFetchedInitialProjectData: (projectData, loadingState) => + dispatch(onFetchedProjectData(projectData, loadingState)), + onLoadedProject: (loadingState, loadSuccess) => { + const canSaveToServer = false; + return dispatch(onLoadedProject(loadingState, canSaveToServer, loadSuccess)); + }, + onRequestNewProject: () => dispatch(requestNewProject(false)) + }); + + return connect(mapStateToProps, mapDispatchToProps)(ScratchDesktopComponent); }; // note that redux's 'compose' function is just being used as a general utility to make // the hierarchy of HOC constructor calls clearer here; it has nothing to do with redux's // ability to compose reducers. const WrappedGui = compose( - ScratchDesktopHOC, - AppStateHOC + AppStateHOC, + ScratchDesktopHOC )(GUI); ReactDOM.render(, appTarget);