mirror of
https://github.com/scratchfoundation/scratch-desktop.git
synced 2024-12-22 21:52:31 -05:00
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:
commit
b05c2c1602
4 changed files with 154 additions and 13 deletions
|
@ -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
23
src/main/argv.js
Normal 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;
|
|
@ -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);
|
||||||
|
|
|
@ -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);
|
||||||
|
|
Loading…
Reference in a new issue