├── .gitignore ├── resources ├── icon.png └── screenshot.png ├── .babelrc ├── src ├── helpers │ ├── file-manager.js │ ├── app_cache.js │ ├── window.js │ └── menu.js └── main.js ├── scripts ├── start.js └── remix-dl.js ├── webpack.config.js ├── README.md ├── shell.nix └── package.json /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | dist 4 | build 5 | -------------------------------------------------------------------------------- /resources/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/horizon-games/remix-app/HEAD/resources/icon.png -------------------------------------------------------------------------------- /resources/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/horizon-games/remix-app/HEAD/resources/screenshot.png -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "@babel/env", { 5 | "targets": { 6 | "browsers": "last 2 Chrome versions", 7 | "node": "current" 8 | } 9 | } 10 | ] 11 | ], 12 | "plugins": ["@babel/plugin-proposal-object-rest-spread"] 13 | } 14 | -------------------------------------------------------------------------------- /src/helpers/file-manager.js: -------------------------------------------------------------------------------- 1 | import { ipcMain, dialog, BrowserWindow } from 'electron'; 2 | import fs from 'fs'; 3 | 4 | ipcMain.on('save-file', (event, file) => { 5 | const win = BrowserWindow.getFocusedWindow() 6 | dialog.showSaveDialog(win, { 7 | title: 'Save solidity file', 8 | defaultPath: file.title, 9 | filters: [{ name: 'Solidity Smart Contract', extensions: ['sol'] }] 10 | }, (filePath) => { 11 | if(filePath){ 12 | fs.writeFile(filePath, file.content, (err) => { 13 | if(err) alert(err); 14 | }) 15 | } 16 | }); 17 | }); 18 | 19 | export { saveFile }; 20 | -------------------------------------------------------------------------------- /scripts/start.js: -------------------------------------------------------------------------------- 1 | const { spawnSync } = require('child_process') 2 | const childProcess = require('child_process') 3 | const webpack = require('webpack') 4 | const config = require('../webpack.config') 5 | const electron = require('electron') 6 | 7 | const env = 'development' 8 | const compiler = webpack(config(env)) 9 | 10 | let electronStarted = false 11 | 12 | const watching = compiler.watch({}, (err, stats) => { 13 | if (!err && !stats.hasErrors() && !electronStarted) { 14 | electronStarted = true 15 | 16 | childProcess 17 | .spawn(electron, ['.'], { stdio: 'inherit' }) 18 | .on('close', () => { 19 | watching.close() 20 | }) 21 | } 22 | }) 23 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const webpack = require('webpack') 3 | const nodeExternals = require('webpack-node-externals') 4 | 5 | module.exports = env => { 6 | return { 7 | target: 'node', 8 | node: { 9 | __dirname: false, 10 | __filename: false 11 | }, 12 | entry: { 13 | main: './src/main.js' 14 | }, 15 | output: { 16 | filename: '[name].js', 17 | path: path.resolve(__dirname, './build') 18 | }, 19 | externals: [ nodeExternals() ], 20 | resolve: { 21 | alias: { 22 | } 23 | }, 24 | module: { 25 | rules: [ 26 | { 27 | test: /\.js$/, 28 | exclude: /node_modules/, 29 | use: ['babel-loader'] 30 | } 31 | ] 32 | }, 33 | plugins: [ 34 | new webpack.DefinePlugin({ 35 | 'NODE_ENV': JSON.stringify(env) 36 | }) 37 | ] 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # remix remix-app 2 | 3 |
4 | 5 | This project packages Remix, the excellent Ethereum Solidity IDE, into 6 | a dedicated desktop application that runs from your local machine. Remix-app 7 | also caches solc compiler downloads so Remix is fully usable in airplane mode. 8 | 9 | screenshot 10 | 11 | ## Install or Build your own 12 | 13 | **To install a pre-built image, see:** https://github.com/horizon-games/remix-app/releases 14 | 15 | **To build your own:** 16 | 1. Clone this repo 17 | 2. `yarn install` 18 | 3. `yarn dist` 19 | 4. Find the distribution for your os in dist/ 20 | 21 | 22 | ## Related links 23 | 24 | * Remix online version: https://remix.ethereum.org 25 | * Remix runtime: https://github.com/ethereum/remix 26 | * Remix IDE: https://github.com/ethereum/browser-solidity 27 | 28 | 29 | ## Credits 30 | 31 | * Thanks to the amazing efforts by @yann300 + team for writing https://github.com/ethereum/remix 32 | * Thanks to @acrylix for contributing 33 | 34 | 35 | ## LICENSE 36 | 37 | MIT 38 | 39 | -------------------------------------------------------------------------------- /shell.nix: -------------------------------------------------------------------------------- 1 | { pkgs ? import {} }: 2 | 3 | # a simple x11 env 4 | (pkgs.buildFHSUserEnv { 5 | name = "fhs"; 6 | targetPkgs = pkgs: with pkgs; [ 7 | coreutils 8 | alsaLib 9 | atk 10 | at-spi2-atk 11 | at-spi2-core 12 | cairo 13 | cups 14 | dbus 15 | expat 16 | file 17 | fontconfig 18 | freetype 19 | gdb 20 | gdk_pixbuf 21 | git 22 | glib 23 | glibc 24 | gtk2 25 | gtk3 26 | libarchive 27 | libnotify 28 | libxml2 29 | libxslt 30 | libuuid 31 | mesa_glu 32 | pango 33 | rxvt_unicode.terminfo 34 | curl 35 | openal 36 | openssl_1_0_2 37 | netcat 38 | nspr 39 | nss 40 | strace 41 | udev 42 | watch 43 | wget 44 | which 45 | xorg.libXxf86vm 46 | xorg.libX11 47 | xorg.libXScrnSaver 48 | xorg.libXcomposite 49 | xorg.libXcursor 50 | xorg.libXdamage 51 | xorg.libXext 52 | xorg.libXfixes 53 | xorg.libXi 54 | xorg.libXrandr 55 | xorg.libXrender 56 | xorg.libXtst 57 | xorg.libxcb 58 | xorg.xcbutilkeysyms 59 | zlib 60 | zsh 61 | ]; 62 | runScript = "bash"; 63 | }).env 64 | -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import url from 'url' 3 | import { app, Menu } from 'electron' 4 | import createWindow from './helpers/window' 5 | import { buildMenu } from './helpers/menu' 6 | import { registerAppCache } from './helpers/app_cache' 7 | 8 | // This line will just execute the listeners for the saving file process 9 | // import './helpers/file-manager' 10 | 11 | let userDataPath = app.getPath('userData') 12 | if (NODE_ENV !== 'production') { 13 | userDataPath = `${userDataPath} (${NODE_ENV})` 14 | app.setPath('userData', userDataPath) 15 | } 16 | 17 | if (process.platform === 'darwin') { 18 | app.setAboutPanelOptions({ 19 | applicationName: 'remix-app', 20 | credits: 'Horizon Blockchain Games \n https://horizongames.net', 21 | copyright: 'Remix by https://github.com/ethereum/remix' 22 | }) 23 | } 24 | 25 | app.on('ready', () => { 26 | const mainWindow = createWindow('main', { 27 | width: 1024 28 | }) 29 | 30 | Menu.setApplicationMenu(Menu.buildFromTemplate(buildMenu(mainWindow))) 31 | 32 | // Register app cache on the main window's network stack 33 | registerAppCache(mainWindow, userDataPath) 34 | 35 | // Load the local static app 36 | mainWindow.loadURL( 37 | url.format({ 38 | pathname: path.join(__dirname, 'app/index.html'), 39 | protocol: 'file:', 40 | slashes: true 41 | }) 42 | ) 43 | 44 | // Work-around for electron/chrome 51+ onbeforeunload behavior 45 | // which prevents the app window to close if not invalidated. 46 | mainWindow.webContents.on('dom-ready', () => { 47 | mainWindow.webContents.executeJavaScript("window.onbeforeunload = null") 48 | }) 49 | }) 50 | 51 | app.on('window-all-closed', () => { 52 | app.quit() 53 | }) 54 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "remix", 3 | "version": "0.4.0", 4 | "description": "Remix Solidity IDE https://remix.ethereum.org - Electron Edition", 5 | "author": "Peter Kieltyka ", 6 | "credits": "Horizon Blockchain Games https://horizongames.net", 7 | "copyright": "Remix by https://github.com/ethereum/remix", 8 | "license": "MIT", 9 | "homepage": "https://github.com/horizon-games/remix-app", 10 | "main": "build/main.js", 11 | "build": { 12 | "appId": "net.horizongames.remix", 13 | "files": [ 14 | "build/**/*", 15 | "resources/**" 16 | ], 17 | "directories": { 18 | "buildResources": "resources" 19 | }, 20 | "win": { 21 | "target": [{ 22 | "target": "portable", 23 | "arch": ["x64", "ia32"] 24 | }] 25 | }, 26 | "mac": { 27 | "target": ["dmg"] 28 | }, 29 | "linux": { 30 | "target": ["tar.gz"] 31 | } 32 | }, 33 | "scripts": { 34 | "postinstall": "node scripts/remix-dl.js", 35 | "dev": "node scripts/start.js", 36 | "clean": "rimraf dist", 37 | "build": "webpack --mode production --color --config=webpack.config.js --env=production", 38 | "pack": "yarn dist:all --dir", 39 | "dist": "yarn build && electron-builder", 40 | "dist:all": "yarn dist -wml", 41 | "dist:windows": "yarn dist -w", 42 | "dist:mac": "yarn dist -m", 43 | "dist:linux": "yarn dist -l" 44 | }, 45 | "dependencies": { 46 | "electron-fetch": "1.3.0", 47 | "fs-jetpack": "2.2.2" 48 | }, 49 | "devDependencies": { 50 | "@babel/core": "7.5.4", 51 | "@babel/plugin-proposal-object-rest-spread": "7.5.4", 52 | "@babel/preset-env": "7.5.4", 53 | "babel-loader": "8.0.6", 54 | "electron": "5.0.6", 55 | "electron-builder": "20.44.4", 56 | "request": "^2.88.0", 57 | "rimraf": "2.6.3", 58 | "source-map-support": "^0.5.12", 59 | "unzipper": "0.10.1", 60 | "webpack": "4.35.3", 61 | "webpack-cli": "3.3.5", 62 | "webpack-node-externals": "1.7.2" 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/helpers/app_cache.js: -------------------------------------------------------------------------------- 1 | import { BrowserWindow, webContents } from 'electron' 2 | import fs from 'fs' 3 | import path from 'path' 4 | import jetpack from 'fs-jetpack' 5 | import fetch from 'electron-fetch' 6 | 7 | const registerAppCache = (browserWindow, userDataPath) => { 8 | 9 | // Attempt to fetch the list.json to determine if we're online 10 | // and the contents are available, in this case, we will always 11 | // fetch the latest list of available compilers, but fallback 12 | // to a local version if available. 13 | let getList = false 14 | const listURL = 'https://solc-bin.ethereum.org/bin/list.json' 15 | const resp = fetch(listURL) 16 | resp.then((resp) => { 17 | if (resp.status === 200) { 18 | getList = true 19 | } 20 | }).catch((err) => { 21 | getList = false 22 | }) 23 | 24 | const filter = { 25 | urls: ['https://solc-bin.ethereum.org/bin/*'] 26 | } 27 | 28 | const session = browserWindow.webContents.session 29 | 30 | session.webRequest.onBeforeRequest(filter, (details, callback) => { 31 | const resourceURL = details.url 32 | const resourceFilename = getFilename(resourceURL) 33 | const localFile = cachedFilePath(userDataPath, resourceURL) 34 | 35 | // Always fetch the new list.json if we're online 36 | if (getList && resourceFilename === 'list.json') { 37 | callback({ cancel: false }) 38 | return 39 | } 40 | 41 | // Serve locally cached files if they are available 42 | if (fs.existsSync(localFile)) { 43 | callback({ 44 | cancel: false, 45 | redirectURL: `file://${localFile}` 46 | }) 47 | } else { 48 | callback({ cancel: false }) 49 | } 50 | }) 51 | 52 | session.webRequest.onCompleted(filter, (details) => { 53 | const resourceURL = details.url 54 | 55 | const resp = fetch(resourceURL) 56 | 57 | resp.then((resp) => { 58 | if (resp.status === 200) { 59 | const filename = getFilename(resourceURL) 60 | const dest = fs.createWriteStream(cachedFilePath(userDataPath, resourceURL)) 61 | resp.body.pipe(dest) 62 | } 63 | }).catch((err) => {}) 64 | }) 65 | 66 | } 67 | 68 | const getFilename = (url) => { 69 | return url.split('/').pop() 70 | } 71 | 72 | const cachedFilePath = (userDataPath, url) => { 73 | return path.join(userDataPath, getFilename(url)) 74 | } 75 | 76 | export { registerAppCache } 77 | -------------------------------------------------------------------------------- /src/helpers/window.js: -------------------------------------------------------------------------------- 1 | import { app, BrowserWindow, screen } from 'electron' 2 | import jetpack from 'fs-jetpack' 3 | 4 | export default (name, options) => { 5 | const userDataDir = jetpack.cwd(app.getPath('userData')) 6 | const stateStoreFile = `window-state-${name}.json` 7 | const defaultSize = { 8 | width: options.width, 9 | height: options.height 10 | } 11 | let state = {} 12 | let win 13 | 14 | const restore = () => { 15 | let restoredState = {} 16 | try { 17 | restoredState = userDataDir.read(stateStoreFile, 'json') 18 | } catch (err) { 19 | // For some reason json can't be read (might be corrupted). 20 | // No worries, we have defaults. 21 | } 22 | return Object.assign({}, defaultSize, restoredState) 23 | } 24 | 25 | const getCurrentPosition = () => { 26 | const position = win.getPosition() 27 | const size = win.getSize() 28 | return { 29 | x: position[0], 30 | y: position[1], 31 | width: size[0], 32 | height: size[1] 33 | } 34 | } 35 | 36 | const windowWithinBounds = (windowState, bounds) => { 37 | return ( 38 | windowState.x >= bounds.x && 39 | windowState.y >= bounds.y && 40 | windowState.x + windowState.width <= bounds.x + bounds.width && 41 | windowState.y + windowState.height <= bounds.y + bounds.height 42 | ) 43 | } 44 | 45 | const resetToDefaults = () => { 46 | const bounds = screen.getPrimaryDisplay().bounds; 47 | return Object.assign({}, defaultSize, { 48 | x: (bounds.width - defaultSize.width) / 2, 49 | y: (bounds.height - defaultSize.height) / 2 50 | }) 51 | } 52 | 53 | const ensureVisibleOnSomeDisplay = windowState => { 54 | const visible = screen.getAllDisplays().some(display => { 55 | return windowWithinBounds(windowState, display.bounds); 56 | }) 57 | if (!visible) { 58 | // Window is partially or fully not visible now. 59 | // Reset it to safe defaults. 60 | return resetToDefaults() 61 | } 62 | return windowState 63 | } 64 | 65 | const saveState = () => { 66 | if (!win.isMinimized() && !win.isMaximized()) { 67 | Object.assign(state, getCurrentPosition()) 68 | } 69 | userDataDir.write(stateStoreFile, state, { atomic: true }) 70 | } 71 | 72 | state = ensureVisibleOnSomeDisplay(restore()) 73 | 74 | win = new BrowserWindow(Object.assign({}, options, state)) 75 | 76 | win.on('close', saveState) 77 | 78 | return win 79 | } 80 | -------------------------------------------------------------------------------- /scripts/remix-dl.js: -------------------------------------------------------------------------------- 1 | // remix-dl script downloads remix distribution to be packaged in 2 | // the electron app. 3 | const os = require('os') 4 | const request = require('request') 5 | const fs = require('fs') 6 | const path = require('path') 7 | const unzipper = require('unzipper') 8 | const rimraf = require('rimraf') 9 | 10 | const fetchLatestRemixDownloadURL = (cb) => { 11 | const repoListURL = 'https://api.github.com/repos/ethereum/remix-live/contents/?ref=gh-pages' 12 | const githubReq = { 13 | url: repoListURL, 14 | headers: { 15 | 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/65.0.3325.88 Safari/537.36' 16 | } 17 | } 18 | 19 | request.get(githubReq, (err, resp, body) => { 20 | if (err) { 21 | console.log('github error:', err) 22 | process.exit(1) 23 | } 24 | 25 | if (resp.statusCode !== 200) { 26 | console.log('github: failed to fetch remix download url, got status', resp.statusCode) 27 | process.exit(1) 28 | } 29 | 30 | const repo = JSON.parse(body) 31 | let downloadURL 32 | 33 | repo.forEach((obj) => { 34 | if (obj.name.match(/remix-[a-z0-9]+\.zip/i)) { 35 | downloadURL = obj.download_url 36 | } 37 | }) 38 | 39 | if (downloadURL) { 40 | cb(downloadURL) 41 | } else { 42 | console.log('github: failed to get latest remix download url') 43 | process.exit(1) 44 | } 45 | }) 46 | } 47 | 48 | const fetchRemixApp = (url, destPath) => { 49 | const ret = () => { 50 | const appDir = path.join(path.dirname(fs.realpathSync(__filename)), '../build/app') 51 | 52 | rimraf(appDir, () => { 53 | const reader = fs.createReadStream(destPath) 54 | reader.pipe(unzipper.Extract({ path: appDir })) 55 | fs.unlinkSync(destPath) 56 | }) 57 | } 58 | 59 | const err = (msg) => { 60 | console.error(msg) 61 | } 62 | 63 | const file = fs.createWriteStream(destPath) 64 | const sendReq = request.get(url) 65 | 66 | sendReq.on('response', (response) => { 67 | if (response.statusCode !== 200) { 68 | return err('Unable to download remix file, status: ' + response.statusCode) 69 | } 70 | }) 71 | 72 | sendReq.on('error', (err) => { 73 | fs.unlink(destPath) 74 | return err(err.message) 75 | }) 76 | 77 | sendReq.pipe(file) 78 | 79 | file.on('finish', () => { 80 | file.close(ret) 81 | }) 82 | 83 | file.on('error', (err) => { 84 | fs.unlink(destPath) 85 | return err(err.message) 86 | }) 87 | } 88 | 89 | 90 | 91 | fetchLatestRemixDownloadURL((downloadURL) => { 92 | console.log(`latest remix download url at ${downloadURL}, downloading & unpacking..`) 93 | 94 | const destPath = path.join(os.tmpdir(), 'remix.zip') 95 | if (fs.existsSync(destPath)) { 96 | fs.unlinkSync(destPath) 97 | } 98 | fetchRemixApp(downloadURL, destPath) 99 | }) 100 | -------------------------------------------------------------------------------- /src/helpers/menu.js: -------------------------------------------------------------------------------- 1 | import { app, shell, BrowserWindow, BrowserView, dialog } from 'electron' 2 | 3 | const pkgJson = require('../../package.json') 4 | 5 | function buildMenu(mainWindow) { 6 | let menu = [] 7 | 8 | 9 | if (process.platform === 'darwin') { 10 | menu.push({ 11 | label: app.getName(), 12 | submenu: [ 13 | { role: 'about' }, 14 | { type: 'separator' }, 15 | { role: 'services', submenu: [] }, 16 | { type: 'separator' }, 17 | { role: 'hide' }, 18 | { role: 'hideothers' }, 19 | { role: 'unhide' }, 20 | { type: 'separator' }, 21 | { role: 'quit' } 22 | ] 23 | }) 24 | } else { 25 | menu.push({ 26 | label: 'File', 27 | submenu: [ 28 | { role: 'quit' } 29 | ] 30 | }) 31 | } 32 | 33 | menu.push(...[ 34 | { 35 | label: 'Edit', 36 | submenu: [ 37 | { role: 'undo' }, 38 | { role: 'redo' }, 39 | { type: 'separator' }, 40 | { 41 | label: 'Save as...', 42 | accelerator: 'Shift+CmdOrCtrl+S', 43 | click: () => { 44 | const win = BrowserWindow.getFocusedWindow() 45 | // The data of the file will be always in localStorage and 46 | // we can get the title of the file by getting the active tab text 47 | win.webContents.executeJavaScript(` 48 | ((window) => { 49 | try { 50 | const title = window.document.getElementsByClassName('active')[0].childNodes[4].title; 51 | if(title === "Home" || !title) { 52 | alert('No file opened') 53 | } else { 54 | require('electron').ipcRenderer.send('save-file', {title, content: window.localStorage.getItem('sol:' + title)}) 55 | } 56 | } catch(err){ 57 | console.log(err) 58 | alert('No file opened') 59 | } 60 | })(window) 61 | `); 62 | } 63 | }, 64 | { type: 'separator' }, 65 | { role: 'cut' }, 66 | { role: 'copy' }, 67 | { role: 'paste' }, 68 | { role: 'selectall' } 69 | ] 70 | }, 71 | { 72 | role: 'window', 73 | submenu: [ 74 | { 75 | label: 'Reload', 76 | accelerator: 'CmdOrCtrl+R', 77 | click: () => { 78 | const win = BrowserWindow.getFocusedWindow() 79 | dialog.showMessageBox( 80 | win, 81 | { 82 | type: 'warning', 83 | buttons: ['Cancel', 'Ok'], 84 | message: 'Do you want to reload the app?', 85 | detail: 'Changes that you made may not be saved' 86 | }, 87 | id => { 88 | // 1 is the index of Ok button, which is the confirmation of reload 89 | if(id === 1) { 90 | win.reload() 91 | } 92 | } 93 | ); 94 | } 95 | }, 96 | { role: 'forcereload' }, 97 | { role: 'minimize' }, 98 | { 99 | label: 'Close', 100 | accelerator: 'Cmd+W', 101 | click: () => { 102 | const win = BrowserWindow.getFocusedWindow() 103 | if (win === null) { 104 | if (mainWindow.isDevToolsFocused()) { 105 | mainWindow.closeDevTools() 106 | } 107 | } else { 108 | if (process.platform === 'darwin') { 109 | app.hide() 110 | } else { 111 | win.close() 112 | } 113 | } 114 | } 115 | } 116 | ] 117 | }, 118 | { 119 | label: 'Inspector', 120 | submenu: [ 121 | { 122 | label: 'Toggle DevTools', 123 | accelerator: 'Alt+CmdOrCtrl+I', 124 | click: () => { 125 | BrowserWindow.getFocusedWindow().toggleDevTools(); 126 | } 127 | } 128 | ] 129 | }, 130 | { 131 | role: 'help', 132 | submenu: [ 133 | { 134 | label: 'Learn More', 135 | click: () => { 136 | shell.openExternal(pkgJson.homepage) 137 | } 138 | } 139 | ] 140 | } 141 | ]) 142 | 143 | return menu 144 | } 145 | 146 | export { buildMenu } 147 | --------------------------------------------------------------------------------