Merge pull request #150 from cwillisf/load-project-from-cli-attempt-2

Support loading project file from command line (attempt 2)
This commit is contained in:
Chris Willis-Ford 2020-09-29 13:56:30 -07:00 committed by GitHub
commit b05c2c1602
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 154 additions and 13 deletions

View file

@ -54,6 +54,7 @@
"lodash.bindall": "^4.4.0", "lodash.bindall": "^4.4.0",
"lodash.defaultsdeep": "^4.6.1", "lodash.defaultsdeep": "^4.6.1",
"minilog": "^3.1.0", "minilog": "^3.1.0",
"minimist": "^1.2.5",
"mkdirp": "^1.0.4", "mkdirp": "^1.0.4",
"nets": "^3.2.0", "nets": "^3.2.0",
"react": "16.2.0", "react": "16.2.0",

23
src/main/argv.js Normal file
View file

@ -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;

View file

@ -2,7 +2,9 @@ import {BrowserWindow, Menu, app, dialog, ipcMain, systemPreferences} from 'elec
import fs from 'fs-extra'; import fs from 'fs-extra';
import path from 'path'; import path from 'path';
import {URL} from 'url'; import {URL} from 'url';
import {promisify} from 'util';
import argv from './argv';
import {getFilterForExtension} from './FileFilters'; import {getFilterForExtension} from './FileFilters';
import telemetry from './ScratchDesktopTelemetry'; import telemetry from './ScratchDesktopTelemetry';
import MacOSMenu from './MacOSMenu'; import MacOSMenu from './MacOSMenu';
@ -374,3 +376,29 @@ app.on('ready', () => {
ipcMain.on('open-about-window', () => { ipcMain.on('open-about-window', () => {
_windows.about.show(); _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);

View file

@ -1,16 +1,33 @@
import {ipcRenderer, shell} from 'electron'; import {ipcRenderer, remote, shell} from 'electron';
import bindAll from 'lodash.bindall'; import bindAll from 'lodash.bindall';
import omit from 'lodash.omit';
import PropTypes from 'prop-types';
import React from 'react'; import React from 'react';
import ReactDOM from 'react-dom'; import ReactDOM from 'react-dom';
import {connect} from 'react-redux';
import {compose} from '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 ElectronStorageHelper from '../common/ElectronStorageHelper';
import styles from './app.css'; import styles from './app.css';
const defaultProjectId = 0;
// override window.open so that it uses the OS's default browser, not an electron browser // override window.open so that it uses the OS's default browser, not an electron browser
window.open = function (url, target) { window.open = function (url, target) {
if (target === '_blank') { if (target === '_blank') {
@ -38,9 +55,38 @@ const ScratchDesktopHOC = function (WrappedComponent) {
'handleTelemetryModalOptOut', 'handleTelemetryModalOptOut',
'handleUpdateProjectTitle' 'handleUpdateProjectTitle'
]); ]);
this.state = { this.props.onLoadingStarted();
projectTitle: null 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 () { componentDidMount () {
ipcRenderer.on('setTitleFromSave', this.handleSetTitleFromSave); ipcRenderer.on('setTitleFromSave', this.handleSetTitleFromSave);
@ -71,11 +117,13 @@ const ScratchDesktopHOC = function (WrappedComponent) {
} }
render () { render () {
const shouldShowTelemetryModal = (typeof ipcRenderer.sendSync('getTelemetryDidOptIn') !== 'boolean'); const shouldShowTelemetryModal = (typeof ipcRenderer.sendSync('getTelemetryDidOptIn') !== 'boolean');
const childProps = omit(this.props, Object.keys(ScratchDesktopComponent.propTypes));
return (<WrappedComponent return (<WrappedComponent
canEditTitle canEditTitle
canModifyCloudData={false}
isScratchDesktop isScratchDesktop
projectId={defaultProjectId}
projectTitle={this.state.projectTitle}
showTelemetryModal={shouldShowTelemetryModal} showTelemetryModal={shouldShowTelemetryModal}
onClickLogo={this.handleClickLogo} onClickLogo={this.handleClickLogo}
onProjectTelemetryEvent={this.handleProjectTelemetryEvent} onProjectTelemetryEvent={this.handleProjectTelemetryEvent}
@ -83,20 +131,61 @@ const ScratchDesktopHOC = function (WrappedComponent) {
onTelemetryModalOptIn={this.handleTelemetryModalOptIn} onTelemetryModalOptIn={this.handleTelemetryModalOptIn}
onTelemetryModalOptOut={this.handleTelemetryModalOptOut} onTelemetryModalOptOut={this.handleTelemetryModalOptOut}
onUpdateProjectTitle={this.handleUpdateProjectTitle} onUpdateProjectTitle={this.handleUpdateProjectTitle}
{...this.props}
// allow passed-in props to override any of the above
{...childProps}
/>); />);
} }
} }
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 // 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 // the hierarchy of HOC constructor calls clearer here; it has nothing to do with redux's
// ability to compose reducers. // ability to compose reducers.
const WrappedGui = compose( const WrappedGui = compose(
ScratchDesktopHOC, AppStateHOC,
AppStateHOC ScratchDesktopHOC
)(GUI); )(GUI);
ReactDOM.render(<WrappedGui />, appTarget); ReactDOM.render(<WrappedGui />, appTarget);