diff --git a/.compilerc b/.compilerc index 484258f..b019e33 100644 --- a/.compilerc +++ b/.compilerc @@ -2,7 +2,8 @@ "env": { "development": { "application/javascript": { - "presets": ["node7"], + "presets": ["node7", "react"], + "plugins": ["transform-class-properties"], "sourceMaps": "inline" }, "text/less": { @@ -11,7 +12,8 @@ }, "production": { "application/javascript": { - "presets": ["node7"], + "presets": ["node7", "react"], + "plugins": ["transform-class-properties"], "sourceMaps": "none" } } diff --git a/package.json b/package.json index e0b8cdc..ac13438 100644 --- a/package.json +++ b/package.json @@ -84,6 +84,9 @@ "node7" ] }, + "standard": { + "parser": "babel-eslint" + }, "dependencies": { "electron-compile": "^6.4.1", "electron-log": "^2.2.7", @@ -91,22 +94,31 @@ "electron-window-state": "^4.1.1", "flexboxgrid": "^6.3.1", "font-awesome": "^4.7.0", - "once": "^1.4.0", "osenv": "^0.1.4", "prismjs": "^1.6.0", + "prop-types": "^15.5.10", + "react": "^15.6.1", + "react-dom": "^15.6.1", + "react-fontawesome": "^1.6.1", + "react-prism": "^4.3.0", "request": "^2.81.0", "semver": "^5.3.0", "yo-yo": "^1.4.1" }, "devDependencies": { "ava": "^0.21.0", + "babel-eslint": "^7.2.3", + "babel-plugin-transform-class-properties": "^6.24.1", "babel-preset-node7": "^1.5.0", + "babel-preset-react": "^6.24.1", "babel-register": "^6.24.1", "devtron": "^1.4.0", + "electron-devtools-installer": "^2.2.0", "electron-forge": "^3.0.5", "electron-prebuilt-compile": "1.7.4", "electron-process-manager": "0.0.4", "mockery": "^2.1.0", + "react-hot-loader": "^3.0.0-beta.6", "sinon": "^2.3.8", "spectron": "^3.7.2", "standard": "^10.0.2" diff --git a/spec/__mocks__/electron-compile.js b/spec/__mocks__/electron-compile.js new file mode 100644 index 0000000..5fac68f --- /dev/null +++ b/spec/__mocks__/electron-compile.js @@ -0,0 +1,5 @@ +import sinon from 'sinon' + +export const electronCompileMock = { + enableLiveReload: sinon.spy() +} diff --git a/spec/__mocks__/electron-devtools-installer.js b/spec/__mocks__/electron-devtools-installer.js new file mode 100644 index 0000000..b12e374 --- /dev/null +++ b/spec/__mocks__/electron-devtools-installer.js @@ -0,0 +1,10 @@ +import sinon from 'sinon' + +const installStub = sinon.stub() +installStub.returns(Promise.resolve()) + +export const electronDevtoolsInstallerMock = { + __esModule: true, + default: installStub, + REACT_DEVELOPER_TOOLS: 'react' +} diff --git a/spec/__mocks__/electron.js b/spec/__mocks__/electron.js index 6987e48..94e1015 100644 --- a/spec/__mocks__/electron.js +++ b/spec/__mocks__/electron.js @@ -52,12 +52,19 @@ class BrowserWindow extends EventEmitter { } } +class CommandLine { + constructor () { + this.appendSwitch = sinon.stub() + } +} + class App extends EventEmitter { constructor () { super() this.getName = sinon.stub() - this.getPah = sinon.stub() + this.getPath = sinon.stub() + this.commandLine = new CommandLine() } } @@ -90,7 +97,8 @@ export const electronMock = { getCurrentWindow: sinon.stub(), require: sinon.stub(), Menu: MockMenu, - MenuItem: MockMenuItem + MenuItem: MockMenuItem, + app: new App() }, ipcRenderer: { send: sinon.stub() @@ -104,3 +112,5 @@ export const electronMock = { screen: new Screen(), BrowserWindow } + +export const electronMainMock = Object.assign({}, electronMock, { remote: null }) diff --git a/spec/main/developer-spec.js b/spec/main/developer-spec.js index 4bc3177..593417b 100644 --- a/spec/main/developer-spec.js +++ b/spec/main/developer-spec.js @@ -2,12 +2,12 @@ import test from 'ava' import mockery from 'mockery' import sinon from 'sinon' -import { electronMock } from '../../spec/__mocks__/electron' +import { electronMainMock } from '../../spec/__mocks__/electron' require('../setup').setup(test) test('it installs devtron if not installed', (t) => { - mockery.registerMock('electron', electronMock) + mockery.registerMock('electron', electronMainMock) mockery.registerMock('devtron', { install: sinon.spy() }) const { BrowserWindow } = require('electron') @@ -23,7 +23,7 @@ test('it installs devtron if not installed', (t) => { }) test('does not it installs devtron if already installed', (t) => { - mockery.registerMock('electron', electronMock) + mockery.registerMock('electron', electronMainMock) mockery.registerMock('devtron', { install: sinon.spy() }) const { BrowserWindow } = require('electron') @@ -32,8 +32,39 @@ test('does not it installs devtron if already installed', (t) => { const { DeveloperFeatures } = require('../../src/main/developer') //eslint-disable-next-line - const developerFeatures = new DeveloperFeatures() + new DeveloperFeatures() const devtron = require('devtron') t.is(devtron.install.callCount, 0) }) + +test('it enables react HMR', (t) => { + mockery.registerMock('electron', electronMainMock) + mockery.registerMock('devtron', { install: sinon.spy() }) + mockery.registerMock('electron-compile', { enableLiveReload: sinon.spy() }) + + const { DeveloperFeatures } = require('../../src/main/developer') + //eslint-disable-next-line + new DeveloperFeatures() + + const electronCompile = require('electron-compile') + + t.is(electronCompile.enableLiveReload.callCount, 1) + t.is(electronCompile.enableLiveReload.firstCall.args[0].strategy, 'react-hmr') +}) + +test('it attempts to install react dev tools', (t) => { + mockery.registerMock('electron', electronMainMock) + mockery.registerMock('devtron', { install: sinon.spy() }) + mockery.registerMock('electron-devtools-installer', { __esModule: true, default: sinon.stub(), REACT_DEVELOPER_TOOLS: 'react-tools' }) + + const electronDevtoolsInstaller = require('electron-devtools-installer') + electronDevtoolsInstaller.default.returns(Promise.resolve()) + + const { DeveloperFeatures } = require('../../src/main/developer') + //eslint-disable-next-line + new DeveloperFeatures() + + t.is(electronDevtoolsInstaller.default.callCount, 1) + t.is(electronDevtoolsInstaller.default.firstCall.args[0], 'react-tools') +}) diff --git a/spec/setup.js b/spec/setup.js index 56b2fa6..9ebc6d3 100644 --- a/spec/setup.js +++ b/spec/setup.js @@ -1,12 +1,16 @@ import mockery from 'mockery' import { logMock } from './__mocks__/electron-log' +import { electronDevtoolsInstallerMock } from './__mocks__/electron-devtools-installer' +import { electronCompileMock } from './__mocks__/electron-compile' import { electronMock } from './__mocks__/electron' import { windowStateMock } from './__mocks__/electron-window-state' export function setup (test) { test.before((t) => { - mockery.enable() + mockery.enable({ + useCleanCache: true + }) mockery.warnOnUnregistered(false) }) @@ -16,6 +20,8 @@ export function setup (test) { mockery.registerMock('electron-window-state', windowStateMock) mockery.registerMock('electron', electronMock) mockery.registerMock('electron-log', logMock) + mockery.registerMock('electron-devtools-installer', electronDevtoolsInstallerMock) + mockery.registerMock('electron-compile', electronCompileMock) }) test.after((t) => { diff --git a/src/main/developer.js b/src/main/developer.js index 5114e9b..473fa45 100644 --- a/src/main/developer.js +++ b/src/main/developer.js @@ -1,12 +1,22 @@ import { BrowserWindow } from 'electron' import devtron from 'devtron' +import * as electronCompile from 'electron-compile' +import installDevTools, { REACT_DEVELOPER_TOOLS } from 'electron-devtools-installer' import { logger } from '../logger' export class DeveloperFeatures { constructor () { this.extensions = BrowserWindow.getDevToolsExtensions() + this.enableHMR() this.installDevtron() + this.installReactTools() + } + + enableHMR () { + electronCompile.enableLiveReload({ + strategy: 'react-hmr' + }) } installDevtron () { @@ -17,4 +27,9 @@ export class DeveloperFeatures { devtron.install() } } + + installReactTools () { + installDevTools(REACT_DEVELOPER_TOOLS) + .catch((err) => logger.error('Failed to install React dev tools', err)) + } } diff --git a/src/main/main.js b/src/main/main.js index 445d86a..b17cc1f 100644 --- a/src/main/main.js +++ b/src/main/main.js @@ -36,6 +36,12 @@ export class App { this.onReady() } }) + + // Fancy scrollbars + app.commandLine.appendSwitch('enable-smooth-scrolling', '1') + app.commandLine.appendSwitch('enable-overlay-scrollbar', '1') + // Fix HDPI zoom bug + app.commandLine.appendSwitch('enable-use-zoom-for-dsf', 'false') } onReady () { diff --git a/src/main/window-manager.js b/src/main/window-manager.js index 6703409..3390f3b 100644 --- a/src/main/window-manager.js +++ b/src/main/window-manager.js @@ -19,7 +19,8 @@ class WindowManager { x: mainWindowState.x, y: mainWindowState.y, width: mainWindowState.width, - height: mainWindowState.height + height: mainWindowState.height, + minHeight: 450 }) mainWindowState.manage(browserWindow) diff --git a/src/renderer/components/App.jsx b/src/renderer/components/App.jsx new file mode 100644 index 0000000..f8d28f4 --- /dev/null +++ b/src/renderer/components/App.jsx @@ -0,0 +1,98 @@ +import React from 'react' + +import { InstallingOverlay } from './InstallingOverlay' +import { LeftPanel } from './LeftPanel' +import { RightPanel } from './RightPanel' + +import { Installer } from '../lib/Installer' +import getInstalledVersion from '../lib/check-node' +import getVersions from '../lib/versions' + +const INSTALL_STATUS = { + NOT_INSTALLING: 0, + INSTALLING: 1, + SUCCESS: 2, + ERROR: 3 +} + +export class App extends React.Component { + state = { + installStatus: INSTALL_STATUS.NOT_INSTALLING, + installError: null, + versions: null, + currentVersion: null + } + + componentDidMount () { + this.loadVersionList() + getInstalledVersion((err, version) => { + if (!err) { + this.setState({ + currentVersion: version + }) + } + }) + } + + cancelInstall = () => { + if (this._currentInstalller) { + this._currentInstalller.cancel() + } + this.setState({ + installStatus: INSTALL_STATUS.NOT_INSTALLING, + installError: null + }) + } + + installVersion = (version) => { + this.setState({ + installStatus: INSTALL_STATUS.INSTALLING + }) + const installer = new Installer(version) + this._currentInstalller = installer + installer.on('error', (err) => { + console.error(err) + this.setState({ + installStatus: INSTALL_STATUS.ERROR, + installError: err + }) + }) + installer.on('done', () => { + this.setState({ + currentVersion: version, + installStatus: INSTALL_STATUS.SUCCESS + }) + delete this._currentInstalller + }) + installer.install() + } + + loadVersionList () { + window.fetch('https://nodejs.org/dist/index.json') + .then(r => r.json()) + .then(getVersions) + .then((versions) => { + this.setState({ + versions + }) + }) + } + + render () { + return ( +
+ + + +
+ ) + } +} diff --git a/src/renderer/components/InstalledNodeVersion.jsx b/src/renderer/components/InstalledNodeVersion.jsx new file mode 100644 index 0000000..244b06d --- /dev/null +++ b/src/renderer/components/InstalledNodeVersion.jsx @@ -0,0 +1,45 @@ +import React from 'react' + +import { ErrorIcon, SpinnerIcon } from './icons' + +import getInstalledVersion from '../lib/check-node' + +export class InstalledNodeVersion extends React.Component { + state = { + loading: true, + error: false, + version: null + } + + componentDidMount () { + getInstalledVersion((err, version) => { + if (err) { + this.setState({ + error: true, + loading: false + }) + } else { + this.setState({ + loading: false, + version + }) + } + }) + } + + render () { + return ( + + { + this.state.loading + ? + : ( + this.state.error + ? + : this.state.version + ) + } + + ) + } +} diff --git a/src/renderer/components/InstallingOverlay.jsx b/src/renderer/components/InstallingOverlay.jsx new file mode 100644 index 0000000..ef601ae --- /dev/null +++ b/src/renderer/components/InstallingOverlay.jsx @@ -0,0 +1,51 @@ +import React from 'react' +import { any, bool, func } from 'prop-types' + +export class InstallingOverlay extends React.Component { + static propTypes = { + installing: bool.isRequired, + error: any, + onCancel: func.isRequired + } + + render () { + if (!this.props.installing && !this.props.error) { + return null + } + return ( +
+
+

Installing Node.js

+
+ { + this.props.installing + ? ( +
+
+
+
+
+
+
+
+ ) + : null + } +
+ { + this.props.error + ?

{this.props.error.message}

+ : null + } + + { + this.props.installing + ? 'Cancel Install' + : 'Return to installer' + } + +
+
+ ) + } +} diff --git a/src/renderer/components/LeftPanel.jsx b/src/renderer/components/LeftPanel.jsx new file mode 100644 index 0000000..8493cff --- /dev/null +++ b/src/renderer/components/LeftPanel.jsx @@ -0,0 +1,86 @@ +import { shell } from 'electron' +import { any, func, string } from 'prop-types' +import React from 'react' +import semver from 'semver' + +import { InstalledNodeVersion } from './InstalledNodeVersion' +import { NodeLogo } from './images/NodeLogo' +import { NodeWhiteIcon, SpinnerIcon } from './icons' + +export class LeftPanel extends React.Component { + static propTypes = { + currentVersion: string, + versions: any, + installVersion: func.isRequired + } + + hasUpdate () { + if (!this.props.currentVersion) return false + if (!this.props.versions) return false + return semver.gt(this.props.versions.latest.version, this.props.currentVersion) + } + + installLatest = () => { + if (!this.props.versions) return + this.props.installVersion(this.props.versions.latest.version) + } + + installLatestLTS = () => { + if (!this.props.versions) return + this.props.installVersion(this.props.versions.latestLTS.version) + } + + launchNodeWebsite (event) { + shell.openExternal('https://nodejs.org/en/') + event.preventDefault() + } + + render () { + const loadingVersionList = !this.props.versions + return ( +
+
+
+ +
+
+
+
+

+ Installed version:

+
+ { + this.hasUpdate() + ? ( + + ) + : null + } +
+ + ) + } +} diff --git a/src/renderer/components/RandomExample.jsx b/src/renderer/components/RandomExample.jsx new file mode 100644 index 0000000..382be20 --- /dev/null +++ b/src/renderer/components/RandomExample.jsx @@ -0,0 +1,39 @@ +import React from 'react' +import { PrismCode } from 'react-prism' + +import getExample from '../lib/examples' + +export class RandomExample extends React.Component { + state = { + example: null + } + + componentDidMount () { + getExample((err, example) => { + // Not possible to get err currently + if (err) return + + this.setState({ + example + }) + }) + } + + render () { + if (!this.state.example) { + return null + } + return ( +
+
+

{this.state.example.title}

+
+            
+              {this.state.example.code}
+            
+          
+
+
+ ) + } +} diff --git a/src/renderer/components/RightPanel.jsx b/src/renderer/components/RightPanel.jsx new file mode 100644 index 0000000..b266f33 --- /dev/null +++ b/src/renderer/components/RightPanel.jsx @@ -0,0 +1,35 @@ +import { shell } from 'electron' +import React from 'react' + +import { RandomExample } from './RandomExample' +import { NodeSchoolLogo } from './images/NodeSchoolLogo' + +export class RightPanel extends React.Component { + launchNodeSchoolWebsite (event) { + shell.openExternal('https://nodeschool.io/') + event.preventDefault() + } + + render () { + return ( +
+
+
+ +
+
+
+
+

Educational resources:

+

+ Open source workshops that teach web software skills.
+ Do them on your own or at a workshop nearby.
+ https://nodeschool.io/ +

+
+
+ +
+ ) + } +} diff --git a/src/renderer/components/icons/Error.jsx b/src/renderer/components/icons/Error.jsx new file mode 100644 index 0000000..810db95 --- /dev/null +++ b/src/renderer/components/icons/Error.jsx @@ -0,0 +1,8 @@ +import React from 'react' +import FontAwesome from 'react-fontawesome' + +export class Error extends React.Component { + render () { + return + } +} diff --git a/src/renderer/components/icons/NodeWhite.jsx b/src/renderer/components/icons/NodeWhite.jsx new file mode 100644 index 0000000..521842e --- /dev/null +++ b/src/renderer/components/icons/NodeWhite.jsx @@ -0,0 +1,10 @@ +import React from 'react' +import path from 'path' + +const logoPath = path.resolve(__dirname, '../../../../static/images/lgoonodejswhite.png') + +export class NodeWhite extends React.Component { + render () { + return + } +} diff --git a/src/renderer/components/icons/Spinner.jsx b/src/renderer/components/icons/Spinner.jsx new file mode 100644 index 0000000..d5c42aa --- /dev/null +++ b/src/renderer/components/icons/Spinner.jsx @@ -0,0 +1,8 @@ +import React from 'react' +import FontAwesome from 'react-fontawesome' + +export class Spinner extends React.Component { + render () { + return + } +} diff --git a/src/renderer/components/icons/index.js b/src/renderer/components/icons/index.js new file mode 100644 index 0000000..4f1bb4b --- /dev/null +++ b/src/renderer/components/icons/index.js @@ -0,0 +1,3 @@ +export { Error as ErrorIcon } from './Error' +export { NodeWhite as NodeWhiteIcon } from './NodeWhite' +export { Spinner as SpinnerIcon } from './Spinner' diff --git a/src/renderer/components/images/NodeLogo.jsx b/src/renderer/components/images/NodeLogo.jsx new file mode 100644 index 0000000..69b3a06 --- /dev/null +++ b/src/renderer/components/images/NodeLogo.jsx @@ -0,0 +1,15 @@ +import React from 'react' +import { func } from 'prop-types' +import path from 'path' + +const logoPath = path.resolve(__dirname, '../../../../static/images/nodejs-new-white-bw.png') + +export class NodeLogo extends React.Component { + static propTypes = { + onClick: func + } + + render () { + return node logo + } +} diff --git a/src/renderer/components/images/NodeSchoolLogo.jsx b/src/renderer/components/images/NodeSchoolLogo.jsx new file mode 100644 index 0000000..d2e1f0e --- /dev/null +++ b/src/renderer/components/images/NodeSchoolLogo.jsx @@ -0,0 +1,15 @@ +import React from 'react' +import { func } from 'prop-types' +import path from 'path' + +const logoPath = path.resolve(__dirname, '../../../../static/images/schoolhouse.svg') + +export class NodeSchoolLogo extends React.Component { + static propTypes = { + onClick: func + } + + render () { + return node school logo + } +} diff --git a/src/renderer/index.html b/src/renderer/index.html index 5ca855b..07a6e46 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -6,77 +6,23 @@ - + -
-
-
-
- -
-
-
-
-

Installed version:

-
- -
-
-
-

Latest versions:

-
- - -
-
-
-
-
- -
-
-
-
-

Educational resources:

-

Open source workshops that teach web software skills.
Do them on your own or at a workshop nearby.
http://nodeschool.io/

-
-
-
-
-

A simple http file server:

-
-              
-              
-            
-
-
-
-
-
-
-

Installing Node.js

-
-
-
-
-
-
-
-
-
- -
- - +
+ diff --git a/src/renderer/less/core.less b/src/renderer/less/core.less new file mode 100644 index 0000000..00b4400 --- /dev/null +++ b/src/renderer/less/core.less @@ -0,0 +1,44 @@ +* { + -webkit-user-select: none; + user-select: none; +} + +html, body, #app, #main, #installing { + height: 100%; + width: 100%; + margin: 0; + padding: 0; + font-family: 'Open Sans', 'Helvetica', sans-serif; + font-weight: lighter; + background-color: #f7f7f7; + overflow: hidden; +} + +#main { + display: flex; +} + +a { + text-decoration: none; + color: #277cd0; +} + +.color-green { + color: #56af57; +} + +.background-green { + background-color: #56af57; +} + +.background-red { + background-color: #D32F2F; +} + +.color-red { + color: #D32F2F; +} + +@import "./panels.less"; +@import "./installing-overlay.less"; +@import "./spinner.less"; \ No newline at end of file diff --git a/src/renderer/less/installing-overlay.less b/src/renderer/less/installing-overlay.less new file mode 100644 index 0000000..de847ef --- /dev/null +++ b/src/renderer/less/installing-overlay.less @@ -0,0 +1,40 @@ +.installing { + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + z-index: 2; + + div { + text-align: center; + + h1 { + font-weight: lighter; + font-size: 3em; + margin: 100px 0; + } + } + + .error-message { + padding: 20px; + border-radius: 3px; + + .error-text { + user-select: all; + font-size: 1.2em; + margin: 0; + padding: 0; + } + + .error-button { + display: block; + border: 1px #D32F2F solid; + border-radius: 3px; + padding: 10px 15px; + width: 200px; + margin: 0 auto; + margin-top: 30px; + } + } +} \ No newline at end of file diff --git a/src/renderer/less/left-panel.less b/src/renderer/less/left-panel.less new file mode 100644 index 0000000..eaf8b29 --- /dev/null +++ b/src/renderer/less/left-panel.less @@ -0,0 +1,70 @@ +.left-panel { + display: flex; + flex-direction: column; + width: 320px; + + > .row { + margin-bottom: 30px; + } + + p { + color: #fff; + font-size: 1.2em; + margin: 0; + } + + .installed-version { + font-size: 1.3em; + } + + .node-image-container { + text-align: center; + + img { + width: 80%; + height: auto; + cursor: pointer; + } + } + + .installed-version, + .update-to, + .version-button { + span { + font-weight: normal; + } + } + + .update-to { + display: none; + } + + .spacer { + flex: 1; + } + + .version-button { + display: inline-block; + border: 1px #fff solid; + border-radius: 3px; + padding: 5px 10px; + width: 250px; + font-size: 1.3em; + margin-top: 10px; + + img { + width: 12px; + height: auto; + margin-right: 10px; + } + } + + .version-button, + .version-button:link, + .version-button:visited, + .version-button:hover, + .version-button:active { + color: #fff; + text-decoration: none; + } +} \ No newline at end of file diff --git a/src/renderer/less/panels.less b/src/renderer/less/panels.less new file mode 100644 index 0000000..99fed88 --- /dev/null +++ b/src/renderer/less/panels.less @@ -0,0 +1,9 @@ +.left-panel, .right-panel { + height: 100%; + margin: 0; + padding: 30px 30px; + box-sizing: border-box; +} + +@import "./left-panel.less"; +@import "./right-panel.less"; \ No newline at end of file diff --git a/src/renderer/less/right-panel.less b/src/renderer/less/right-panel.less new file mode 100644 index 0000000..1cba304 --- /dev/null +++ b/src/renderer/less/right-panel.less @@ -0,0 +1,46 @@ +.right-panel { + display: flex; + flex-direction: column; + flex: 1; + + > .row { + &:last-child { + flex: 1; + > div { + height: 100%; + display: flex; + flex-direction: column; + } + } + } + + .school-image-container { + text-align: center; + } + + .school-image-container img { + width: 280px; + height: auto + } + + .title { + font-weight: normal; + } + + .text { + line-height: 30px; + } + + pre, code, code span { + -webkit-user-select: all; + user-select: all; + } + + pre { + overflow: auto; + padding: 0em 1em; + font-size: 0.9em; + height: auto; + max-height: 320px; + } +} \ No newline at end of file diff --git a/src/renderer/less/spinner.less b/src/renderer/less/spinner.less new file mode 100644 index 0000000..a95d901 --- /dev/null +++ b/src/renderer/less/spinner.less @@ -0,0 +1,89 @@ +/* http://tobiasahlin.com/spinkit/ */ +.sk-folding-cube { + margin: 20px auto; + width: 80px; + height: 80px; + position: relative; + -webkit-transform: rotateZ(45deg); + transform: rotateZ(45deg); +} + +.sk-folding-cube .sk-cube { + float: left; + width: 50%; + height: 50%; + position: relative; + -webkit-transform: scale(1.1); + -ms-transform: scale(1.1); + transform: scale(1.1); +} +.sk-folding-cube .sk-cube:before { + content: ''; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: #56af57; + -webkit-animation: sk-foldCubeAngle 2.4s infinite linear both; + animation: sk-foldCubeAngle 2.4s infinite linear both; + -webkit-transform-origin: 100% 100%; + -ms-transform-origin: 100% 100%; + transform-origin: 100% 100%; +} +.sk-folding-cube .sk-cube2 { + -webkit-transform: scale(1.1) rotateZ(90deg); + transform: scale(1.1) rotateZ(90deg); +} +.sk-folding-cube .sk-cube3 { + -webkit-transform: scale(1.1) rotateZ(180deg); + transform: scale(1.1) rotateZ(180deg); +} +.sk-folding-cube .sk-cube4 { + -webkit-transform: scale(1.1) rotateZ(270deg); + transform: scale(1.1) rotateZ(270deg); +} +.sk-folding-cube .sk-cube2:before { + -webkit-animation-delay: 0.3s; + animation-delay: 0.3s; +} +.sk-folding-cube .sk-cube3:before { + -webkit-animation-delay: 0.6s; + animation-delay: 0.6s; +} +.sk-folding-cube .sk-cube4:before { + -webkit-animation-delay: 0.9s; + animation-delay: 0.9s; +} + +@-webkit-keyframes sk-foldCubeAngle { + 0%, 10% { + -webkit-transform: perspective(140px) rotateX(-180deg); + transform: perspective(140px) rotateX(-180deg); + opacity: 0; + } 25%, 75% { + -webkit-transform: perspective(140px) rotateX(0deg); + transform: perspective(140px) rotateX(0deg); + opacity: 1; + } 90%, 100% { + -webkit-transform: perspective(140px) rotateY(180deg); + transform: perspective(140px) rotateY(180deg); + opacity: 0; + } +} + +@keyframes sk-foldCubeAngle { + 0%, 10% { + -webkit-transform: perspective(140px) rotateX(-180deg); + transform: perspective(140px) rotateX(-180deg); + opacity: 0; + } 25%, 75% { + -webkit-transform: perspective(140px) rotateX(0deg); + transform: perspective(140px) rotateX(0deg); + opacity: 1; + } 90%, 100% { + -webkit-transform: perspective(140px) rotateY(180deg); + transform: perspective(140px) rotateY(180deg); + opacity: 0; + } +} diff --git a/src/renderer/lib/Installer.js b/src/renderer/lib/Installer.js new file mode 100644 index 0000000..bade589 --- /dev/null +++ b/src/renderer/lib/Installer.js @@ -0,0 +1,115 @@ +import { EventEmitter } from 'events' +import fs from 'fs' +import osenv from 'osenv' +import path from 'path' +import request from 'request' +import semver from 'semver' +import Sudoer from 'electron-sudo' + +export class Installer extends EventEmitter { + constructor (version) { + super() + this._rawVersion = version + this.version = semver.valid(version) + this._cancel = false + } + + _emitError (msg) { + this.emit('error', new Error(msg)) + } + + _getDownloadAndInstallInfo () { + const base = 'https://nodejs.org/dist' + + switch (process.platform) { + case 'darwin': + return { + url: version => `${base}/v${version}/node-v${version}.pkg`, + install: path => `installer -pkg ${path} -target /` + } + case 'linux': + return { + url: version => `${base}/v${version}/node-v${version}-linux-${process.arch}.tar.gz`, + install: path => `tar --strip=1 -C /usr/local -oxf ${path}` + } + case 'win32': + return { + url: version => `${base}/v${version}/node-v${version}-${process.arch}.msi`, + install: path => `msiexec /qb /i ${path}` + } + default: + return null + } + } + + cancel () { + this._cancel = true + this.emit('cancel') + } + + install () { + const info = this._getDownloadAndInstallInfo() + if (!info) { + return this._emitError(`The installer doesn't current support the ${process.platform} platform`) + } + if (!this.version) { + return this._emitError(`The provided version: ${this._rawVersion} is not a valid version`) + } + const downloadUrl = info.url(this.version) + const fileName = path.basename(downloadUrl) + const downloadPath = path.resolve(osenv.tmpdir(), fileName) + + const cleanup = () => fs.unlink(downloadPath, () => {}) + + const file = fs.createWriteStream(downloadPath) + + this.on('cancel', () => { + try { + file.close() + } catch (err) { + // Ignore + } + }) + + request(downloadUrl) + .on('error', (err) => { + fs.unlink(downloadPath) + this.emit('error', err) + }) + .pipe(file) + // TODO: Emit download progress to show progress on screen + .on('close', () => { + if (this._cancel) return + + const installCommand = info.install(downloadPath) + + const sudoOptions = { + name: 'Install Node', + process: { + on: (ps) => { + ps.stdout.pipe(process.stdout) + ps.stderr.pipe(process.stderr) + } + } + } + + const sudo = new Sudoer(sudoOptions) + if (process.platform === 'win32') { + // Safety wipe + const elevatePath = path.resolve(osenv.tmpdir(), 'elevate.exe') + if (fs.existsSync(elevatePath)) fs.unlinkSync(elevatePath) + sudo.bundled = path.resolve(__dirname, '../../../node_modules/electron-sudo', sudo.bundled) + } + + sudo.exec(installCommand, sudoOptions) + .then(() => { + cleanup() + this.emit('done') + }) + .catch((err) => { + cleanup() + this.emit('error', err) + }) + }) + } +} diff --git a/src/renderer/lib/examples.js b/src/renderer/lib/examples.js index fa4aaf1..ae376ce 100644 --- a/src/renderer/lib/examples.js +++ b/src/renderer/lib/examples.js @@ -42,6 +42,5 @@ fs.readFile(file, function (err, contents) { `}] module.exports = (callback) => { - const { title, code } = examples[Math.floor(Math.random() * examples.length)] - callback(title, code) + callback(null, examples[Math.floor(Math.random() * examples.length)]) } diff --git a/src/renderer/lib/install.js b/src/renderer/lib/install.js deleted file mode 100644 index e4850a7..0000000 --- a/src/renderer/lib/install.js +++ /dev/null @@ -1,60 +0,0 @@ -const request = require('request') -const semver = require('semver') -const osenv = require('osenv') -const path = require('path') -const once = require('once') -const fs = require('fs') -const sudo = require('electron-sudo') - -const checkNode = require('./check-node') - -function downloadAndInstallInfo () { - const base = 'https://nodejs.org/dist' - - switch (process.platform) { - case 'darwin': - return { - url: version => `${base}/v${version}/node-v${version}.pkg`, - install: path => `installer -pkg ${path} -target /` - } - case 'linux': - return { - url: version => `${base}/v${version}/node-v${version}-linux-${process.arch}.tar.gz`, - install: path => `tar --strip=1 -C /usr/local -oxf ${path}` - } - case 'win32': - return { - url: version => `${base}/v${version}/node-v${version}-${process.arch}.msi`, - install: path => `msiexec /qb /i ${path}` - } - } -} - -module.exports = function install (version, cb) { - version = semver.valid(version) - cb = once(cb) - const info = downloadAndInstallInfo() - const u = info.url(version) - const filename = path.basename(u) - const p = path.join(osenv.tmpdir(), filename) - console.log(u, p) - const file = fs.createWriteStream(p) - request(u).on('error', cb).pipe(file).on('close', () => { - const command = info.install(p) - - const sudoOpts = { - name: 'Install Node', - // icns: '/path/to/icns/file' // (optional, only for MacOS), - process: { - on: (ps) => { - ps.stdout.pipe(process.stdout) - ps.stderr.pipe(process.stderr) - } - } - } - sudo.exec(command, sudoOpts, (err) => { - if (err) return cb(err, null) - checkNode(cb) - }) - }) -} diff --git a/src/renderer/lib/load.js b/src/renderer/lib/load.js deleted file mode 100644 index d8f6112..0000000 --- a/src/renderer/lib/load.js +++ /dev/null @@ -1,16 +0,0 @@ -// This file is required by the index.html file and will -// be executed in the renderer process for that window. -// All of the Node.js APIs are available in this process. - -const request = require('request').defaults({ json: true }) -const versions = require('./versions') - -module.exports = (cb) => { - request('https://nodejs.org/dist/index.json', (err, resp, index) => { - if (err) return cb(err) - if (resp.statusCode !== 200) { - return cb(new Error('Status not 200, ' + resp.statusCode)) - } - cb(null, versions(index)) - }) -} diff --git a/src/renderer/lib/versions.js b/src/renderer/lib/versions.js index 555a359..da4cd1a 100644 --- a/src/renderer/lib/versions.js +++ b/src/renderer/lib/versions.js @@ -1,4 +1,4 @@ -const semver = require('semver') +import semver from 'semver' function forceSort (dict) { function sorter (v1, v2) { @@ -11,50 +11,46 @@ function forceSort (dict) { return keys.map((k) => dict[k]) } -function Versions (index) { - this.index = index - this.majors = {} - this.lts = {} - this.load() -} +class Versions { + constructor (raw) { + this.raw = raw + this.majors = {} + this.lts = {} + this.load() + } + + load () { + const raw = this.raw + for (const version of raw) { + const major = semver.major(version.version) -Versions.prototype.load = function () { - const index = this.index - index.forEach((v) => { - var m = semver.major(v.version) - if (!this.majors[m]) this.majors[m] = {} - this.majors[m][v.version] = v - if (v.lts) { - if (!this.lts[m]) this.lts[m] = {} - this.lts[m][v.version] = v + if (!this.majors[major]) this.majors[major] = {} + this.majors[major][version.version] = version + if (version.lts) { + if (!this.lts[major]) this.lts[major] = {} + this.lts[major][version.version] = version + } + } + + this._latest = 0 + for (const majorN in this.majors) { + this.majors[majorN] = forceSort(this.majors[majorN]) + if (parseInt(majorN, 10) > this._latest) this._latest = parseInt(majorN, 10) + } + this._latestLTS = 0 + for (let k in this.lts) { + this.lts[k] = forceSort(this.lts[k]) + if (parseInt(k) > this._latestLTS) this._latestLTS = parseInt(k) } - }) - this._latest = 0 - for (let k in this.majors) { - this.majors[k] = forceSort(this.majors[k]) - if (parseInt(k) > this._latest) this._latest = parseInt(k) - } - this._latestLTS = 0 - for (let k in this.lts) { - this.lts[k] = forceSort(this.lts[k]) - if (parseInt(k) > this._latestLTS) this._latestLTS = parseInt(k) } -} -Versions.prototype.latest = function (version) { - let major - if (!version) major = this._latest - else major = semver.major(version) - if (!this.majors[major]) major = this._latest - return this.majors[major][0] -} + get latest () { + return this.majors[this._latest][0] + } -Versions.prototype.latestLTS = function (version) { - let major - if (!version) major = this._latestLTS - else major = semver.major(version) - if (!this.lts[major]) major = this._latestLTS - return this.lts[major][0] + get latestLTS () { + return this.lts[this._latestLTS][0] + } } -module.exports = index => new Versions(index) +module.exports = raw => new Versions(raw) diff --git a/src/renderer/renderer.js b/src/renderer/renderer.js deleted file mode 100644 index 41a6b49..0000000 --- a/src/renderer/renderer.js +++ /dev/null @@ -1,105 +0,0 @@ -'use strict' -/* globals alert, Prism */ - -import semver from 'semver' - -import getInstalledVersion from './lib/check-node' -import loadVersions from './lib/load' -import installNode from './lib/install' -import getExample from './lib/examples' - -// utility -const errIcon = '' -const domElement = e => document.querySelector(e) -const writeHTML = (e, h) => { domElement(e).innerHTML = h } -const compare = (a, l) => semver.lt(a, l) -const major = v => v.split('.')[0] - -const installing = { - run: false, - start () { - domElement('#installing').style.display = 'block' - this.run = true - }, - done () { - domElement('#installing').style.display = 'none' - this.run = false - } -} - -// Sets the text of the 'installed version' label -getInstalledVersion((err, version) => { - writeHTML('#installed-version span', err ? errIcon : `v${version}`) -}) - -// Sets the text of the 'install version' buttons -loadVersions((err, versions) => { - // get versions - const stable = versions.latestLTS().version - const latest = versions.latest().version - - // write versions into buttons - writeHTML('#install-stable span', err ? errIcon : stable) - writeHTML('#install-latest span', err ? errIcon : latest) - - if (err) return - - // checks if needs to update nodejs - getInstalledVersion((err2, actual) => { - if (err2) return - const updateButton = domElement('#update-to') - // checks if stable is up to date - if (major(actual) === major(stable) && compare(actual, stable)) { - updateButton.style.display = 'inline-block' - updateButton.children[1].innerHTML = stable - // checks if latest is up to date - } else if (major(actual) <= major(latest) && compare(actual, latest)) { - updateButton.style.display = 'inline-block' - updateButton.children[1].innerHTML = latest - } - // event listeners are attached after that the loadVersions retrieves the node versions - domElement('#install-stable').addEventListener('click', installEvent) - domElement('#install-latest').addEventListener('click', installEvent) - domElement('#update-to').addEventListener('click', installEvent) - }) -}) - -// Install events listener for 'install version' buttons -function installEvent (e) { - if (!installing.run) { - installing.start() - // gets version number from button text - const version = this.children[1].innerHTML.slice(1) - installNode(version, (err, v) => { - if (err) { - console.log(err) - domElement('#installing .error-message').style.display = 'block' - domElement('.sk-folding-cube').style.display = 'none' - writeHTML('#error-text', err.message) - return - } - console.log('Done!', v) - writeHTML('#installed-version span', `v${version}`) - domElement('#update-to').style.display = 'none' - installing.done() - }) - } else { - alert('Already performing an installation!') - } -} - -function installErrorEvent (e) { - domElement('#installing .error-message').style.display = 'none' - domElement('.sk-folding-cube').style.display = 'block' - writeHTML('#error-text', '') - installing.done() -} - -// Adds a random code example and refreshes Prism lib -getExample((title, code) => { - writeHTML('#code-title', title) - writeHTML('#code-example', code) - Prism.highlightElement(domElement('#code-example')) -}) - -domElement('#error-button').addEventListener('click', installErrorEvent) diff --git a/static/style.css b/static/style.css deleted file mode 100644 index 15705d3..0000000 --- a/static/style.css +++ /dev/null @@ -1,290 +0,0 @@ -/***************/ -/*** GENERAL ***/ -/***************/ - -* { - -webkit-user-select: none; - user-select: none; -} - -html, body, #main, #installing { - height: 100%; - width: 100%; - margin: 0; - padding: 0; - font-family: 'Open Sans', 'Helvetica', sans-serif; - font-weight: lighter; - background-color: #f7f7f7; - overflow: hidden; -} - -a { - text-decoration: none; - color: #277cd0; -} - -.left-panel, .right-panel { - height: 100%; - margin: 0; - padding: 30px 30px; -} - -.color-green { - color: #56af57; -} - -.background-green { - background-color: #56af57; -} - -.background-red { - background-color: #D32F2F; -} - -.color-red { - color: #D32F2F; -} - -/******************/ -/*** LEFT PANEL ***/ -/******************/ - -.left-panel>.row { - margin-bottom: 30px; -} - -.left-panel p { - color: #fff; - font-size: 1.2em; - margin: 0; -} - -#installed-version { - font-size: 1.3em; -} - -.node-image-container { - text-align: center; -} - -#node-logo { - width: 80%; - height: auto; - cursor: pointer; -} - -#installed-version span, -#update-to span, -#install-stable span, -#install-latest span { - font-weight: normal; -} - -#update-to { - display: none; -} - -.bottom { - position: absolute; - bottom: 30px; -} - -.version-button { - display: inline-block; - border: 1px #fff solid; - border-radius: 3px; - padding: 5px 10px; - width: 250px; - font-size: 1.3em; - margin-top: 10px; -} - -.version-button img { - width: 12px; - height: auto; - margin-right: 10px; -} - -.version-button, -.version-button:link, -.version-button:visited, -.version-button:hover, -.version-button:active { - color: #fff; - text-decoration: none; -} - -/*******************/ -/*** RIGHT PANEL ***/ -/*******************/ -.school-image-container { - text-align: center; -} - -.school-image-container img { - width: 280px; - height: auto -} - -.title { - font-weight: normal; - margin-bottom: -10px; -} - -.text { - line-height: 30px; -} - -pre, code, code span { - -webkit-user-select: all; - user-select: all; -} - -#code-container { - height: auto; - max-height: 320px; -} - -pre[class*="language-"] { - overflow: scroll !important; - padding: 0em 1em; - font-size: 0.9em; -} - -/******************/ -/*** INSTALLING ***/ -/******************/ -#installing { - position: fixed; - top: 0; - left: 0; - display: none; - z-index: 9999999; -} - -#installing div { - text-align: center; -} - -#installing div h1 { - font-weight: lighter; - font-size: 3em; - margin: 100px 0px; -} - -#installing .error-message { - display: none; - padding: 20px; - border-radius: 3px; -} - -#error-text { - -webkit-user-select: all; - user-select: all; - font-size: 1.2em; - margin: 0; - padding: 0; -} - -#error-button { - display: block; - border: 1px #D32F2F solid; - border-radius: 3px; - padding: 10px 15px; - width: 200px; - margin: 0 auto; - margin-top: 30px; -} - -/***************/ -/*** SPINNER ***/ -/***************/ -/* http://tobiasahlin.com/spinkit/ */ -.sk-folding-cube { - margin: 20px auto; - width: 80px; - height: 80px; - position: relative; - -webkit-transform: rotateZ(45deg); - transform: rotateZ(45deg); -} - -.sk-folding-cube .sk-cube { - float: left; - width: 50%; - height: 50%; - position: relative; - -webkit-transform: scale(1.1); - -ms-transform: scale(1.1); - transform: scale(1.1); -} -.sk-folding-cube .sk-cube:before { - content: ''; - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - background-color: #56af57; - -webkit-animation: sk-foldCubeAngle 2.4s infinite linear both; - animation: sk-foldCubeAngle 2.4s infinite linear both; - -webkit-transform-origin: 100% 100%; - -ms-transform-origin: 100% 100%; - transform-origin: 100% 100%; -} -.sk-folding-cube .sk-cube2 { - -webkit-transform: scale(1.1) rotateZ(90deg); - transform: scale(1.1) rotateZ(90deg); -} -.sk-folding-cube .sk-cube3 { - -webkit-transform: scale(1.1) rotateZ(180deg); - transform: scale(1.1) rotateZ(180deg); -} -.sk-folding-cube .sk-cube4 { - -webkit-transform: scale(1.1) rotateZ(270deg); - transform: scale(1.1) rotateZ(270deg); -} -.sk-folding-cube .sk-cube2:before { - -webkit-animation-delay: 0.3s; - animation-delay: 0.3s; -} -.sk-folding-cube .sk-cube3:before { - -webkit-animation-delay: 0.6s; - animation-delay: 0.6s; -} -.sk-folding-cube .sk-cube4:before { - -webkit-animation-delay: 0.9s; - animation-delay: 0.9s; -} - -@-webkit-keyframes sk-foldCubeAngle { - 0%, 10% { - -webkit-transform: perspective(140px) rotateX(-180deg); - transform: perspective(140px) rotateX(-180deg); - opacity: 0; - } 25%, 75% { - -webkit-transform: perspective(140px) rotateX(0deg); - transform: perspective(140px) rotateX(0deg); - opacity: 1; - } 90%, 100% { - -webkit-transform: perspective(140px) rotateY(180deg); - transform: perspective(140px) rotateY(180deg); - opacity: 0; - } -} - -@keyframes sk-foldCubeAngle { - 0%, 10% { - -webkit-transform: perspective(140px) rotateX(-180deg); - transform: perspective(140px) rotateX(-180deg); - opacity: 0; - } 25%, 75% { - -webkit-transform: perspective(140px) rotateX(0deg); - transform: perspective(140px) rotateX(0deg); - opacity: 1; - } 90%, 100% { - -webkit-transform: perspective(140px) rotateY(180deg); - transform: perspective(140px) rotateY(180deg); - opacity: 0; - } -}