diff --git a/common/Root.tsx b/common/Root.tsx index 2db29812..a869fd86 100644 --- a/common/Root.tsx +++ b/common/Root.tsx @@ -13,6 +13,7 @@ import BroadcastTx from 'containers/Tabs/BroadcastTx'; import ErrorScreen from 'components/ErrorScreen'; import PageNotFound from 'components/PageNotFound'; import LogOutPrompt from 'components/LogOutPrompt'; +import { TitleBar } from 'components/ui'; import { Store } from 'redux'; import { pollOfflineStatus } from 'actions/config'; import { AppState } from 'reducers'; @@ -73,12 +74,16 @@ export default class Root extends Component { ); - const Router = process.env.BUILD_DOWNLOADABLE ? HashRouter : BrowserRouter; + const Router = + process.env.BUILD_DOWNLOADABLE && process.env.NODE_ENV === 'production' + ? HashRouter + : BrowserRouter; return ( + {process.env.BUILD_ELECTRON && } {routes} diff --git a/common/components/Header/components/Version.scss b/common/components/Header/components/Version.scss new file mode 100644 index 00000000..03a64a76 --- /dev/null +++ b/common/components/Header/components/Version.scss @@ -0,0 +1,61 @@ +@import 'common/sass/variables'; + +@keyframes new-update-popin { + 0% { + opacity: 0; + transform: scale(0.7); + } + 60% { + opacity: 1; + transform: scale(1.2); + } + 100% { + opacity: 1; + transform: scale(1); + } +} + +@keyframes new-update-glow { + 0% { + opacity: 1; + transform: scale(1); + }, + 80%, 100% { + opacity: 0; + transform: scale(2.5); + } +} + +.Version { + position: relative; + + &-text { + &.has-update:hover { + cursor: pointer; + text-decoration: underline; + } + } + + &-new { + position: absolute; + top: 0px; + left: -12px; + width: 8px; + height: 8px; + border-radius: 100%; + background: $brand-warning; + animation: new-update-popin 500ms ease 1; + + &:before { + content: ''; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + border-radius: 100%; + box-shadow: 0 0 3px $brand-warning; + animation: new-update-glow 1200ms ease infinite; + } + } +} diff --git a/common/components/Header/components/Version.tsx b/common/components/Header/components/Version.tsx new file mode 100644 index 00000000..ea6c92c7 --- /dev/null +++ b/common/components/Header/components/Version.tsx @@ -0,0 +1,48 @@ +import React from 'react'; +import { VERSION } from 'config/data'; +import UpdateModal, { UpdateInfo } from 'components/UpdateModal'; +import { addListener } from 'utils/electron'; +import EVENTS from 'shared/electronEvents'; +import './Version.scss'; + +interface State { + updateInfo: UpdateInfo | null; + isModalOpen: boolean; +} + +export default class Version extends React.Component<{}, State> { + public state: State = { + updateInfo: null, + isModalOpen: false + }; + + public componentDidMount() { + addListener(EVENTS.UPDATE.UPDATE_AVAILABLE, updateInfo => { + this.setState({ updateInfo }); + }); + } + + public render() { + const { updateInfo, isModalOpen } = this.state; + return ( +
+ + v{VERSION} + + {updateInfo && ( + + + + + )} +
+ ); + } + + private openModal = () => this.setState({ isModalOpen: true }); + private closeModal = () => this.setState({ isModalOpen: false }); +} diff --git a/common/components/Header/index.tsx b/common/components/Header/index.tsx index e4de9ee6..34c5f167 100644 --- a/common/components/Header/index.tsx +++ b/common/components/Header/index.tsx @@ -16,7 +16,6 @@ import { ANNOUNCEMENT_TYPE, languages, NODES, - VERSION, NodeConfig, CustomNodeConfig, CustomNetworkConfig @@ -25,6 +24,7 @@ import GasPriceDropdown from './components/GasPriceDropdown'; import Navigation from './components/Navigation'; import CustomNodeModal from './components/CustomNodeModal'; import OnlineStatus from './components/OnlineStatus'; +import Version from './components/Version'; import { getKeyByValue } from 'utils/helpers'; import { makeCustomNodeId } from 'utils/node'; import { getNetworkConfigFromId } from 'utils/network'; @@ -128,7 +128,9 @@ export default class Header extends Component { />
- v{VERSION} + + +
diff --git a/common/components/UpdateModal.scss b/common/components/UpdateModal.scss new file mode 100644 index 00000000..c4eb8f16 --- /dev/null +++ b/common/components/UpdateModal.scss @@ -0,0 +1,62 @@ +@import 'common/sass/variables'; + +.UpdateModal { + cursor: default; + @media (min-width: 680px) { + min-width: 680px; + } + + &-title { + margin-top: 0; + margin-bottom: $space-xs; + } + + &-date { + font-size: $font-size-small; + color: $gray; + padding-bottom: $space-sm; + border-bottom: 1px solid $gray-lighter; + } + + &-downloader { + padding: 50px 30px 80px; + text-align: center; + + &-bar { + position: relative; + margin: 0 20px; + height: 16px; + border-radius: 4px; + background: $gray-lighter; + margin-bottom: $space-sm; + + &-inner { + position: absolute; + top: 0; + left: 0; + bottom: 0; + border-radius: 4px; + background: $brand-primary; + transition: width 100ms ease; + } + } + + &-info { + color: $gray-light; + font-size: $font-size-xs; + + &-bit { + &:after { + display: inline-block; + content: "•"; + padding: 0 $space-xs; + opacity: 0.8; + } + + &:last-child:after { + display: none; + } + } + } + } +} diff --git a/common/components/UpdateModal.tsx b/common/components/UpdateModal.tsx new file mode 100644 index 00000000..d5855b91 --- /dev/null +++ b/common/components/UpdateModal.tsx @@ -0,0 +1,142 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import moment from 'moment'; +import { showNotification, TShowNotification } from 'actions/notifications'; +import { Spinner, NewTabLink } from 'components/ui'; +import Modal, { IButton } from 'components/ui/Modal'; +import { addListener, sendEvent } from 'utils/electron'; +import EVENTS from 'shared/electronEvents'; +import { bytesToHuman } from 'utils/formatters'; +import './UpdateModal.scss'; + +export interface UpdateInfo { + version: string; + sha512: string; + releaseDate: string; + releaseName: string; + releaseNotes: string; +} + +export interface DownloadProgress { + bytesPerSecond: number; + percent: number; + transferred: number; + total: number; +} + +interface Props { + isOpen: boolean; + updateInfo: UpdateInfo; + showNotification: TShowNotification; + handleClose(): void; +} + +interface State { + isDownloading: boolean; + downloadProgress: DownloadProgress | null; +} + +class UpdateModal extends React.Component { + public state: State = { + isDownloading: false, + downloadProgress: null + }; + + public componentDidMount() { + addListener(EVENTS.UPDATE.UPDATE_DOWNLOADED, () => { + sendEvent(EVENTS.UPDATE.QUIT_AND_INSTALL); + }); + addListener(EVENTS.UPDATE.DOWNLOAD_PROGRESS, downloadProgress => { + this.setState({ downloadProgress }); + }); + addListener(EVENTS.UPDATE.ERROR, err => { + console.error('Update failed:', err); + this.setState({ isDownloading: false }); + this.props.showNotification( + 'danger', + + Update could not be downloaded, please visit{' '} + + our github + {' '} + to download the latest release + , + Infinity + ); + }); + } + + public render() { + const { isOpen, updateInfo, handleClose } = this.props; + const { isDownloading, downloadProgress } = this.state; + const buttons: IButton[] | undefined = downloadProgress + ? undefined + : [ + { + text: {isDownloading && } Download Update, + type: 'primary', + onClick: this.downloadUpdate, + disabled: isDownloading + }, + { + text: 'Close', + type: 'default', + onClick: handleClose + } + ]; + + return ( + +
+ {downloadProgress ? ( +
+

Downloading...

+
+
+
+
+ + Downloaded {downloadProgress.percent.toFixed(1)}% + + + {bytesToHuman(downloadProgress.bytesPerSecond)}/s + + + Total Size {bytesToHuman(downloadProgress.total)} + +
+
+ ) : ( +
+

{updateInfo.releaseName}

+
{moment(updateInfo.releaseDate).format('LL')}
+
+
+ )} +
+ + ); + } + + private downloadUpdate = () => { + this.setState({ isDownloading: true }); + sendEvent('UPDATE:download-update'); + }; +} + +export default connect(undefined, { showNotification })(UpdateModal); diff --git a/common/components/ui/NewTabLink.tsx b/common/components/ui/NewTabLink.tsx index cbd11930..95fb67dd 100644 --- a/common/components/ui/NewTabLink.tsx +++ b/common/components/ui/NewTabLink.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import { openInBrowser } from 'utils/electron'; interface AAttributes { charset?: string; @@ -35,10 +36,21 @@ interface NewTabLinkProps extends AAttributes { children?: React.ReactElement | string; } -const NewTabLink = ({ content, children, ...rest }: NewTabLinkProps) => ( - - {content || children} - -); +export class NewTabLink extends React.Component { + public render() { + const { content, children, ...rest } = this.props; + return ( + + {content || children} + + ); + } + + private handleClick(ev: React.MouseEvent) { + if (openInBrowser(ev.currentTarget.href)) { + ev.preventDefault(); + } + } +} export default NewTabLink; diff --git a/common/components/ui/TitleBar.scss b/common/components/ui/TitleBar.scss new file mode 100644 index 00000000..d6450593 --- /dev/null +++ b/common/components/ui/TitleBar.scss @@ -0,0 +1,27 @@ +@import 'common/sass/variables'; + +$height: 22px; + +// TODO - Implement styles for custom title bar on all platforms +.TitleBar, +.TitleBarPlaceholder { + display: none; +} + +.TitleBar { + position: fixed; + top: 0; + left: 0; + right: 0; + height: $height; + line-height: $height; + -webkit-user-select: none; + -webkit-app-region: drag; + background: $body-bg; + z-index: $zindex-top; + box-shadow: 0 1px 1px rgba(#000, 0.08); +} + +.TitleBarPlaceholder { + height: $height; +} diff --git a/common/components/ui/TitleBar.tsx b/common/components/ui/TitleBar.tsx new file mode 100644 index 00000000..fe7fdcdd --- /dev/null +++ b/common/components/ui/TitleBar.tsx @@ -0,0 +1,11 @@ +import React from 'react'; +import './TitleBar.scss'; + +const TitleBar: React.SFC<{}> = () => ( + +
+
+ +); + +export default TitleBar; diff --git a/common/components/ui/index.ts b/common/components/ui/index.ts index 07f92209..580d5f38 100644 --- a/common/components/ui/index.ts +++ b/common/components/ui/index.ts @@ -10,5 +10,6 @@ export { default as UnitDisplay } from './UnitDisplay'; export { default as Spinner } from './Spinner'; export { default as SwapDropdown } from './SwapDropdown'; export { default as Tooltip } from './Tooltip'; +export { default as TitleBar } from './TitleBar'; export * from './ConditionalInput'; export * from './Expandable'; diff --git a/common/utils/electron.ts b/common/utils/electron.ts new file mode 100644 index 00000000..0e09b0c2 --- /dev/null +++ b/common/utils/electron.ts @@ -0,0 +1,25 @@ +// Handles integrations with Electron. Wherever possible, should stub out +// behavior with noop's if not in the Electron environment, to simplify code. +import { ElectronBridgeFunctions } from 'shared/electronBridge'; +const bridge: ElectronBridgeFunctions | null = (window as any).electronBridge; + +export const addListener: ElectronBridgeFunctions['addListener'] = (event, cb) => { + if (bridge && bridge.addListener) { + // @ts-ignore unused ev + bridge.addListener(event, (ev, data) => cb(data)); + } +}; + +export const sendEvent: ElectronBridgeFunctions['sendEvent'] = (event, data) => { + if (bridge && bridge.sendEvent) { + bridge.sendEvent(event, data); + } +}; + +export const openInBrowser: ElectronBridgeFunctions['openInBrowser'] = url => { + if (bridge && bridge.openInBrowser) { + bridge.openInBrowser(url); + return true; + } + return false; +}; diff --git a/common/utils/formatters.ts b/common/utils/formatters.ts index 8056e841..505b3cb3 100644 --- a/common/utils/formatters.ts +++ b/common/utils/formatters.ts @@ -100,3 +100,12 @@ export function formatGasLimit(limit: Wei, transactionUnit: string = 'ether') { export function formatMnemonic(phrase: string) { return phrase.replace(/(\r\n|\n|\r|\s+|,)/gm, ' ').trim(); } + +export function bytesToHuman(bytes: number) { + if (bytes <= 0) { + return '0 b'; + } + const sizes = ['b', 'kb', 'mb', 'gb', 'tb']; + const i = Math.round(Math.floor(Math.log(bytes) / Math.log(1024))); + return Math.round(bytes / Math.pow(1024, i)) + ' ' + sizes[i]; +} diff --git a/electron-app/icons/icon.icns b/electron-app/icons/icon.icns new file mode 100644 index 00000000..b8bcc71d Binary files /dev/null and b/electron-app/icons/icon.icns differ diff --git a/electron-app/icons/icon.ico b/electron-app/icons/icon.ico new file mode 100644 index 00000000..e13b4442 Binary files /dev/null and b/electron-app/icons/icon.ico differ diff --git a/electron-app/main/index.ts b/electron-app/main/index.ts new file mode 100644 index 00000000..6be98398 --- /dev/null +++ b/electron-app/main/index.ts @@ -0,0 +1,86 @@ +import { app, BrowserWindow, Menu } from 'electron'; +import * as path from 'path'; +import updater from './updater'; +import MENU from './menu'; + +const isDevelopment = process.env.NODE_ENV !== 'production'; + +// Global reference to mainWindow +// Necessary to prevent window from being garbage collected +let mainWindow; + +function createMainWindow() { + // Construct new BrowserWindow + const window = new BrowserWindow({ + title: 'MyEtherWallet', + backgroundColor: '#fbfbfb', + width: 1220, + height: 800, + minWidth: 320, + minHeight: 400, + // TODO - Implement styles for custom title bar in components/ui/TitleBar.scss + // frame: false, + // titleBarStyle: 'hidden', + webPreferences: { + devTools: true, + nodeIntegration: false, + preload: path.resolve(__dirname, 'preload.js') + } + }); + + const url = isDevelopment + ? `http://localhost:${process.env.HTTPS ? 3443 : 3000}` + : `file://${__dirname}/index.html`; + window.loadURL(url); + + window.on('closed', () => { + mainWindow = null; + }); + + window.webContents.on('devtools-opened', () => { + window.focus(); + setImmediate(() => { + window.focus(); + }); + }); + + if (isDevelopment) { + window.webContents.on('did-fail-load', () => { + setTimeout(() => { + if (window && window.webContents) { + window.webContents.reload(); + } + }, 500); + }); + } + + Menu.setApplicationMenu(Menu.buildFromTemplate(MENU)); + + return window; +} + +// Quit application when all windows are closed +app.on('window-all-closed', () => { + // On macOS it is common for applications to stay open + // until the user explicitly quits + if (process.platform !== 'darwin') { + app.quit(); + } +}); + +app.on('activate', () => { + // On macOS it is common to re-create a window + // even after all windows have been closed + if (mainWindow === null) { + mainWindow = createMainWindow(); + updater(app, mainWindow); + } +}); + +// Create main BrowserWindow when electron is ready +app.on('ready', () => { + mainWindow = createMainWindow(); + mainWindow.webContents.on('did-finish-load', () => { + updater(app, mainWindow); + }); +}); diff --git a/electron-app/main/menu.ts b/electron-app/main/menu.ts new file mode 100644 index 00000000..c5857f20 --- /dev/null +++ b/electron-app/main/menu.ts @@ -0,0 +1,81 @@ +import { MenuItemConstructorOptions, shell } from 'electron'; + +const MENU: MenuItemConstructorOptions[] = [ + { + label: 'Edit', + submenu: [ + { role: 'undo' }, + { role: 'redo' }, + { type: 'separator' }, + { role: 'cut' }, + { role: 'copy' }, + { role: 'paste' }, + { role: 'pasteandmatchstyle' }, + { role: 'delete' }, + { role: 'selectall' } + ] + }, + { + label: 'View', + submenu: [ + { role: 'reload' }, + { role: 'forcereload' }, + { type: 'separator' }, + { role: 'resetzoom' }, + { role: 'zoomin' }, + { role: 'zoomout' }, + { role: 'togglefullscreen' }, + { type: 'separator' }, + { role: 'toggledevtools' } + ] + } +]; + +const HELP_MENU = { + role: 'help', + submenu: [ + { + label: 'Help / FAQ', + click() { + shell.openExternal('https://myetherwallet.github.io/knowledge-base/'); + } + }, + { + label: 'Report a Bug', + click() { + shell.openExternal('https://github.com/MyEtherWallet/MyEtherWallet/issues/new'); + } + } + ] +}; + +if (process.platform === 'darwin') { + MENU.unshift({ + label: 'MyEtherWallet', + submenu: [ + { role: 'about' }, + { type: 'separator' }, + { role: 'hide' }, + { role: 'hideothers' }, + { role: 'unhide' }, + { type: 'separator' }, + { role: 'quit' } + ] + }); + + // Modified help menu + MENU.push({ + ...HELP_MENU, + submenu: [ + ...HELP_MENU.submenu, + { + label: 'Speech', + submenu: [{ role: 'startspeaking' }, { role: 'stopspeaking' }] + } + ] + }); +} else { + MENU.push(HELP_MENU); +} + +export default MENU; diff --git a/electron-app/main/testrelease.json b/electron-app/main/testrelease.json new file mode 100644 index 00000000..ccfca81d --- /dev/null +++ b/electron-app/main/testrelease.json @@ -0,0 +1,19 @@ +{ + "version": "5.0.0", + "files": [ + { + "url": "MyEtherWallet-5.0.0-mac.zip", + "sha512": "b95kHlKspcJTo3Bh5tYAZKOrpjncOGOpS2GVA+nRPw6sLy/90SR/alijbe96m+T2CB9ajxDbYMBfs+wknBHZ4g==" + }, + { + "url": "MyEtherWallet-5.0.0.dmg", + "sha512": "p+tMwE0t2svyppZVK1pFXVLEspRsVPYJtGIp07ppRtQWzkz+krz+dyU9sn2wMdtfIVpSjAGWDpoS2nhvLbHb5A==", + "size": 49786443 + } + ], + "path": "MyEtherWallet-5.0.0-mac.zip", + "sha512": "b95kHlKspcJTo3Bh5tYAZKOrpjncOGOpS2GVA+nRPw6sLy/90SR/alijbe96m+T2CB9ajxDbYMBfs+wknBHZ4g==", + "releaseDate": "2018-01-14T20:45:04.007Z", + "releaseName": "v5.0.0 - Fake Test Release", + "releaseNotes": "

Bug Fixes

Linux

  • Fixed subpixel font rendering with freetype. #11402

macOS

  • Fixed rendering issues with Nvidia GPU on High Sierra. #10898
  • Fixed incorrectly cropped TouchBar items. #11141
" +} diff --git a/electron-app/main/updater.ts b/electron-app/main/updater.ts new file mode 100644 index 00000000..1f0d09bb --- /dev/null +++ b/electron-app/main/updater.ts @@ -0,0 +1,97 @@ +import { App, BrowserWindow, ipcMain } from 'electron'; +import { autoUpdater } from 'electron-updater'; +import EVENTS from '../../shared/electronEvents'; +import TEST_RELEASE from './testrelease.json'; +autoUpdater.autoDownload = false; + +enum AutoUpdaterEvents { + CHECKING_FOR_UPDATE = 'checking-for-update', + UPDATE_NOT_AVAILABLE = 'update-not-available', + UPDATE_AVAILABLE = 'update-available', + DOWNLOAD_PROGRESS = 'download-progress', + UPDATE_DOWNLOADED = 'update-downloaded', + ERROR = 'error' +} + +export default (app: App, window: BrowserWindow) => { + // Set to 'true' if you want to test update behavior. Requires a recompile. + const shouldMockUpdate = true && process.env.NODE_ENV !== 'production'; + + // Report update status + autoUpdater.on(AutoUpdaterEvents.CHECKING_FOR_UPDATE, () => { + window.webContents.send(EVENTS.UPDATE.CHECKING_FOR_UPDATE); + }); + + autoUpdater.on(AutoUpdaterEvents.UPDATE_NOT_AVAILABLE, () => { + window.webContents.send(EVENTS.UPDATE.UPDATE_NOT_AVAILABLE); + }); + + autoUpdater.on(AutoUpdaterEvents.UPDATE_AVAILABLE, info => { + window.webContents.send(EVENTS.UPDATE.UPDATE_AVAILABLE, info); + }); + + autoUpdater.on(AutoUpdaterEvents.DOWNLOAD_PROGRESS, progress => { + window.webContents.send(EVENTS.UPDATE.DOWNLOAD_PROGRESS, progress); + }); + + autoUpdater.on(AutoUpdaterEvents.UPDATE_DOWNLOADED, () => { + window.webContents.send(EVENTS.UPDATE.UPDATE_DOWNLOADED); + }); + + autoUpdater.on(AutoUpdaterEvents.ERROR, (err, msg) => { + console.error('Update failed with an error'); + console.error(err); + window.webContents.send(EVENTS.UPDATE.ERROR, msg); + }); + + autoUpdater.checkForUpdatesAndNotify(); + + // Listen for restart request + ipcMain.on(EVENTS.UPDATE.DOWNLOAD_UPDATE, () => { + if (shouldMockUpdate) { + mockDownload(window); + } else { + autoUpdater.downloadUpdate(); + } + }); + + ipcMain.on(EVENTS.UPDATE.QUIT_AND_INSTALL, () => { + if (shouldMockUpdate) { + app.quit(); + } else { + autoUpdater.quitAndInstall(); + } + }); + + // Simulate a test release + if (shouldMockUpdate) { + mockUpdateCheck(window); + } +}; + +// Mock functions for dev testing +function mockUpdateCheck(window: BrowserWindow) { + window.webContents.send(EVENTS.UPDATE.CHECKING_FOR_UPDATE); + + setTimeout(() => { + window.webContents.send(EVENTS.UPDATE.UPDATE_AVAILABLE, TEST_RELEASE); + }, 3000); +} + +function mockDownload(window: BrowserWindow) { + for (let i = 0; i < 101; i++) { + setTimeout(() => { + const total = 150000000; + window.webContents.send(EVENTS.UPDATE.DOWNLOAD_PROGRESS, { + bytesPerSecond: Math.round(Math.random() * 100000), + percent: i, + transferred: total / i, + total + }); + + if (i === 100) { + window.webContents.send(EVENTS.UPDATE.UPDATE_DOWNLOADED); + } + }, 50 * i); + } +} diff --git a/electron-app/preload.ts b/electron-app/preload.ts new file mode 100644 index 00000000..da9fd22c --- /dev/null +++ b/electron-app/preload.ts @@ -0,0 +1,19 @@ +// Selectively expose node integration, since all node integrations are +// disabled by default for security purposes. +import { ipcRenderer, shell } from 'electron'; +import { ElectronBridgeFunctions } from '../shared/electronBridge'; +const win = window as any; + +const functions: ElectronBridgeFunctions = { + addListener(event, cb) { + ipcRenderer.on(event, cb); + }, + sendEvent(event, data) { + ipcRenderer.send(event, data); + }, + openInBrowser(url) { + return shell.openExternal(url); + } +}; + +win.electronBridge = functions; diff --git a/jest_config/jest.config.json b/jest_config/jest.config.json index b93875a1..91ddb83f 100644 --- a/jest_config/jest.config.json +++ b/jest_config/jest.config.json @@ -10,7 +10,8 @@ "\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "/jest_config/__mocks__/fileMock.ts", "\\.(css|scss|less)$": "/jest_config/__mocks__/styleMock.ts", - "\\.worker.ts":"/jest_config/__mocks__/workerMock.js" + "\\.worker.ts":"/jest_config/__mocks__/workerMock.js", + "^shared(.*)$": "/shared$1" }, "testPathIgnorePatterns": ["/common/config"], "setupFiles": [ diff --git a/jest_config/jest.int.config.json b/jest_config/jest.int.config.json index 404dce04..f2be1359 100644 --- a/jest_config/jest.int.config.json +++ b/jest_config/jest.int.config.json @@ -9,7 +9,8 @@ "moduleNameMapper": { "\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "/jest_config/__mocks__/fileMock.ts", - "\\.(css|scss|less)$": "/jest_config/__mocks__/styleMock.ts" + "\\.(css|scss|less)$": "/jest_config/__mocks__/styleMock.ts", + "^shared(.*)$": "/shared$1" }, "testPathIgnorePatterns": ["/common/config"], "setupFiles": [ diff --git a/package.json b/package.json index ba242ca9..3d8a0e8f 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,10 @@ { "name": "MyEtherWallet", + "author": "MyEtherWallet", "version": "4.0.0-alpha.1", - "main": "common/index.jsx", + "main": "main.js", "description": "MyEtherWallet v4", + "repository": "https://github.com/wbobeirne/MyEtherWallet-electron", "engines": { "node": ">= 8.0.0", "npm": ">= 5.0.0" @@ -13,6 +15,7 @@ "bn.js": "4.11.8", "bootstrap-sass": "3.3.7", "classnames": "2.2.5", + "electron-updater": "2.18.2", "ethereum-blockies": "git+https://github.com/MyEtherWallet/blockies.git", "ethereumjs-abi": "0.6.5", "ethereumjs-tx": "1.3.3", @@ -74,8 +77,11 @@ "bs58": "4.0.1", "cache-loader": "1.2.0", "check-node-version": "3.2.0", + "concurrently": "3.5.1", "copy-webpack-plugin": "4.3.1", "css-loader": "0.28.8", + "electron": "1.7.10", + "electron-builder": "19.52.1", "empty": "0.10.1", "enzyme": "3.3.0", "enzyme-adapter-react-16": "1.1.1", @@ -131,21 +137,27 @@ "freezer": "webpack --config=./webpack_config/webpack.freezer.js && node ./dist/freezer.js", "freezer:validate": "npm run freezer -- --validate", "db": "nodemon ./db", - "build": "rimraf dist && webpack --config webpack_config/webpack.prod.js", + "build": "webpack --config webpack_config/webpack.prod.js", "prebuild": "check-node-version --package", - "postbuild": "node ./utils/postBuild.js", - "build:downloadable": "BUILD_DOWNLOADABLE=true rimraf dist && webpack --config webpack_config/webpack.prod.js", - "prebuild:demo": "check-node-version --package", + "build:downloadable": "webpack --config webpack_config/webpack.html.js", + "prebuild:downloadable": "check-node-version --package", + "build:electron": "webpack --config webpack_config/webpack.electron-prod.js && node webpack_config/buildElectron.js", + "build:electron:osx": "webpack --config webpack_config/webpack.electron-prod.js && ELECTRON_OS=osx node webpack_config/buildElectron.js", + "build:electron:windows": "webpack --config webpack_config/webpack.electron-prod.js && ELECTRON_OS=windows node webpack_config/buildElectron.js", + "build:electron:linux": "webpack --config webpack_config/webpack.electron-prod.js && ELECTRON_OS=linux node webpack_config/buildElectron.js", + "prebuild:electron": "check-node-version --package", "test:coverage": "jest --config=jest_config/jest.config.json --coverage", "test": "jest --config=jest_config/jest.config.json", "test:unit": "jest --config=jest_config/jest.config.json --coverage", "test:int": "jest --config=jest_config/jest.int.config.json --coverage", "updateSnapshot": "jest --config=jest_config/jest.config.json --updateSnapshot", "pretest": "check-node-version --package", - "dev": "node webpack_config/server.js", + "dev": "node webpack_config/devServer.js", "predev": "check-node-version --package", - "dev:https": "HTTPS=true node webpack_config/server.js", + "dev:https": "HTTPS=true node webpack_config/devServer.js", "predev:https": "check-node-version --package", + "dev:electron": "concurrently --kill-others --names 'webpack,electron' 'BUILD_ELECTRON=true node webpack_config/devServer.js' 'webpack --config webpack_config/webpack.electron-dev.js && electron dist/electron-js/main.js'", + "dev:electron:https": "concurrently --kill-others --names 'webpack,electron' 'BUILD_ELECTRON=true HTTPS=true node webpack_config/devServer.js' 'HTTPS=true webpack --config webpack_config/webpack.electron-dev.js && electron dist/electron-js/main.js'", "tslint": "tslint --project . --exclude common/vendor/**/*", "tscheck": "tsc --noEmit", "start": "npm run dev", diff --git a/shared/electronBridge.ts b/shared/electronBridge.ts new file mode 100644 index 00000000..f24582f4 --- /dev/null +++ b/shared/electronBridge.ts @@ -0,0 +1,9 @@ +// Provide typescript definitions / mappings between `electron-app/preload.ts` +// and 'common/utils/electron.ts' +export type ElectronBridgeCallback = (data?: any) => void; + +export interface ElectronBridgeFunctions { + addListener(event: string, cb: ElectronBridgeCallback); + sendEvent(event: string, data?: any); + openInBrowser(url: string): boolean; +} diff --git a/shared/electronEvents.ts b/shared/electronEvents.ts new file mode 100644 index 00000000..60fb2fd6 --- /dev/null +++ b/shared/electronEvents.ts @@ -0,0 +1,12 @@ +export default { + UPDATE: { + CHECKING_FOR_UPDATE: 'UPDATE:checking-for-update', + UPDATE_NOT_AVAILABLE: 'UPDATE:update-not-available', + UPDATE_AVAILABLE: 'UPDATE:update-available', + DOWNLOAD_PROGRESS: 'UPDATE:download-progress', + UPDATE_DOWNLOADED: 'UPDATE:update-downloaded', + ERROR: 'UPDATE:error', + DOWNLOAD_UPDATE: 'UPDATE:download-update', + QUIT_AND_INSTALL: 'UPDATE:quit-and-install' + } +}; diff --git a/tsconfig.json b/tsconfig.json index c22c42e2..8f948409 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -8,6 +8,9 @@ "target": "es2015", "allowJs": true, "baseUrl": "./common/", + "paths": { + "shared*": ["../shared*"] + }, "lib": [ "es2017", "dom" @@ -20,6 +23,7 @@ }, "include": [ "./common/", + "./electron/", "spec", "./node_modules/types-rlp/index.d.ts" ], diff --git a/utils/postBuild.js b/utils/postBuild.js deleted file mode 100644 index d79d5a6b..00000000 --- a/utils/postBuild.js +++ /dev/null @@ -1,46 +0,0 @@ - -/** - * (1) Parses the '.cache' file in the 'dist/icons' folder - * (2) Sorts the 'cache.result.files' property - * (3) Rewrites the file to ensure a deterministic build - */ - - -const fs = require('fs') -const path = require('path') -const klawSync = require('klaw-sync') - -const DIST_PATH = path.resolve('./dist/') -const CACHE_FILE_REGEX = /.*icons-[a-z0-9]*\/\.cache$/ - -const findCacheFile = item => CACHE_FILE_REGEX.test(item.path) - -console.log('postBuild start') - -try { - const cacheFilePaths = klawSync(DIST_PATH, { filter: findCacheFile }) - - if (!cacheFilePaths.length) { - throw new Error('Could not find .cache file') - } - - if (cacheFilePaths.length > 1) { - throw new Error('More than one possible .cache file detected') - } - - const cacheFilePath = cacheFilePaths[0].path - const rawCacheFile = fs.readFileSync(cacheFilePath, 'utf8') - const cache = JSON.parse(rawCacheFile) - - cache.result.files = cache.result.files.sort() - - fs.writeFileSync(cacheFilePath, JSON.stringify(cache), 'utf8') - -} catch(err) { - console.log('postBuild fail', err) - process.exit(1) -} - -console.log('postBuild finish') - - diff --git a/webpack_config/buildElectron.js b/webpack_config/buildElectron.js new file mode 100644 index 00000000..356d06f6 --- /dev/null +++ b/webpack_config/buildElectron.js @@ -0,0 +1,68 @@ +'use strict'; +const path = require('path'); +const fs = require('fs'); +const rimraf = require('rimraf'); +const builder = require('electron-builder'); +const config = require('./config'); + +function shouldBuildOs(os) { + return !process.env.ELECTRON_OS || process.env.ELECTRON_OS === os; +} + +async function build() { + console.log('Beginning Electron build process...'); + const jsBuildDir = path.join(config.path.output, 'electron-js'); + const electronBuildsDir = path.join(config.path.output, 'electron-builds'); + const compression = 'store'; + + console.log('Clearing out old builds...'); + rimraf.sync(electronBuildsDir); + + // Builder requires package.json be in the app directory, so copy it in + fs.copyFileSync( + path.join(config.path.root, 'package.json'), + path.join(jsBuildDir, 'package.json') + ); + + console.log('Building...'); + await builder.build({ + mac: shouldBuildOs('mac') ? ['zip', 'dmg'] : undefined, + win: shouldBuildOs('windows') ? ['nsis'] : undefined, + linux: shouldBuildOs('linux') ? ['AppImage'] : undefined, + x64: true, + ia32: true, + config: { + appId: 'com.github.myetherwallet.myetherwallet', + productName: 'MyEtherWallet', + directories: { + app: jsBuildDir, + output: electronBuildsDir, + }, + mac: { + category: 'public.app-category.finance', + icon: path.join(config.path.electron, 'icons/icon.icns'), + compression + }, + win: { + icon: path.join(config.path.electron, 'icons/icon.ico'), + compression + }, + linux: { + category: 'Finance', + compression + }, + publish: { + provider: 'github', + owner: 'MyEtherWallet', + repo: 'MyEtherWallet', + vPrefixedTagName: false + }, + // IMPORTANT: Prevents extending configs in node_modules + extends: null + } + }); + + console.info(`Electron builds are finished! Available at ${electronBuildsDir}`); +} + +build(); diff --git a/webpack_config/config.js b/webpack_config/config.js index a5d6acfa..8c0c151f 100644 --- a/webpack_config/config.js +++ b/webpack_config/config.js @@ -1,13 +1,45 @@ 'use strict'; const path = require('path'); +const paths = { + root: path.join(__dirname, '../'), + src: path.join(__dirname, '../common'), + output: path.join(__dirname, '../dist'), + assets: path.join(__dirname, '../common/assets'), + static: path.join(__dirname, '../static'), + electron: path.join(__dirname, '../electron-app'), + shared: path.join(__dirname, '../shared'), + modules: path.join(__dirname, '../node_modules'), +} + module.exports = { + // Configuration port: process.env.HTTPS ? 3443 : 3000, title: 'MEW', - publicPath: process.env.BUILD_DOWNLOADABLE ? './' : '/', - srcPath: path.join(__dirname, './../common'), - // add these dependencies to a standalone vendor bundle - vendor: [ + path: paths, + + // Typescript rule config + typescriptRule: { + test: /\.(ts|tsx)$/, + include: [paths.src, paths.shared, paths.electron], + use: [{ loader: 'ts-loader', options: { happyPackMode: true, logLevel: 'info' } }], + exclude: ['assets', 'sass', 'vendor', 'translations/lang'] + .map(dir => path.resolve(paths.src, dir)) + .concat([paths.modules]) + }, + + // File resolution + resolve: { + extensions: ['.ts', '.tsx', '.js', '.css', '.json', '.scss', '.less'], + modules: [ + paths.src, + paths.modules, + paths.root, + ] + }, + + // Vendor modules + vendorModules: [ 'bip39', 'bn.js', 'classnames', diff --git a/webpack_config/server.js b/webpack_config/devServer.js similarity index 94% rename from webpack_config/server.js rename to webpack_config/devServer.js index e5359788..e79ddd88 100644 --- a/webpack_config/server.js +++ b/webpack_config/devServer.js @@ -6,7 +6,7 @@ const https = require('https'); const fs = require('fs'); const webpackConfig = require('./webpack.dev'); const config = require('./config'); -const LogPlugin = require('./log-plugin'); +const LogPlugin = require('./plugins/serverLog'); const app = express(); @@ -25,13 +25,13 @@ let compiler; try { compiler = webpack(webpackConfig); } catch (err) { - console.log(err.message); + console.error(err.message); process.exit(1); } const devMiddleWare = require('webpack-dev-middleware')(compiler, { publicPath: webpackConfig.output.publicPath, - quiet: true, + logLevel: 'warn', inline: true, headers: { 'Access-Control-Allow-Origin': '*', @@ -45,7 +45,7 @@ const devMiddleWare = require('webpack-dev-middleware')(compiler, { app.use(devMiddleWare); app.use( require('webpack-hot-middleware')(compiler, { - log: console.log + log: console.info }) ); diff --git a/webpack_config/makeConfig.js b/webpack_config/makeConfig.js new file mode 100644 index 00000000..843d75ab --- /dev/null +++ b/webpack_config/makeConfig.js @@ -0,0 +1,321 @@ +'use strict'; +const path = require('path'); +const webpack = require('webpack'); +const threadLoader = require('thread-loader'); + +const HtmlWebpackPlugin = require('html-webpack-plugin'); +const CopyWebpackPlugin = require('copy-webpack-plugin'); +const FriendlyErrorsPlugin = require('friendly-errors-webpack-plugin'); +const AutoDllPlugin = require('autodll-webpack-plugin'); +const HardSourceWebpackPlugin = require('hard-source-webpack-plugin'); +const FaviconsWebpackPlugin = require('favicons-webpack-plugin'); +const ExtractTextPlugin = require('extract-text-webpack-plugin'); +const ProgressPlugin = require('webpack/lib/ProgressPlugin'); +const BabelMinifyPlugin = require('babel-minify-webpack-plugin'); +const SriPlugin = require('webpack-subresource-integrity'); +const ClearDistPlugin = require('./plugins/clearDist'); +const SortCachePlugin = require('./plugins/sortCache'); + +const config = require('./config'); + +const DEFAULT_OPTIONS = { + isProduction: false, + isElectronBuild: false, + isHTMLBuild: false, + outputDir: '' +}; + +module.exports = function(opts = {}) { + const options = Object.assign({}, DEFAULT_OPTIONS, opts); + const isDownloadable = options.isHTMLBuild || options.isElectronBuild; + + // ==================== + // ====== Entry ======= + // ==================== + const entry = { + client: './common/index.tsx' + }; + + if (options.isProduction) { + entry.vendor = config.vendorModules; + } + + // ==================== + // ====== Rules ======= + // ==================== + const rules = []; + + // Typescript + if (options.isProduction || !process.env.SLOW_BUILD_SPEED) { + rules.push(config.typescriptRule); + } + else { + threadLoader.warmup( + config.typescriptRule.use[0].options, + [config.typescriptRule.use[0].loader] + ); + rules.push({ + ...config.typescriptRule, + use: [{ + loader: 'thread-loader', + options: { + workers: 4 + } + }, ...config.typescriptRule.use], + }); + } + + // Styles (CSS, SCSS, LESS) + if (options.isProduction) { + rules.push({ + test: /\.css$/, + use: ExtractTextPlugin.extract({ + fallback: 'style-loader', + use: 'css-loader' + }) + }, { + test: /\.scss$/, + use: ExtractTextPlugin.extract({ + fallback: 'style-loader', + use: ['css-loader', 'sass-loader'] + }) + }, { + test: /\.less$/, + use: ExtractTextPlugin.extract({ + fallback: 'style-loader', + use: ['css-loader', 'less-loader'] + }) + }); + } else { + rules.push({ + test: /\.css$/, + include: path.resolve(config.path.src, 'vendor'), + use: ['style-loader', 'css-loader'] + }, { + test: /\.scss$/, + include: ['components', 'containers', 'sass'] + .map(dir => path.resolve(config.path.src, dir)) + .concat([config.path.modules]), + + exclude: /node_modules(?!\/font-awesome)/, + use: ['style-loader', 'css-loader', 'sass-loader'] + }, { + test: /\.less$/, + include: path.resolve(config.path.assets, 'styles'), + use: ['style-loader', 'css-loader', 'less-loader'] + }); + } + + // Web workers + rules.push({ + test: /\.worker\.js$/, + loader: 'worker-loader' + }); + + // Images + rules.push({ + include: [ + path.resolve(config.path.assets), + path.resolve(config.path.modules) + ], + exclude: /node_modules(?!\/font-awesome)/, + test: /\.(gif|png|jpe?g|svg)$/i, + use: [ + { + loader: 'file-loader', + options: { + hash: 'sha512', + digest: 'hex', + name: '[path][name].[ext]?[hash:6]' + } + }, + { + loader: 'image-webpack-loader', + options: { + bypassOnDebug: true, + optipng: { + optimizationLevel: 4 + }, + gifsicle: { + interlaced: false + }, + mozjpeg: { + quality: 80 + }, + svgo: { + plugins: [{ removeViewBox: true }, { removeEmptyAttrs: false }, { sortAttrs: true }] + } + } + } + ] + }); + + // Fonts + rules.push({ + include: [ + path.resolve(config.path.assets), + path.resolve(config.path.modules) + ], + exclude: /node_modules(?!\/font-awesome)/, + test: /\.(ico|eot|otf|webp|ttf|woff|woff2)(\?.*)?$/, + loader: 'file-loader' + }); + + // ==================== + // ====== Plugins ===== + // ==================== + const plugins = [ + new HtmlWebpackPlugin({ + title: config.title, + template: path.resolve(config.path.src, 'index.html'), + inject: true + }), + + new CopyWebpackPlugin([ + { + from: config.path.static, + // to the root of dist path + to: './' + } + ]), + + new webpack.LoaderOptionsPlugin({ + minimize: options.isProduction, + debug: !options.isProduction, + options: { + // css-loader relies on context + context: process.cwd() + } + }), + + new webpack.DefinePlugin({ + 'process.env.NODE_ENV': JSON.stringify(options.isProduction ? 'production' : 'development'), + 'process.env.BUILD_DOWNLOADABLE': JSON.stringify(isDownloadable), + 'process.env.BUILD_HTML': JSON.stringify(options.isHTMLBuild), + 'process.env.BUILD_ELECTRON': JSON.stringify(options.isElectronBuild) + }), + ]; + + if (options.isProduction) { + plugins.push( + new BabelMinifyPlugin({ + // Mangle seems to be reusing variable identifiers, causing errors + mangle: false, + // These two on top of a lodash file are causing illegal characters for + // safari and ios browsers + evaluate: false, + propertyLiterals: false, + }, { + comments: false + }), + new webpack.optimize.CommonsChunkPlugin({ + name: 'vendor', + filename: 'vendor.[chunkhash:8].js' + }), + new ExtractTextPlugin('[name].[chunkhash:8].css'), + new FaviconsWebpackPlugin({ + logo: path.resolve(config.path.static, 'favicon/android-chrome-384x384.png'), + background: '#163151', + inject: true + }), + new SriPlugin({ + hashFuncNames: ['sha256', 'sha384'], + enabled: true + }), + new ProgressPlugin(), + new ClearDistPlugin(), + new SortCachePlugin() + ) + } + else { + plugins.push( + new AutoDllPlugin({ + inject: true, // will inject the DLL bundles to index.html + filename: '[name]_[hash].js', + debug: true, + context: path.join(config.path.root), + entry: { + vendor: [ + ...config.vendorModules, + 'babel-polyfill', + 'bootstrap-sass', + 'font-awesome' + ] + } + }), + new HardSourceWebpackPlugin({ + environmentHash: { + root: process.cwd(), + directories: ['webpack_config'], + files: ['package.json'] + } + }), + new webpack.HotModuleReplacementPlugin(), + new webpack.NoEmitOnErrorsPlugin(), + new FriendlyErrorsPlugin() + ); + } + + if (options.isElectronBuild) { + // target: 'electron-renderer' kills scrypt, so manually pull in some + // of its configuration instead + plugins.push(new webpack.ExternalsPlugin("commonjs", [ + "desktop-capturer", + "electron", + "ipc", + "ipc-renderer", + "remote", + "web-frame", + "clipboard", + "crash-reporter", + "native-image", + "screen", + "shell" + ])); + } + + // ==================== + // ====== DevTool ===== + // ==================== + let devtool = false; + if (!options.isProduction) { + if (process.env.SLOW_BUILD_SPEED) { + devtool = 'source-map'; + } + else { + devtool = 'cheap-module-eval-source-map'; + } + } + + // ==================== + // ====== Output ====== + // ==================== + const output = { + path: path.resolve(config.path.output, options.outputDir), + filename: options.isProduction ? '[name].[chunkhash:8].js' : '[name].js', + publicPath: isDownloadable && options.isProduction ? './' : '/', + crossOriginLoading: 'anonymous' + } + + + // The final bundle + return { + entry, + output, + module: { rules }, + plugins, + target: 'web', + resolve: config.resolve, + performance: { + hints: options.isProduction ? 'warning' : false + }, + stats: { + // Reduce build output + children: false, + chunks: false, + chunkModules: false, + chunkOrigins: false, + modules: false + } + }; +} diff --git a/webpack_config/plugins/clearDist.js b/webpack_config/plugins/clearDist.js new file mode 100644 index 00000000..9f222347 --- /dev/null +++ b/webpack_config/plugins/clearDist.js @@ -0,0 +1,11 @@ +'use strict'; +const rimraf = require('rimraf'); + +function ClearDistPlugin() {}; +ClearDistPlugin.prototype.apply = function(compiler) { + compiler.plugin('before-run', (params, done) => { + rimraf(params.outputPath, () => done()); + }); +}; + +module.exports = ClearDistPlugin; diff --git a/webpack_config/plugins/delay.js b/webpack_config/plugins/delay.js new file mode 100644 index 00000000..5873b21b --- /dev/null +++ b/webpack_config/plugins/delay.js @@ -0,0 +1,15 @@ +'use strict'; + +const DelayPlugin = function(delayMs) { + this.delayMs = delayMs; +}; + +DelayPlugin.prototype.apply = function(compiler) { + compiler.plugin('before-run', (compiler, done) => { + setTimeout(() => { + done(); + }, this.delayMs); + }); +}; + +module.exports = DelayPlugin; diff --git a/webpack_config/log-plugin.js b/webpack_config/plugins/serverLog.js similarity index 100% rename from webpack_config/log-plugin.js rename to webpack_config/plugins/serverLog.js diff --git a/webpack_config/plugins/sortCache.js b/webpack_config/plugins/sortCache.js new file mode 100644 index 00000000..be77fcd7 --- /dev/null +++ b/webpack_config/plugins/sortCache.js @@ -0,0 +1,32 @@ +// Makes for a deterministic cache file by sorting it +'use strict'; +const fs = require('fs'); +const klawSync = require('klaw-sync'); + +const CACHE_FILE_REGEX = /.*icons-[a-z0-9]*\/\.cache$/; +const findCacheFile = item => CACHE_FILE_REGEX.test(item.path); + +function SortCachePlugin() {}; +SortCachePlugin.prototype.apply = function(compiler) { + compiler.plugin('done', (stats) => { + const buildDir = stats.compilation.compiler.outputPath; + const cacheFilePaths = klawSync(buildDir, { filter: findCacheFile }); + + if (!cacheFilePaths.length) { + throw new Error('Could not find .cache file'); + } + if (cacheFilePaths.length > 1) { + throw new Error('More than one possible .cache file detected'); + } + + const cacheFilePath = cacheFilePaths[0].path; + const rawCacheFile = fs.readFileSync(cacheFilePath, 'utf8'); + const cache = JSON.parse(rawCacheFile); + + cache.result.files = cache.result.files.sort(); + + fs.writeFileSync(cacheFilePath, JSON.stringify(cache), 'utf8'); + }); +}; + +module.exports = SortCachePlugin; diff --git a/webpack_config/utils.js b/webpack_config/utils.js deleted file mode 100644 index d3ecc8ce..00000000 --- a/webpack_config/utils.js +++ /dev/null @@ -1,28 +0,0 @@ -'use strict'; -const path = require('path'); -const config = require('./config'); - -const _ = (module.exports = {}); - -_.cwd = file => { - return path.join(process.cwd(), file || ''); -}; - -_.outputPath = path.join(__dirname, '../dist'); - -_.outputIndexPath = path.join(__dirname, '../dist/index.html'); - -_.target = 'web'; - -_.loadersOptions = () => { - const isProd = process.env.NODE_ENV === 'production'; - - return { - minimize: isProd, - debug: !isProd, - options: { - // css-loader relies on context - context: process.cwd() - } - }; -}; diff --git a/webpack_config/webpack.base.js b/webpack_config/webpack.base.js deleted file mode 100644 index a5e78758..00000000 --- a/webpack_config/webpack.base.js +++ /dev/null @@ -1,115 +0,0 @@ -'use strict'; -const path = require('path'); -const webpack = require('webpack'); -const HtmlWebpackPlugin = require('html-webpack-plugin'); -const CopyWebpackPlugin = require('copy-webpack-plugin'); -const FaviconsWebpackPlugin = require('favicons-webpack-plugin'); -const SriPlugin = require('webpack-subresource-integrity'); - -const config = require('./config'); -const _ = require('./utils'); - -const webpackConfig = { - entry: { - client: './common/index.tsx' - }, - output: { - path: _.outputPath, - filename: '[name].js', - publicPath: config.publicPath, - crossOriginLoading: "anonymous" - }, - resolve: { - extensions: ['.ts', '.tsx', '.js', '.css', '.json', '.scss', '.less'], - modules: [ - // places where to search for required modules - config.srcPath, - _.cwd('node_modules'), - _.cwd('./') - ] - }, - module: { - rules: [ - { - test: /\.(ts|tsx)$/, - include: path.resolve(__dirname, '../common'), - use: [{ loader: 'ts-loader', options: { happyPackMode: true, logLevel: 'info' } }], - exclude: ['assets', 'sass', 'vendor', 'translations/lang'] - .map(dir => path.resolve(__dirname, `../common/${dir}`)) - .concat([path.resolve(__dirname, '../node_modules')]) - }, - { - test: /\.worker\.js$/, - loader: 'worker-loader' - }, - { - include: [ - path.resolve(__dirname, '../common/assets'), - path.resolve(__dirname, '../node_modules') - ], - exclude: /node_modules(?!\/font-awesome)/, - test: /\.(gif|png|jpe?g|svg)$/i, - use: [ - { - loader: 'file-loader', - options: { - hash: 'sha512', - digest: 'hex', - name: '[path][name].[ext]?[hash:6]' - } - }, - { - loader: 'image-webpack-loader', - options: { - bypassOnDebug: true, - optipng: { - optimizationLevel: 4 - }, - gifsicle: { - interlaced: false - }, - mozjpeg: { - quality: 80 - }, - svgo: { - plugins: [{ removeViewBox: true }, { removeEmptyAttrs: false }, { sortAttrs: true }] - } - } - } - ] - }, - { - include: [ - path.resolve(__dirname, '../common/assets'), - path.resolve(__dirname, '../node_modules') - ], - exclude: /node_modules(?!\/font-awesome)/, - test: /\.(ico|eot|otf|webp|ttf|woff|woff2)(\?.*)?$/, - loader: 'file-loader' - } - ] - }, - plugins: [ - new SriPlugin({ - hashFuncNames: ['sha256', 'sha384'], - enabled: true - }), - new HtmlWebpackPlugin({ - title: config.title, - template: path.resolve(__dirname, '../common/index.html'), - inject: true, - filename: _.outputIndexPath - }), - new CopyWebpackPlugin([ - { - from: _.cwd('./static'), - // to the root of dist path - to: './' - } - ]), - - new webpack.LoaderOptionsPlugin(_.loadersOptions()) - ], - target: _.target -}; -module.exports = webpackConfig; diff --git a/webpack_config/webpack.derivation-checker.js b/webpack_config/webpack.derivation-checker.js index d87d8c1d..68148f51 100644 --- a/webpack_config/webpack.derivation-checker.js +++ b/webpack_config/webpack.derivation-checker.js @@ -1,22 +1,19 @@ // Compile derivation checker using the (mostly) same webpack config 'use strict'; -const baseConfig = require('./webpack.base'); +const path = require('path'); +const config = require('./config'); -const derivationConfig = Object.assign({}, baseConfig, { - // Remove the cruft we don't need - plugins: undefined, - target: undefined, - performance: undefined, - module: { - // Typescript loader - loaders: [baseConfig.module.loaders[0]] - }, - - // Point at derivation checker, make sure it's setup to run in node +const derivationConfig = { target: 'node', - entry: { - 'derivation-checker': './common/derivation-checker.ts' - } -}); + entry: './common/derivation-checker.ts', + output: { + path: config.path.output, + filename: 'derivation-checker.js' + }, + module: { + rules: [config.typescriptRule], + }, + resolve: config.resolve, +}; module.exports = derivationConfig; diff --git a/webpack_config/webpack.dev.js b/webpack_config/webpack.dev.js index dddcaf67..52405083 100644 --- a/webpack_config/webpack.dev.js +++ b/webpack_config/webpack.dev.js @@ -1,87 +1,7 @@ 'use strict'; -process.env.NODE_ENV = 'development'; -const path = require('path'); -const webpack = require('webpack'); -const base = require('./webpack.base'); -const FriendlyErrors = require('friendly-errors-webpack-plugin'); -const AutoDllPlugin = require('autodll-webpack-plugin'); -const config = require('./config'); -const HardSourceWebpackPlugin = require('hard-source-webpack-plugin'); -const threadLoader = require('thread-loader'); +const makeConfig = require('./makeConfig'); -const fullSourceMap = process.env.SLOW_BUILD_SPEED; -if (fullSourceMap) { - base.devtool = fullSourceMap ? 'source-map' : 'cheap-module-eval-source-map'; - - threadLoader.warmup( - { - // pool options, like passed to loader options - // must match loader options to boot the correct pool - happyPackMode: true, - logLevel: 'info' - }, - [ - // modules to load - // can be any module, i. e. - 'ts-loader' - ] - ); - base.module.rules[0].use.unshift({ - loader: 'thread-loader', - options: { - workers: 4 - } - }); -} - -base.performance = { hints: false }; - -base.module.rules.push( - { - test: /\.css$/, - include: path.resolve(__dirname, '../common/vendor'), - use: ['style-loader', 'css-loader'] - }, - { - test: /\.scss$/, - include: ['components', 'containers', 'sass'] - .map(dir => path.resolve(__dirname, `../common/${dir}`)) - .concat([path.resolve(__dirname, '../node_modules')]), - - exclude: /node_modules(?!\/font-awesome)/, - use: ['style-loader', 'css-loader', 'sass-loader'] - }, - { - test: /\.less$/, - include: path.resolve(__dirname, '../common/assets/styles'), - use: ['style-loader', 'css-loader', 'less-loader'] - } -); - -base.plugins.push( - new AutoDllPlugin({ - inject: true, // will inject the DLL bundles to index.html - filename: '[name]_[hash].js', - debug: true, - context: path.join(__dirname, '..'), - entry: { - vendor: [...config.vendor, 'babel-polyfill', 'bootstrap-sass', 'font-awesome'] - } - }), - new HardSourceWebpackPlugin({ - environmentHash: { - root: process.cwd(), - directories: ['webpack_config'], - files: ['package.json'] - } - }), - - new webpack.DefinePlugin({ - 'process.env.NODE_ENV': JSON.stringify('development') - }), - new webpack.HotModuleReplacementPlugin(), - new webpack.NoEmitOnErrorsPlugin(), - new FriendlyErrors() -); - -module.exports = base; +module.exports = makeConfig({ + isProduction: false, + isElectronBuild: !!process.env.BUILD_ELECTRON +}); diff --git a/webpack_config/webpack.electron-dev.js b/webpack_config/webpack.electron-dev.js new file mode 100644 index 00000000..ff1a4525 --- /dev/null +++ b/webpack_config/webpack.electron-dev.js @@ -0,0 +1,36 @@ +'use strict'; +const webpack = require('webpack'); +const path = require('path'); +const ClearDistPlugin = require('./plugins/clearDist'); +const config = require('./config'); +const makeConfig = require('./makeConfig'); + +const electronConfig = { + target: 'electron-main', + entry: { + main: path.join(config.path.electron, 'main/index.ts'), + preload: path.join(config.path.electron, 'preload.ts') + }, + module: { + rules: [config.typescriptRule] + }, + resolve: { + extensions: ['.ts', '.js', '.json'] + }, + output: { + filename: '[name].js', + path: path.resolve(config.path.output, 'electron-js') + }, + plugins: [ + new ClearDistPlugin(), + new webpack.DefinePlugin({ + 'process.env.NODE_ENV': JSON.stringify('development') + }), + ], + node: { + __dirname: false, + __filename: false + } +}; + +module.exports = electronConfig; diff --git a/webpack_config/webpack.electron-prod.js b/webpack_config/webpack.electron-prod.js new file mode 100644 index 00000000..c06d1d5a --- /dev/null +++ b/webpack_config/webpack.electron-prod.js @@ -0,0 +1,24 @@ +'use strict'; +const webpack = require('webpack'); +const path = require('path'); +const ClearDistPlugin = require('./plugins/clearDist'); +const DelayPlugin = require('./plugins/delay'); +const makeConfig = require('./makeConfig'); +const electronConfig = require('./webpack.electron-dev.js'); + +const jsConfig = makeConfig({ + isProduction: true, + isElectronBuild: true, + outputDir: 'electron-js' +}); + +// Redefine plugins with prod specific stuff +electronConfig.plugins = [ + new ClearDistPlugin(), + new webpack.DefinePlugin({ + 'process.env.NODE_ENV': JSON.stringify('production') + }), + new DelayPlugin(500) +]; + +module.exports = [electronConfig, jsConfig]; diff --git a/webpack_config/webpack.freezer.js b/webpack_config/webpack.freezer.js index 7fb20d0c..556aa51a 100644 --- a/webpack_config/webpack.freezer.js +++ b/webpack_config/webpack.freezer.js @@ -1,22 +1,19 @@ // Compile freezer using the (mostly) same webpack config 'use strict'; -const baseConfig = require('./webpack.base'); +const path = require('path'); +const config = require('./config'); -const freezerConfig = Object.assign({}, baseConfig, { - // Remove the cruft we don't need - plugins: undefined, - target: undefined, - performance: undefined, - module: { - // Typescript loader - loaders: [baseConfig.module.rules[0]] - }, - - // Point at freezer, make sure it's setup to run in node +const freezerConfig = { target: 'node', - entry: { - 'freezer': './common/freezer' - } -}); + entry: './common/freezer', + output: { + path: config.path.output, + filename: 'freezer.js' + }, + module: { + rules: [config.typescriptRule], + }, + resolve: config.resolve, +}; module.exports = freezerConfig; diff --git a/webpack_config/webpack.html.js b/webpack_config/webpack.html.js new file mode 100644 index 00000000..4b2e7fbd --- /dev/null +++ b/webpack_config/webpack.html.js @@ -0,0 +1,8 @@ +'use strict'; +const makeConfig = require('./makeConfig'); + +module.exports = makeConfig({ + isProduction: true, + isHTMLBuild: true, + outputDir: 'download' +}); diff --git a/webpack_config/webpack.prod.js b/webpack_config/webpack.prod.js index f13548cc..98a69630 100644 --- a/webpack_config/webpack.prod.js +++ b/webpack_config/webpack.prod.js @@ -1,101 +1,7 @@ 'use strict'; -process.env.NODE_ENV = 'production'; -const FaviconsWebpackPlugin = require('favicons-webpack-plugin'); -const path = require('path'); -const webpack = require('webpack'); -const ExtractTextPlugin = require('extract-text-webpack-plugin'); -const ProgressPlugin = require('webpack/lib/ProgressPlugin'); -const BabelMinifyPlugin = require('babel-minify-webpack-plugin'); -// const OfflinePlugin = require('offline-plugin') -const base = require('./webpack.base'); -const config = require('./config'); -const rimraf = require('rimraf'); -const distFolder = 'dist/'; +const makeConfig = require('./makeConfig'); -// Clear out build folder -rimraf.sync(distFolder, { rmdirSync: true }); - -base.devtool = false; -base.module.rules.push( - { - test: /\.css$/, - use: ExtractTextPlugin.extract({ - fallback: 'style-loader', - use: 'css-loader' - }) - }, - { - test: /\.scss$/, - use: ExtractTextPlugin.extract({ - fallback: 'style-loader', - use: ['css-loader', 'sass-loader'] - }) - }, - { - test: /\.less$/, - use: ExtractTextPlugin.extract({ - fallback: 'style-loader', - use: ['css-loader', 'less-loader'] - }) - } -); -// a white list to add dependencies to vendor chunk -base.entry.vendor = config.vendor; -// use hash filename to support long-term caching -base.output.filename = '[name].[chunkhash:8].js'; -// add webpack plugins -base.plugins.unshift( - new FaviconsWebpackPlugin({ - logo: path.resolve(__dirname, '../static/favicon/android-chrome-384x384.png'), - background: '#163151', - inject: true - }) -); - -base.plugins.push( - new ProgressPlugin(), - new ExtractTextPlugin('[name].[chunkhash:8].css'), - new webpack.DefinePlugin({ - 'process.env.BUILD_DOWNLOADABLE': JSON.stringify(!!process.env.BUILD_DOWNLOADABLE) - }), - new webpack.DefinePlugin({ - 'process.env.NODE_ENV': JSON.stringify('production') - }), - new BabelMinifyPlugin({ - // Mangle seems to be reusing variable identifiers, causing errors - mangle: false, - // These two on top of a lodash file are causing illegal characters for - // safari and ios browsers - evaluate: false, - propertyLiterals: false, - }, { - comments: false - }), - // extract vendor chunks - new webpack.optimize.CommonsChunkPlugin({ - name: 'vendor', - filename: 'vendor.[chunkhash:8].js' - }) - // For progressive web apps - // new OfflinePlugin({ - // relativePaths: false, - // AppCache: false, - // ServiceWorker: { - // events: true - // } - // }) -); - -// minimize webpack output -base.stats = { - // Add children information - children: false, - // Add chunk information (setting this to `false` allows for a less verbose output) - chunks: false, - // Add built modules information to chunk information - chunkModules: false, - chunkOrigins: false, - modules: false -}; - -module.exports = base; +module.exports = makeConfig({ + isProduction: true, + outputDir: 'prod' +});