Merge pull request #166 from cwillisf/fix-telemetry-modal

Fix telemetry modal
This commit is contained in:
Christopher Willis-Ford 2021-01-26 14:25:44 -08:00 committed by GitHub
commit c8c9ae51f4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 256 additions and 188 deletions

View file

@ -104,3 +104,27 @@ configuration like this:
] ]
}, },
``` ```
### Resetting the Telemetry System
This application includes a telemetry system which is only active if the user opts in. When testing this system, it's
sometimes helpful to reset it by deleting the `telemetry.json` file.
The location of this file depends on your operating system and whether or not you're running a packaged build. Running
from `npm start` or equivalent is a non-packaged build.
In addition, macOS may store the file in one of two places depending on the OS version and a few other variables. If
in doubt, I recommend removing both.
- Windows, packaged build: `%APPDATA%\Scratch\telemetry.json`
- Windows, non-packaged: `%APPDATA%\Electron\telemetry.json`
- macOS, packaged build: `~/Library/Application Support/Scratch/telemetry.json` or
`~/Library/Containers/edu.mit.scratch.scratch-desktop/Data/Library/Application Support/Scratch/telemetry.json`
- macOS, non-packaged build: `~/Library/Application Support/Electron/telemetry.json` or
`~/Library/Containers/edu.mit.scratch.scratch-desktop/Data/Library/Application Support/Electron/telemetry.json`
Deleting this file will:
- Remove any pending telemetry packets
- Reset the opt in/out state: the app should display the opt in/out modal on next launch
- Remove the random client UUID: the app will generate a new one on next launch

View file

@ -0,0 +1,53 @@
import {ipcRenderer} from 'electron';
import bindAll from 'lodash.bindall';
import React from 'react';
/**
* Higher-order component to add desktop logic to AppStateHOC.
* @param {Component} WrappedComponent - an AppStateHOC-like component to wrap.
* @returns {Component} - a component similar to AppStateHOC with desktop-specific logic added.
*/
const ScratchDesktopAppStateHOC = function (WrappedComponent) {
class ScratchDesktopAppStateComponent extends React.Component {
constructor (props) {
super(props);
bindAll(this, [
'handleTelemetryModalOptIn',
'handleTelemetryModalOptOut'
]);
this.state = {
// use `sendSync` because this should be set before first render
telemetryDidOptIn: ipcRenderer.sendSync('getTelemetryDidOptIn')
};
}
handleTelemetryModalOptIn () {
ipcRenderer.send('setTelemetryDidOptIn', true);
ipcRenderer.invoke('getTelemetryDidOptIn').then(telemetryDidOptIn => {
this.setState({telemetryDidOptIn});
});
}
handleTelemetryModalOptOut () {
ipcRenderer.send('setTelemetryDidOptIn', false);
ipcRenderer.invoke('getTelemetryDidOptIn').then(telemetryDidOptIn => {
this.setState({telemetryDidOptIn});
});
}
render () {
const shouldShowTelemetryModal = (typeof ipcRenderer.sendSync('getTelemetryDidOptIn') !== 'boolean');
return (<WrappedComponent
isTelemetryEnabled={this.state.telemetryDidOptIn}
onTelemetryModalOptIn={this.handleTelemetryModalOptIn}
onTelemetryModalOptOut={this.handleTelemetryModalOptOut}
showTelemetryModal={shouldShowTelemetryModal}
// allow passed-in props to override any of the above
{...this.props}
/>);
}
}
return ScratchDesktopAppStateComponent;
};
export default ScratchDesktopAppStateHOC;

View file

@ -0,0 +1,175 @@
import {ipcRenderer, remote} from 'electron';
import bindAll from 'lodash.bindall';
import omit from 'lodash.omit';
import PropTypes from 'prop-types';
import React from 'react';
import {connect} from 'react-redux';
import GUIComponent from 'scratch-gui/src/components/gui/gui.jsx';
import {
LoadingStates,
onFetchedProjectData,
onLoadedProject,
defaultProjectId,
requestNewProject,
requestProjectUpload,
setProjectId
} from 'scratch-gui/src/reducers/project-state';
import {
openLoadingProject,
closeLoadingProject,
openTelemetryModal
} from 'scratch-gui/src/reducers/modals';
import ElectronStorageHelper from '../common/ElectronStorageHelper';
import showPrivacyPolicy from './showPrivacyPolicy';
/**
* Higher-order component to add desktop logic to the GUI.
* @param {Component} WrappedComponent - a GUI-like component to wrap.
* @returns {Component} - a component similar to GUI with desktop-specific logic added.
*/
const ScratchDesktopGUIHOC = function (WrappedComponent) {
class ScratchDesktopGUIComponent extends React.Component {
constructor (props) {
super(props);
bindAll(this, [
'handleProjectTelemetryEvent',
'handleSetTitleFromSave',
'handleStorageInit',
'handleUpdateProjectTitle'
]);
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);
}
componentWillUnmount () {
ipcRenderer.removeListener('setTitleFromSave', this.handleSetTitleFromSave);
}
handleClickAbout () {
ipcRenderer.send('open-about-window');
}
handleProjectTelemetryEvent (event, metadata) {
ipcRenderer.send(event, metadata);
}
handleSetTitleFromSave (event, args) {
this.handleUpdateProjectTitle(args.title);
}
handleStorageInit (storageInstance) {
storageInstance.addHelper(new ElectronStorageHelper(storageInstance));
}
handleUpdateProjectTitle (newTitle) {
this.setState({projectTitle: newTitle});
}
render () {
const childProps = omit(this.props, Object.keys(ScratchDesktopGUIComponent.propTypes));
return (<WrappedComponent
canEditTitle
canModifyCloudData={false}
canSave={false}
isScratchDesktop
onClickAbout={[
{
title: 'About',
onClick: () => this.handleClickAbout()
},
{
title: 'Privacy Policy',
onClick: () => showPrivacyPolicy()
},
{
title: 'Data Settings',
onClick: () => this.props.onTelemetrySettingsClicked()
}
]}
onProjectTelemetryEvent={this.handleProjectTelemetryEvent}
onShowPrivacyPolicy={showPrivacyPolicy}
onStorageInit={this.handleStorageInit}
onUpdateProjectTitle={this.handleUpdateProjectTitle}
// allow passed-in props to override any of the above
{...childProps}
/>);
}
}
ScratchDesktopGUIComponent.propTypes = {
loadingState: PropTypes.oneOf(LoadingStates),
onFetchedInitialProjectData: PropTypes.func,
onHasInitialProject: PropTypes.func,
onLoadedProject: PropTypes.func,
onLoadingCompleted: PropTypes.func,
onLoadingStarted: PropTypes.func,
onRequestNewProject: PropTypes.func,
onTelemetrySettingsClicked: PropTypes.func,
// using PropTypes.instanceOf(VM) here will cause prop type warnings due to VM mismatch
vm: GUIComponent.WrappedComponent.propTypes.vm
};
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)),
onTelemetrySettingsClicked: () => dispatch(openTelemetryModal())
});
return connect(mapStateToProps, mapDispatchToProps)(ScratchDesktopGUIComponent);
};
export default ScratchDesktopGUIHOC;

View file

@ -1,32 +1,11 @@
import {ipcRenderer, remote} from 'electron';
import bindAll from 'lodash.bindall';
import omit from 'lodash.omit';
import PropTypes from 'prop-types';
import React from 'react'; import React from 'react';
import {connect} from 'react-redux';
import {compose} from 'redux'; import {compose} from 'redux';
import GUI from 'scratch-gui/src/index'; import GUI from 'scratch-gui/src/index';
import VM from 'scratch-vm';
import AppStateHOC from 'scratch-gui/src/lib/app-state-hoc.jsx'; 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,
openTelemetryModal
} from 'scratch-gui/src/reducers/modals';
import ElectronStorageHelper from '../common/ElectronStorageHelper'; import ScratchDesktopAppStateHOC from './ScratchDesktopAppStateHOC.jsx';
import ScratchDesktopGUIHOC from './ScratchDesktopGUIHOC.jsx';
import showPrivacyPolicy from './showPrivacyPolicy';
import styles from './app.css'; import styles from './app.css';
const appTarget = document.getElementById('app'); const appTarget = document.getElementById('app');
@ -34,177 +13,14 @@ appTarget.className = styles.app || 'app';
GUI.setAppElement(appTarget); GUI.setAppElement(appTarget);
const ScratchDesktopHOC = function (WrappedComponent) {
class ScratchDesktopComponent extends React.Component {
constructor (props) {
super(props);
bindAll(this, [
'handleProjectTelemetryEvent',
'handleSetTitleFromSave',
'handleStorageInit',
'handleTelemetryModalOptIn',
'handleTelemetryModalOptOut',
'handleUpdateProjectTitle'
]);
this.state = {
// use `sendSync` because this should be set before first render
telemetryDidOptIn: ipcRenderer.sendSync('getTelemetryDidOptIn')
};
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);
}
componentWillUnmount () {
ipcRenderer.removeListener('setTitleFromSave', this.handleSetTitleFromSave);
}
handleClickAbout () {
ipcRenderer.send('open-about-window');
}
handleProjectTelemetryEvent (event, metadata) {
ipcRenderer.send(event, metadata);
}
handleSetTitleFromSave (event, args) {
this.handleUpdateProjectTitle(args.title);
}
handleStorageInit (storageInstance) {
storageInstance.addHelper(new ElectronStorageHelper(storageInstance));
}
handleTelemetryModalOptIn () {
ipcRenderer.send('setTelemetryDidOptIn', true);
ipcRenderer.invoke('getTelemetryDidOptIn').then(telemetryDidOptIn => {
this.setState({telemetryDidOptIn});
});
}
handleTelemetryModalOptOut () {
ipcRenderer.send('setTelemetryDidOptIn', false);
ipcRenderer.invoke('getTelemetryDidOptIn').then(telemetryDidOptIn => {
this.setState({telemetryDidOptIn});
});
}
handleUpdateProjectTitle (newTitle) {
this.setState({projectTitle: newTitle});
}
render () {
const shouldShowTelemetryModal = (typeof this.state.telemetryDidOptIn !== 'boolean');
const childProps = omit(this.props, Object.keys(ScratchDesktopComponent.propTypes));
return (<WrappedComponent
canEditTitle
canModifyCloudData={false}
canSave={false}
isScratchDesktop
isTelemetryEnabled={this.state.telemetryDidOptIn}
showTelemetryModal={shouldShowTelemetryModal}
onClickAbout={[
{
title: 'About',
onClick: () => this.handleClickAbout()
},
{
title: 'Privacy Policy',
onClick: () => showPrivacyPolicy()
},
{
title: 'Data Settings',
onClick: () => this.props.onTelemetrySettingsClicked()
}
]}
onProjectTelemetryEvent={this.handleProjectTelemetryEvent}
onShowPrivacyPolicy={showPrivacyPolicy}
onStorageInit={this.handleStorageInit}
onTelemetryModalOptIn={this.handleTelemetryModalOptIn}
onTelemetryModalOptOut={this.handleTelemetryModalOptOut}
onUpdateProjectTitle={this.handleUpdateProjectTitle}
// allow passed-in props to override any of the above
{...childProps}
/>);
}
}
ScratchDesktopComponent.propTypes = {
loadingState: PropTypes.oneOf(LoadingStates),
onFetchedInitialProjectData: PropTypes.func,
onHasInitialProject: PropTypes.func,
onLoadedProject: PropTypes.func,
onLoadingCompleted: PropTypes.func,
onLoadingStarted: PropTypes.func,
onRequestNewProject: PropTypes.func,
onTelemetrySettingsClicked: 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)),
onTelemetrySettingsClicked: () => dispatch(openTelemetryModal())
});
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(
ScratchDesktopAppStateHOC,
AppStateHOC, AppStateHOC,
ScratchDesktopHOC ScratchDesktopGUIHOC
)(GUI); )(GUI);
export default <WrappedGui />; export default <WrappedGui />;