From 25243b0542dd6f13c3ae87814f3b988a9206f840 Mon Sep 17 00:00:00 2001 From: Christopher Willis-Ford <7019101+cwillisf@users.noreply.github.com> Date: Thu, 3 Sep 2020 16:11:01 -0700 Subject: [PATCH 1/5] npm i --save-dev minimist --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index c2f3c92..8e0d831 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", From e2f39580dfe2c9c961f9d93511ef9b851623edf6 Mon Sep 17 00:00:00 2001 From: Christopher Willis-Ford <7019101+cwillisf@users.noreply.github.com> Date: Thu, 3 Sep 2020 17:01:22 -0700 Subject: [PATCH 2/5] make command line args available to render process --- src/main/argv.js | 23 +++++++++++++++++++++++ src/main/index.js | 3 +++ src/renderer/app.jsx | 10 ++++++++++ 3 files changed, 36 insertions(+) create mode 100644 src/main/argv.js 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 229d4a4..c806962 100644 --- a/src/main/index.js +++ b/src/main/index.js @@ -3,6 +3,7 @@ import fs from 'fs-extra'; import path from 'path'; import {URL} from 'url'; +import argv from './argv'; import {getFilterForExtension} from './FileFilters'; import telemetry from './ScratchDesktopTelemetry'; import MacOSMenu from './MacOSMenu'; @@ -373,3 +374,5 @@ app.on('ready', () => { ipcMain.on('open-about-window', () => { _windows.about.show(); }); + +ipcMain.handle('get-argv', () => argv); diff --git a/src/renderer/app.jsx b/src/renderer/app.jsx index 49ff284..e3c1169 100644 --- a/src/renderer/app.jsx +++ b/src/renderer/app.jsx @@ -6,6 +6,7 @@ import {compose} from 'redux'; import GUI, {AppStateHOC} from 'scratch-gui'; import ElectronStorageHelper from '../common/ElectronStorageHelper'; +import log from '../common/log'; import styles from './app.css'; @@ -99,4 +100,13 @@ const WrappedGui = compose( AppStateHOC )(GUI); +ipcRenderer.invoke('get-argv').then( + argv => { + log.log(`argv._ = ${argv._}`); + }, + err => { + log.warn('Failed to retrieve argv', err); + } +); + ReactDOM.render(, appTarget); From 05e8b26a34d1a749b174660c6b9afef3cd5c6de6 Mon Sep 17 00:00:00 2001 From: Christopher Willis-Ford <7019101+cwillisf@users.noreply.github.com> Date: Fri, 4 Sep 2020 13:26:34 -0700 Subject: [PATCH 3/5] WIP: actually load project from command line Doing it this way works for the initial load but overrides later actions like File -> New. --- src/main/index.js | 27 ++++++++++++++++++++++++++- src/renderer/app.jsx | 31 ++++++++++++++++++++----------- 2 files changed, 46 insertions(+), 12 deletions(-) diff --git a/src/main/index.js b/src/main/index.js index c806962..1545521 100644 --- a/src/main/index.js +++ b/src/main/index.js @@ -2,6 +2,7 @@ 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'; @@ -375,4 +376,28 @@ ipcMain.on('open-about-window', () => { _windows.about.show(); }); -ipcMain.handle('get-argv', () => argv); +// 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 e3c1169..9ad78c0 100644 --- a/src/renderer/app.jsx +++ b/src/renderer/app.jsx @@ -6,7 +6,6 @@ import {compose} from 'redux'; import GUI, {AppStateHOC} from 'scratch-gui'; import ElectronStorageHelper from '../common/ElectronStorageHelper'; -import log from '../common/log'; import styles from './app.css'; @@ -40,8 +39,16 @@ const ScratchDesktopHOC = function (WrappedComponent) { 'handleUpdateProjectTitle' ]); this.state = { - projectTitle: null + projectTitle: null, + projectLoading: true }; + + ipcRenderer.invoke('get-initial-project-data').then(projectData => { + this.setState({ + projectData, + projectLoading: false + }); + }); } componentDidMount () { ipcRenderer.on('setTitleFromSave', this.handleSetTitleFromSave); @@ -72,6 +79,11 @@ const ScratchDesktopHOC = function (WrappedComponent) { } render () { const shouldShowTelemetryModal = (typeof ipcRenderer.sendSync('getTelemetryDidOptIn') !== 'boolean'); + + if (this.state.projectLoading) { + return

Loading File...

; + } + return (); } @@ -100,13 +118,4 @@ const WrappedGui = compose( AppStateHOC )(GUI); -ipcRenderer.invoke('get-argv').then( - argv => { - log.log(`argv._ = ${argv._}`); - }, - err => { - log.warn('Failed to retrieve argv', err); - } -); - ReactDOM.render(, appTarget); From a9933242e0daa417fd9ccb2492b33f83f77d5954 Mon Sep 17 00:00:00 2001 From: Christopher Willis-Ford <7019101+cwillisf@users.noreply.github.com> Date: Tue, 15 Sep 2020 14:32:36 -0700 Subject: [PATCH 4/5] use project state system to load initial project --- src/renderer/app.jsx | 112 +++++++++++++++++++++++++++++++++---------- 1 file changed, 87 insertions(+), 25 deletions(-) diff --git a/src/renderer/app.jsx b/src/renderer/app.jsx index 9ad78c0..0e73fff 100644 --- a/src/renderer/app.jsx +++ b/src/renderer/app.jsx @@ -1,16 +1,33 @@ import {ipcRenderer, 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,16 +55,29 @@ const ScratchDesktopHOC = function (WrappedComponent) { 'handleTelemetryModalOptOut', 'handleUpdateProjectTitle' ]); - this.state = { - projectTitle: null, - projectLoading: true - }; + 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(); + console.error(e); // TODO: dialog box reporting the error? - ipcRenderer.invoke('get-initial-project-data').then(projectData => { - this.setState({ - projectData, - projectLoading: false - }); + // abandon this load and instead fetch+load the default project + // WARNING: this stuff doesn't currently work correctly! + this.props.onLoadedProject(this.props.loadingState, false); + this.props.onRequestNewProject(); + } + ); }); } componentDidMount () { @@ -80,15 +110,12 @@ const ScratchDesktopHOC = function (WrappedComponent) { render () { const shouldShowTelemetryModal = (typeof ipcRenderer.sendSync('getTelemetryDidOptIn') !== 'boolean'); - if (this.state.projectLoading) { - return

Loading File...

; - } + 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); From 0fa4c4b2ed88e6e3532ecc3914ecb2e1492af8ab Mon Sep 17 00:00:00 2001 From: Christopher Willis-Ford <7019101+cwillisf@users.noreply.github.com> Date: Tue, 15 Sep 2020 14:52:08 -0700 Subject: [PATCH 5/5] handle initial project load errors --- src/renderer/app.jsx | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/src/renderer/app.jsx b/src/renderer/app.jsx index 0e73fff..1fe1a37 100644 --- a/src/renderer/app.jsx +++ b/src/renderer/app.jsx @@ -1,4 +1,4 @@ -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'; @@ -70,11 +70,19 @@ const ScratchDesktopHOC = function (WrappedComponent) { }, e => { this.props.onLoadingCompleted(); - console.error(e); // TODO: dialog box reporting the error? - - // abandon this load and instead fetch+load the default project - // WARNING: this stuff doesn't currently work correctly! 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(); } );