├── .editorconfig ├── .github └── workflows │ ├── main.yml │ └── release.yml ├── .gitignore ├── .npmignore ├── LICENSE ├── README.md ├── babel.config.js ├── dist └── .keep ├── forge.config.js ├── package.json ├── screenshot2.png ├── screenshot3.png ├── src ├── browser-like-window.js ├── eth-provider-injected.js ├── eth-provider-preload.js ├── index.js ├── renderer │ ├── control.html │ ├── control.js │ ├── control.jsx │ ├── style.css │ └── useConnect.js └── web3-protocol.js ├── webpack.config.js └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | ; http://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | indent_style = space 7 | indent_size = 2 8 | end_of_line = lf 9 | charset = utf-8 10 | trim_trailing_whitespace = true 11 | insert_final_newline = true 12 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Build Distributions 2 | 3 | on: 4 | # Allows to run this workflow manually from the Actions tab 5 | workflow_dispatch: 6 | inputs: 7 | linux_artifact_name: 8 | description: 'Linux Artifact name' 9 | required: false 10 | default: 'linux-dist' 11 | windows_artifact_name: 12 | description: 'Windows Artifact name' 13 | required: false 14 | default: 'windows-dist' 15 | macos_artifact_name: 16 | description: 'MacOs Artifact name' 17 | required: false 18 | default: 'macos-dist' 19 | 20 | env: 21 | NODE_VERSION: 18 22 | # TODO - set GITHUB_TOKEN for publishing to GitHub 23 | 24 | jobs: 25 | 26 | build-linux: 27 | runs-on: ubuntu-latest 28 | 29 | steps: 30 | - name: Checkout 🛎️ 31 | uses: actions/checkout@v3.3.0 32 | 33 | - name: Set up Node.js ${{ env.NODE_VERSION }} 🔧 34 | uses: actions/setup-node@v3 35 | with: 36 | node-version: ${{ env.NODE_VERSION }} 37 | 38 | - name: Install dependencies 🔧 39 | run: yarn install 40 | 41 | - name: Build 🚀 42 | run: yarn make 43 | 44 | - name: Upload artifact 📦 45 | uses: actions/upload-artifact@v3 46 | with: 47 | name: ${{ github.event.inputs.linux_artifact_name }} 48 | path: ./out/make/zip 49 | 50 | 51 | build-windows: 52 | runs-on: windows-latest 53 | 54 | steps: 55 | - name: Checkout 🛎️ 56 | uses: actions/checkout@v3.3.0 57 | 58 | - name: Set up Node.js ${{ env.NODE_VERSION }} 🔧 59 | uses: actions/setup-node@v3 60 | with: 61 | node-version: ${{ env.NODE_VERSION }} 62 | 63 | - name: Install dependencies 🔧 64 | run: yarn install 65 | 66 | - name: Build 🚀 67 | run: yarn make 68 | 69 | - name: Upload artifact 📦 70 | uses: actions/upload-artifact@v3 71 | with: 72 | name: ${{ github.event.inputs.windows_artifact_name }} 73 | path: ./out/make/zip/ 74 | 75 | 76 | build-macos: 77 | runs-on: macos-latest 78 | 79 | steps: 80 | - name: Checkout 🛎️ 81 | uses: actions/checkout@v3.3.0 82 | 83 | - name: Set up Node.js ${{ env.NODE_VERSION }} 🔧 84 | uses: actions/setup-node@v3 85 | with: 86 | node-version: ${{ env.NODE_VERSION }} 87 | 88 | - name: Install dependencies 🔧 89 | run: yarn install 90 | 91 | - name: Build 🚀 92 | run: yarn make 93 | 94 | - name: Upload artifact 📦 95 | uses: actions/upload-artifact@v3 96 | with: 97 | name: ${{ github.event.inputs.macos_artifact_name }} 98 | path: ./out/make/zip/ 99 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Build and publish releases 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | workflow_dispatch: 8 | 9 | env: 10 | NODE_VERSION: 18 11 | 12 | jobs: 13 | publish-linux: 14 | runs-on: ubuntu-latest 15 | permissions: 16 | contents: write 17 | 18 | steps: 19 | - name: Checkout 20 | uses: actions/checkout@v3.3.0 21 | 22 | - name: Set up Node.js ${{ env.NODE_VERSION }} 23 | uses: actions/setup-node@v3 24 | with: 25 | node-version: ${{ env.NODE_VERSION }} 26 | 27 | - name: Install dependencies 28 | run: yarn install 29 | 30 | - name: Publish 31 | run: yarn run publish 32 | env: 33 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 34 | 35 | 36 | publish-windows: 37 | # Building on windows fails with 38 | # error An unexpected error occurred: "https://registry.yarnpkg.com/viem/-/viem-0.3.37.tgz: ESOCKETTIMEDOUT". 39 | # Since we are only making a zip, we don't need to be on windows, let make it on ubuntu 40 | # runs-on: windows-latest 41 | runs-on: ubuntu-latest 42 | permissions: 43 | contents: write 44 | 45 | steps: 46 | - name: Checkout 47 | uses: actions/checkout@v3.3.0 48 | 49 | - name: Set up Node.js ${{ env.NODE_VERSION }} 50 | uses: actions/setup-node@v3 51 | with: 52 | node-version: ${{ env.NODE_VERSION }} 53 | 54 | - name: Install system dependencies 55 | run: sudo apt-get install -y wine wine64 mono-devel 56 | 57 | - name: Install dependencies 58 | run: yarn install 59 | 60 | - name: Publish 61 | run: yarn run publish -p win32 62 | env: 63 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 64 | 65 | 66 | publish-macos: 67 | runs-on: macos-latest 68 | permissions: 69 | contents: write 70 | 71 | steps: 72 | - name: Checkout 73 | uses: actions/checkout@v3.3.0 74 | 75 | - name: Set up Node.js ${{ env.NODE_VERSION }} 76 | uses: actions/setup-node@v3 77 | with: 78 | node-version: ${{ env.NODE_VERSION }} 79 | 80 | - name: Install dependencies 81 | run: yarn install 82 | 83 | - name: Publish on universal 84 | run: yarn run publish -p darwin -a universal 85 | env: 86 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | control-compiled.js 2 | .cache/ 3 | dist/ 4 | # Logs 5 | logs 6 | *.log 7 | npm-debug.log* 8 | yarn-debug.log* 9 | yarn-error.log* 10 | 11 | # Runtime data 12 | pids 13 | *.pid 14 | *.seed 15 | *.pid.lock 16 | 17 | # Directory for instrumented libs generated by jscoverage/JSCover 18 | lib-cov 19 | 20 | # Coverage directory used by tools like istanbul 21 | coverage 22 | 23 | # nyc test coverage 24 | .nyc_output 25 | 26 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 27 | .grunt 28 | 29 | # Bower dependency directory (https://bower.io/) 30 | bower_components 31 | 32 | # node-waf configuration 33 | .lock-wscript 34 | 35 | # Compiled binary addons (https://nodejs.org/api/addons.html) 36 | build/Release 37 | 38 | # Dependency directories 39 | node_modules/ 40 | jspm_packages/ 41 | 42 | # TypeScript v1 declaration files 43 | typings/ 44 | 45 | # Optional npm cache directory 46 | .npm 47 | 48 | # Optional eslint cache 49 | .eslintcache 50 | 51 | # Optional REPL history 52 | .node_repl_history 53 | 54 | # Output of 'npm pack' 55 | *.tgz 56 | 57 | # Yarn Integrity file 58 | .yarn-integrity 59 | 60 | # dotenv environment variables file 61 | .env 62 | 63 | # next.js build output 64 | .next 65 | 66 | # IntelliJ project files 67 | .idea 68 | 69 | # Results of packaging are stored there 70 | out/ 71 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | docs/ 2 | example/ 3 | src/ 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 hui.liu 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # EVM Browser 2 | 3 | Web browser with support of the [ERC-4804 / ERC-6860 ``web3://`` protocol](https://docs.web3url.io/), which can show on-chain websites hosted on Ethereum and all others EVM chains. It includes support for the [Frame.sh](https://frame.sh/) wallet. 4 | 5 | ![./screenshot2.png](./screenshot2.png) 6 | 7 | As an example, ``web3://terraformnavigator.eth/`` is an on-chain website served by a [smart contract](https://etherscan.io/address/0xad41bf1c7f22f0ec988dac4c0ae79119cab9bb7e#code), which interacts with the [Terraform NFT contract](https://etherscan.io/address/0x4e1f41613c9084fdb9e34e11fae9412427480e56#code) : pages are generated dynamically, these are not static pages. 8 | 9 | The browser works out of the box with all chains (providers are given by [viem.sh](https://viem.sh/) and [chainid.network](https://chainid.network/)) and support the [Frame.sh](https://frame.sh/) wallet. The browser is forked from the great [electron-as-browser](https://github.com/hulufei/electron-as-browser) from hulufei. 10 | 11 | In the above example, clicking on a terraform will load a dynamic page, for example : 12 | 13 | ``web3://terraformnavigator.eth/view/9352`` 14 | 15 | ![./screenshot3.png](./screenshot3.png) 16 | 17 | More examples : 18 | 19 | ``web3://0x4E1f41613c9084FdB9E34E11fAE9412427480e56/tokenHTML/9352`` 20 | 21 | Call the ``tokenHTML`` method of ``0x4E1f41613c9084FdB9E34E11fAE9412427480e56``, and gives the uint 9352 as an argument. 22 | 23 | ``web3://0x4E1f41613c9084FdB9E34E11fAE9412427480e56/tokenSVG/9352?mime.type=svg`` 24 | 25 | Call the ``tokenSVG`` method of ``0x5a985f13345e820aa9618826b85f74c3986e1463``, gives the uint 9352 as an argument, and return the result as ``image/svg+xml``. 26 | 27 | ``web3://0xA5aFC9fE76a28fB12C60954Ed6e2e5f8ceF64Ff2/levelAndTile/2/50?returns=(uint256,uint256)`` 28 | 29 | Returns 2 numbers from this contract method, whose arguments are 2 and 50. The output will be casted as JSON : ``["0x1","0x24"]`` 30 | 31 | ``web3://usdc.eth/balanceOf/vitalik.eth?returns=(uint256)`` 32 | 33 | Call the ``balanceOf`` method of ``usdc.eth`` with ``vitalik.eth`` resolved to this address as an argument. 34 | 35 | See the [ ``web3://`` protocol documentation](https://docs.web3url.io/) for more infos. 36 | 37 | 38 | 39 | ## Wallet support 40 | 41 | EVM Browser also ships with [Frame.sh](https://frame.sh/) wallet and local node wallet support, which allows us to have a full read+write experience! 42 | 43 | This is thanks to [eth-provider](https://github.com/floating/eth-provider), which is exposed on ``window.ethereum`` 44 | 45 | ## Web3 domain support 46 | 47 | EVM Browser support ``.eth`` ENS domains and ``.og`` Linagee domains. 48 | 49 | 50 | ## Current limitations 51 | 52 | - web storage apis (localStorage, sessionStorage, webSQL, indexedDB, cookies) are disabled for now (see [progress in issue](https://github.com/nand2/evm-browser/issues/3)), due to a bug in electron. 53 | - Loading resources from blockchain with a chain id above 65536 (such as Sepolia) will fail. 54 | 55 | ## Usage 56 | 57 | `evm-browser` 58 | 59 | By default it will use the ethereum providers embedded with the [viem.sh](https://viem.sh) library. 60 | 61 | If you want to use your own web3 provider for mainnet : `evm-browser --chain-rpc 1=https://eth-mainnet.alchemyapi.io/v2/` 62 | 63 | Add or override multiple chains : `evm-browser --chain-rpc 42170=https://nova.arbitrum.io/rpc --chain-rpc 5=http://127.0.0.1:8545` 64 | 65 | Show the devtools : `evm-browser --debug` 66 | 67 | ## Install from source 68 | 69 | `yarn install` 70 | 71 | ## Usage from source 72 | 73 | `yarn start` 74 | 75 | If you want to use your local evm node for sepolia : `yarn start -- -- --chain-rpc 11155111=http://127.0.0.1:8545` (the ``-- --`` is nedded to skip electron-forge then yarn) 76 | 77 | ## Debugging 78 | 79 | All calls to ``web3://`` are returned with debugging headers, visible in the devtools, to help understand what is happening. 80 | 81 | - ``web3-nameservice-chainid`` The chain id where the domain name resolver was called. 82 | - ``web3-target-chainid`` After nameservice resolution, the chaid id where the actual call will happen. 83 | - ``web3-resolve-mode`` Indicate if the web3 call will be made in ``auto`` or ``manual`` mode (see EIP 4804 specs) 84 | - ``web3-auto-method`` If ``auto`` mode, the name of the smartcontract method that will be called. 85 | - ``web3-auto-args`` If ``auto`` mode, the types of the arguments that will be given to the smartcontract method. 86 | - ``web3-auto-return`` If ``auto`` mode, the types of the data returned by the smartcontract method. 87 | - ``web3-calldata`` If ``manual`` mode, the calldata sent to the contract. 88 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | const presets = [ 2 | [ 3 | '@babel/preset-env', 4 | { 5 | targets: { electron: 6 } 6 | } 7 | ], 8 | ['@babel/preset-react'] 9 | ]; 10 | 11 | module.exports = { presets }; 12 | -------------------------------------------------------------------------------- /dist/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/web3-protocol/evm-browser/ca32c6e5498bbb2b39526d1278c9cf6c66c9fd82/dist/.keep -------------------------------------------------------------------------------- /forge.config.js: -------------------------------------------------------------------------------- 1 | const webpack = require("webpack"); 2 | const webpackConfig = require("./webpack.config.js"); 3 | 4 | module.exports = { 5 | packagerConfig: {}, 6 | rebuildConfig: {}, 7 | makers: [ 8 | { 9 | name: '@electron-forge/maker-zip', 10 | platforms: ['linux', 'darwin', 'win32'], 11 | }, 12 | // { 13 | // name: '@electron-forge/maker-squirrel', 14 | // config: {}, 15 | // }, 16 | // { 17 | // name: '@electron-forge/maker-deb', 18 | // config: {}, 19 | // }, 20 | // { 21 | // name: '@electron-forge/maker-rpm', 22 | // config: {}, 23 | // }, 24 | ], 25 | publishers: [ 26 | { 27 | name: '@electron-forge/publisher-github', 28 | config: { 29 | repository: { 30 | owner: 'nand2', 31 | name: 'evm-browser' 32 | }, 33 | // prerelease: true 34 | draft: true 35 | } 36 | } 37 | ], 38 | hooks: { 39 | generateAssets: async (forgeConfig, platform, arch) => { 40 | 41 | // Run webpack 42 | await new Promise((resolve, reject) => { 43 | webpack(webpackConfig).run(async (err, stats) => { 44 | if (err) { 45 | return reject(err); 46 | } 47 | return resolve(stats); 48 | }); 49 | }) 50 | 51 | } 52 | } 53 | }; 54 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "evm-browser", 3 | "version": "0.2.6", 4 | "description": "web3:// web browser", 5 | "keywords": [ 6 | "browser", 7 | "web3" 8 | ], 9 | "main": "src/index.js", 10 | "author": "nand, hui.liu", 11 | "license": "MIT", 12 | "repository": { 13 | "type": "git", 14 | "url": "https://github.com/nand2/evm-browser" 15 | }, 16 | "scripts": { 17 | "start:control-dev": "npx babel --watch src/renderer/control.jsx --out-file src/renderer/control-compiled.js", 18 | "start:control": "npx babel src/renderer/control.jsx --out-file src/renderer/control-compiled.js", 19 | "start:webpack": "npx webpack", 20 | "start": "electron-forge start", 21 | "package": "electron-forge package", 22 | "make": "electron-forge make", 23 | "publish": "electron-forge publish" 24 | }, 25 | "dependencies": { 26 | "electron-log": "^3.0.7", 27 | "electron-squirrel-startup": "^1.0.0", 28 | "eth-provider": "^0.13.6", 29 | "file-url": "^2.0.2", 30 | "undici": "^5.20.0", 31 | "web3protocol": "0.6.2", 32 | "webpack": "^5.76.1", 33 | "webpack-cli": "^5.0.1", 34 | "yargs": "^17.7.1" 35 | }, 36 | "devDependencies": { 37 | "@babel/cli": "^7.23.0", 38 | "@babel/core": "7.23.3", 39 | "@babel/preset-env": "7.23.3", 40 | "@babel/preset-react": "^7.0.0", 41 | "@electron-forge/cli": "^6.1.1", 42 | "@electron-forge/maker-deb": "^6.1.1", 43 | "@electron-forge/maker-rpm": "^6.1.1", 44 | "@electron-forge/maker-squirrel": "6.4.2", 45 | "@electron-forge/maker-zip": "^6.1.1", 46 | "@electron-forge/publisher-github": "^6.1.1", 47 | "babel-loader": "^9.1.2", 48 | "classnames": "^2.2.6", 49 | "electron": "^29.0.0", 50 | "react": "^16.8.6", 51 | "react-dom": "^16.8.6" 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /screenshot2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/web3-protocol/evm-browser/ca32c6e5498bbb2b39526d1278c9cf6c66c9fd82/screenshot2.png -------------------------------------------------------------------------------- /screenshot3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/web3-protocol/evm-browser/ca32c6e5498bbb2b39526d1278c9cf6c66c9fd82/screenshot3.png -------------------------------------------------------------------------------- /src/browser-like-window.js: -------------------------------------------------------------------------------- 1 | const { BrowserWindow, BrowserView, ipcMain, app, protocol } = require('electron'); 2 | const EventEmitter = require('events'); 3 | const log = require('electron-log'); 4 | 5 | log.transports.file.level = false; 6 | log.transports.console.level = false; 7 | 8 | /** 9 | * @typedef {number} TabID 10 | * @description BrowserView's id as tab id 11 | */ 12 | 13 | /** 14 | * @typedef {object} Tab 15 | * @property {string} url - tab's url(address bar) 16 | * @property {string} href - tab's loaded page url(location.href) 17 | * @property {string} title - tab's title 18 | * @property {string} favicon - tab's favicon url 19 | * @property {boolean} isLoading 20 | * @property {boolean} canGoBack 21 | * @property {boolean} canGoForward 22 | */ 23 | 24 | /** 25 | * @typedef {Object.} Tabs 26 | */ 27 | 28 | /** 29 | * @typedef {object} Bounds 30 | * @property {number} x 31 | * @property {number} y 32 | * @property {number} width 33 | * @property {number} height 34 | */ 35 | 36 | /** 37 | * A browser like window 38 | * @param {object} options 39 | * @param {number} [options.width = 1024] - browser window's width 40 | * @param {number} [options.height = 800] - browser window's height 41 | * @param {string} options.controlPanel - control interface path to load 42 | * @param {number} [options.controlHeight = 130] - control interface's height 43 | * @param {object} [options.viewWebPreferences] - webReferences for every BrowserView 44 | * @param {object} [options.controlReferences] - webReferences for control panel BrowserView 45 | * @param {object} [options.winOptions] - options for BrowserWindow 46 | * @param {string} [options.startPage = ''] - start page to load on browser open 47 | * @param {string} [options.blankPage = ''] - blank page to load on new tab 48 | * @param {string} [options.blankTitle = 'about:blank'] - blank page's title 49 | * @param {function} [options.onNewWindow] - custom webContents `new-window` event handler 50 | * @param {boolean} [options.debug] - toggle debug 51 | */ 52 | class BrowserLikeWindow extends EventEmitter { 53 | constructor(options) { 54 | super(); 55 | 56 | this.options = options; 57 | const { 58 | width = 1300, 59 | height = 1000, 60 | winOptions = {}, 61 | controlPanel, 62 | controlReferences 63 | } = options; 64 | 65 | this.win = new BrowserWindow({ 66 | ...winOptions, 67 | width, 68 | height 69 | }); 70 | 71 | this.defCurrentViewId = null; 72 | this.defTabConfigs = {}; 73 | // Prevent browser views garbage collected 74 | this.views = {}; 75 | // keep order 76 | this.tabs = []; 77 | // ipc channel 78 | this.ipc = null; 79 | 80 | this.controlView = new BrowserView({ 81 | webPreferences: { 82 | contextIsolation: false, 83 | nodeIntegration: true, 84 | ...controlReferences 85 | } 86 | }); 87 | 88 | // BrowserView should add to window before setup 89 | this.win.addBrowserView(this.controlView); 90 | this.controlView.setBounds(this.getControlBounds()); 91 | this.controlView.setAutoResize({ width: true }); 92 | this.controlView.webContents.loadURL(controlPanel); 93 | 94 | const webContentsAct = actionName => { 95 | const webContents = this.currentWebContents; 96 | const action = webContents && webContents[actionName]; 97 | if (typeof action === 'function') { 98 | if (actionName === 'reload' && webContents.getURL() === '') return; 99 | action.call(webContents); 100 | log.debug( 101 | `do webContents action ${actionName} for ${this.currentViewId}:${webContents && 102 | webContents.getTitle()}` 103 | ); 104 | } else { 105 | log.error('Invalid webContents action ', actionName); 106 | } 107 | }; 108 | 109 | const channels = Object.entries({ 110 | 'control-ready': e => { 111 | this.ipc = e; 112 | 113 | this.newTab(this.options.startPage || ''); 114 | /** 115 | * control-ready event. 116 | * 117 | * @event BrowserLikeWindow#control-ready 118 | * @type {IpcMainEvent} 119 | */ 120 | this.emit('control-ready', e); 121 | }, 122 | 'url-change': (e, url) => { 123 | this.setTabConfig(this.currentViewId, { url }); 124 | }, 125 | 'url-enter': (e, url) => { 126 | this.loadURL(url); 127 | }, 128 | act: (e, actName) => webContentsAct(actName), 129 | 'new-tab': (e, url) => { 130 | log.debug('new-tab with url', url); 131 | this.newTab(url); 132 | }, 133 | 'switch-tab': (e, id) => { 134 | this.switchTab(id); 135 | }, 136 | 'close-tab': (e, id) => { 137 | log.debug('close tab ', { id, currentViewId: this.currentViewId }); 138 | this.closeTab(id) 139 | } 140 | }); 141 | 142 | channels 143 | .map(([name, listener]) => [ 144 | name, 145 | (e, ...args) => { 146 | // Support multiple BrowserLikeWindow 147 | if (this.controlView && e.sender === this.controlView.webContents) { 148 | log.debug(`Trigger ${name} from ${e.sender.id}`); 149 | listener(e, ...args); 150 | } 151 | } 152 | ]) 153 | .forEach(([name, listener]) => ipcMain.on(name, listener)); 154 | 155 | // On windows maximize, the browserviews are not updated automatically 156 | // cf https://github.com/electron/electron/issues/22174 157 | // So we add the below code just to handle the maximize window issue 158 | this.win.on('resize', () => { 159 | setTimeout(() => { 160 | this.setContentBounds(); 161 | this.controlView.setBounds(this.getControlBounds()); 162 | }, 0) 163 | }) 164 | 165 | /** 166 | * closed event 167 | * 168 | * @event BrowserLikeWindow#closed 169 | */ 170 | this.win.on('closed', () => { 171 | // Remember to clear all ipcMain events as ipcMain bind 172 | // on every new browser instance 173 | channels.forEach(([name, listener]) => ipcMain.removeListener(name, listener)); 174 | 175 | // Prevent BrowserView memory leak on close 176 | this.tabs.forEach(id => this.destroyView(id)); 177 | if (this.controlView) { 178 | this.controlView.webContents.destroy(); 179 | this.controlView = null; 180 | log.debug('Control view destroyed'); 181 | } 182 | this.emit('closed'); 183 | }); 184 | 185 | if (this.options.debug) { 186 | this.controlView.webContents.openDevTools({ mode: 'detach' }); 187 | log.transports.console.level = 'debug'; 188 | } 189 | 190 | 191 | } 192 | 193 | /** 194 | * Get control view's bounds 195 | * 196 | * @returns {Bounds} Bounds of control view(exclude window's frame) 197 | */ 198 | getControlBounds() { 199 | const contentBounds = this.win.getContentBounds(); 200 | return { 201 | x: 0, 202 | y: 0, 203 | width: contentBounds.width, 204 | height: this.options.controlHeight || 130 205 | }; 206 | } 207 | 208 | /** 209 | * Set web contents view's bounds automatically 210 | * @ignore 211 | */ 212 | setContentBounds() { 213 | const [contentWidth, contentHeight] = this.win.getContentSize(); 214 | const controlBounds = this.getControlBounds(); 215 | if (this.currentView) { 216 | this.currentView.setBounds({ 217 | x: 0, 218 | y: controlBounds.y + controlBounds.height, 219 | width: contentWidth, 220 | height: contentHeight - controlBounds.height 221 | }); 222 | } 223 | } 224 | 225 | get currentView() { 226 | return this.currentViewId ? this.views[this.currentViewId] : null; 227 | } 228 | 229 | get currentWebContents() { 230 | const { webContents } = this.currentView || {}; 231 | return webContents; 232 | } 233 | 234 | // The most important thing to remember about the get keyword is that it defines an accessor property, 235 | // rather than a method. So, it can’t have the same name as the data property that stores the value it accesses. 236 | get currentViewId() { 237 | return this.defCurrentViewId; 238 | } 239 | 240 | set currentViewId(id) { 241 | this.defCurrentViewId = id; 242 | this.setContentBounds(); 243 | if (this.ipc) { 244 | this.ipc.reply('active-update', id); 245 | } 246 | } 247 | 248 | get tabConfigs() { 249 | return this.defTabConfigs; 250 | } 251 | 252 | set tabConfigs(v) { 253 | this.defTabConfigs = v; 254 | if (this.ipc) { 255 | this.ipc.reply('tabs-update', { 256 | confs: v, 257 | tabs: this.tabs 258 | }); 259 | } 260 | } 261 | 262 | setTabConfig(viewId, kv) { 263 | const tab = this.tabConfigs[viewId]; 264 | const { webContents } = this.views[viewId] || {}; 265 | this.tabConfigs = { 266 | ...this.tabConfigs, 267 | [viewId]: { 268 | ...tab, 269 | canGoBack: webContents && webContents.canGoBack(), 270 | canGoForward: webContents && webContents.canGoForward(), 271 | ...kv 272 | } 273 | }; 274 | return this.tabConfigs; 275 | } 276 | 277 | loadURL(url) { 278 | const { currentView } = this; 279 | if (!url || !currentView) return; 280 | 281 | const { id, webContents } = currentView; 282 | 283 | // Prevent addEventListeners on same webContents when enter urls in same tab 284 | const MARKS = '__IS_INITIALIZED__'; 285 | if (webContents[MARKS]) { 286 | webContents.loadURL(url); 287 | return; 288 | } 289 | 290 | const onNewWindow = (e, newUrl, frameName, disposition, winOptions) => { 291 | log.debug('on new-window', { disposition, newUrl, frameName }); 292 | 293 | if (!new URL(newUrl).host) { 294 | // Handle newUrl = 'about:blank' in some cases 295 | log.debug('Invalid url open with default window'); 296 | return; 297 | } 298 | 299 | e.preventDefault(); 300 | 301 | if (disposition === 'new-window') { 302 | e.newGuest = new BrowserWindow(winOptions); 303 | } else if (disposition === 'foreground-tab') { 304 | this.newTab(newUrl, id); 305 | // `newGuest` must be setted to prevent freeze trigger tab in case. 306 | // The window will be destroyed automatically on trigger tab closed. 307 | e.newGuest = new BrowserWindow({ ...winOptions, show: false }); 308 | } else { 309 | this.newTab(newUrl, id); 310 | } 311 | }; 312 | 313 | webContents.on('new-window', this.options.onNewWindow || onNewWindow); 314 | 315 | // Keep event in order 316 | webContents 317 | .on('did-start-loading', () => { 318 | log.debug('did-start-loading > set loading'); 319 | this.setTabConfig(id, { isLoading: true }); 320 | }) 321 | .on('did-start-navigation', (e, href, isInPlace, isMainFrame) => { 322 | if (isMainFrame) { 323 | log.debug('did-start-navigation > set url address', { 324 | href, 325 | isInPlace, 326 | isMainFrame 327 | }); 328 | this.setTabConfig(id, { url: href, href }); 329 | /** 330 | * url-updated event. 331 | * 332 | * @event BrowserLikeWindow#url-updated 333 | * @return {BrowserView} view - current browser view 334 | * @return {string} href - updated url 335 | */ 336 | this.emit('url-updated', { view: currentView, href }); 337 | } 338 | }) 339 | .on('will-redirect', (e, href) => { 340 | log.debug('will-redirect > update url address', { href }); 341 | this.setTabConfig(id, { url: href, href }); 342 | this.emit('url-updated', { view: currentView, href }); 343 | }) 344 | .on('page-title-updated', (e, title) => { 345 | log.debug('page-title-updated', title); 346 | this.setTabConfig(id, { title }); 347 | }) 348 | .on('page-favicon-updated', (e, favicons) => { 349 | log.debug('page-favicon-updated', favicons); 350 | this.setTabConfig(id, { favicon: favicons[0] }); 351 | }) 352 | .on('did-stop-loading', () => { 353 | log.debug('did-stop-loading', { title: webContents.getTitle() }); 354 | this.setTabConfig(id, { isLoading: false }); 355 | }) 356 | .on('did-fail-load', (e, errorCode, errorDescription) => { 357 | log.debug('did-fail-load', errorCode, errorDescription) 358 | }) 359 | .on('dom-ready', () => { 360 | webContents.focus(); 361 | }); 362 | 363 | webContents.loadURL(url); 364 | webContents[MARKS] = true; 365 | 366 | this.setContentBounds(); 367 | 368 | if (this.options.debug) { 369 | webContents.openDevTools({ mode: 'detach' }); 370 | } 371 | } 372 | 373 | setCurrentView(viewId) { 374 | if (!viewId) return; 375 | if(this.currentView !== null) { 376 | this.win.removeBrowserView(this.currentView); 377 | } 378 | this.win.addBrowserView(this.views[viewId]); 379 | this.currentViewId = viewId; 380 | } 381 | 382 | /** 383 | * Create a tab 384 | * 385 | * @param {string} [url=this.options.blankPage] 386 | * @param {number} [appendTo] - add next to specified tab's id 387 | * @param {object} [references=this.options.viewWebPreferences] - custom webPreferences to this tab 388 | * 389 | * @fires BrowserLikeWindow#new-tab 390 | */ 391 | newTab(url, appendTo) { 392 | const view = new BrowserView({ 393 | webPreferences: { 394 | ...(this.options.viewWebPreferences) 395 | } 396 | }); 397 | 398 | view.id = view.webContents.id; 399 | 400 | if (appendTo) { 401 | const prevIndex = this.tabs.indexOf(appendTo); 402 | this.tabs.splice(prevIndex + 1, 0, view.id); 403 | } else { 404 | this.tabs.push(view.id); 405 | } 406 | this.views[view.id] = view; 407 | 408 | // Add to manager first 409 | const lastView = this.currentView; 410 | this.setCurrentView(view.id); 411 | view.setAutoResize({ width: true, height: true }); 412 | this.loadURL(url || this.options.blankPage); 413 | this.setTabConfig(view.id, { 414 | title: this.options.blankTitle || 'about:blank' 415 | }); 416 | /** 417 | * new-tab event. 418 | * 419 | * @event BrowserLikeWindow#new-tab 420 | * @return {BrowserView} view - current browser view 421 | * @return {string} [source.openedURL] - opened with url 422 | * @return {BrowserView} source.lastView - previous active view 423 | */ 424 | this.emit('new-tab', view, { openedURL: url, lastView }); 425 | return view; 426 | } 427 | 428 | /** 429 | * Swith to tab 430 | * @param {TabID} viewId 431 | */ 432 | switchTab(viewId) { 433 | log.debug('switch to tab', viewId); 434 | this.setCurrentView(viewId); 435 | this.currentView.webContents.focus(); 436 | } 437 | 438 | /** 439 | * Destroy tab 440 | * @param {TabID} viewId 441 | * @ignore 442 | */ 443 | destroyView(viewId) { 444 | const view = this.views[viewId]; 445 | if (view) { 446 | view.webContents.destroy(); 447 | this.views[viewId] = undefined; 448 | log.debug(`${viewId} destroyed`); 449 | } 450 | } 451 | 452 | closeTab(id) { 453 | if (id === this.currentViewId) { 454 | const removeIndex = this.tabs.indexOf(id); 455 | let nextIndex = 0; 456 | if(removeIndex < this.tabs.length - 1) { 457 | nextIndex = removeIndex + 1; 458 | } 459 | else if(removeIndex > 0) { 460 | nextIndex = removeIndex - 1; 461 | } 462 | this.setCurrentView(this.tabs[nextIndex]); 463 | } 464 | this.tabs = this.tabs.filter(v => v !== id); 465 | this.tabConfigs = { 466 | ...this.tabConfigs, 467 | [id]: undefined 468 | }; 469 | this.destroyView(id); 470 | 471 | if (this.tabs.length === 0) { 472 | this.newTab(); 473 | } 474 | } 475 | 476 | focusUrlBar() { 477 | if(this.ipc) { 478 | this.controlView.webContents.focus(); 479 | this.ipc.reply('focus-url-bar') 480 | } 481 | } 482 | } 483 | 484 | module.exports = BrowserLikeWindow; -------------------------------------------------------------------------------- /src/eth-provider-injected.js: -------------------------------------------------------------------------------- 1 | const provider = require('eth-provider') 2 | 3 | window.ethereum = provider(); -------------------------------------------------------------------------------- /src/eth-provider-preload.js: -------------------------------------------------------------------------------- 1 | const { ipcRenderer } = require('electron') 2 | 3 | window.onload = async function() { 4 | // Only inject eth-provider is there is a document body (e.g. not for SVGs) 5 | if(document.body == null) { 6 | return 7 | } 8 | 9 | // Fetch the JS of eth-provider being exposed on window.ethereum 10 | // (Cannot use file reading here) 11 | let webpackedScript = await ipcRenderer.invoke('getEthProviderJs') 12 | 13 | // Inject it in document 14 | var script = document.createElement("script"); 15 | var scriptText=document.createTextNode(webpackedScript) 16 | script.appendChild(scriptText); 17 | document.body.appendChild(script); 18 | 19 | // When loading an url returning a header "content-type: application/json", 20 | // electron generate some HTML to render the JSON. It includes 21 | // "" which has the weird effect of 22 | // making text white if the OS color scheme is dark. 23 | // Workaround : In dark mode, in
 tags (let's limit side effects), put back text as dark
24 |     // This should no affect normal pages, as by default text is dark even with OS color scheme being dark
25 |     document.head.insertAdjacentHTML("afterbegin", ``)
26 | };


--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
  1 | const { app, protocol, ipcMain } = require('electron');
  2 | const fileUrl = require('file-url');
  3 | 
  4 | const yargs = require("yargs");
  5 | const { fetch } = require("undici");
  6 | global.fetch = fetch;
  7 | const fs = require('fs')
  8 | 
  9 | const BrowserLikeWindow = require('./browser-like-window.js');
 10 | const { registerWeb3Protocol } = require('./web3-protocol.js')
 11 | 
 12 | let browser;
 13 | 
 14 | 
 15 | 
 16 | //
 17 | // Args processing
 18 | //
 19 | 
 20 | yargs
 21 |   .usage("evm-browser  [options]")
 22 |   .option('chain-rpc', {
 23 |     alias: 'wc',
 24 |     type: 'string',
 25 |     description: "Add/override a chain RPC\nFormat: = \nMultiple can be provided with multiple --chain-rpc use. Override existing chain settings. Examples:\n1=https://eth-mainnet.alchemyapi.io/v2/\n42170=https://nova.arbitrum.io/rpc\n 5=http://127.0.0.1:8545"
 26 |   })
 27 |   .option('chain-ens-registry', {
 28 |     alias: 'ens',
 29 |     type: 'string',
 30 |     requiresArg: true,
 31 |     description: "Add/override a chain ENS registry\nFormat: = \nCan be used multiple times. Examples:\n1=0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e\n 31337=0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512"
 32 |   })
 33 |   .option('enable-http-https', {
 34 |     alias: 'http',
 35 |     type: 'boolean',
 36 |     default: false,
 37 |     description: "Activate HTTP/HTTPS"
 38 |   })
 39 |   .option('debug', {
 40 |     type: 'boolean',
 41 |     // Activate by default for dev work
 42 |     default: app.isPackaged == false,
 43 |     description: "Show devtools windows, output debugging infos on console"
 44 |   })
 45 | let args = yargs.parse()
 46 | 
 47 | // Add/override chain definitions
 48 | let chainRpcOverrides = []
 49 | if(args.chainRpc) {
 50 |   if((args.chainRpc instanceof Array) == false) {
 51 |     args.chainRpc = [args.chainRpc]
 52 |   }
 53 | 
 54 |   args.chainRpc.map(newChain => newChain.split('=')).map(newChainComponents => {
 55 |     if(newChainComponents.length <= 1) {
 56 |       console.log("Chain format is invalid");
 57 |       process.exit(1)
 58 |     }
 59 |     let chainId = parseInt(newChainComponents[0]);
 60 |     if(isNaN(chainId) || chainId <= 0) {
 61 |       console.log("Chain id is invalid");
 62 |       process.exit(1)
 63 |     }
 64 |     let chainRpcUrl = newChainComponents.slice(1).join("=");
 65 | 
 66 |     chainRpcOverrides.push({
 67 |       id: chainId,
 68 |       rpcUrls: [chainRpcUrl]
 69 |     })
 70 |   })
 71 | }
 72 | 
 73 | // Add/override ENS registry address
 74 | let chainEnsOverrides = []
 75 | if(args.chainEnsRegistry) {
 76 |   if((args.chainEnsRegistry instanceof Array) == false) {
 77 |     args.chainEnsRegistry = [args.chainEnsRegistry]
 78 |   }
 79 | 
 80 |   args.chainEnsRegistry.map(newChain => newChain.split('=')).map(newChainComponents => {
 81 |     if(newChainComponents.length <= 1) {
 82 |       console.log("Chain format is invalid");
 83 |       process.exit(1)
 84 |     }
 85 |     let chainId = parseInt(newChainComponents[0]);
 86 |     if(isNaN(chainId) || chainId <= 0) {
 87 |       console.log("Chain id is invalid");
 88 |       process.exit(1)
 89 |     }
 90 |     let chainEnsRegistry = newChainComponents.slice(1).join("=");
 91 | 
 92 |     chainEnsOverrides.push({
 93 |       id: chainId,
 94 |       ensRegistry: chainEnsRegistry
 95 |     })
 96 |   })
 97 | }
 98 | 
 99 | 
100 | //
101 | // Main electron lifecycle
102 | //
103 | 
104 | function createWindow() {
105 |   browser = new BrowserLikeWindow({
106 |     controlHeight: 99,
107 |     controlPanel: fileUrl(`${__dirname}/renderer/control.html`),
108 |     startPage: args._.length == 1 ? args._[0] : 'web3://terraformnavigator.eth/',
109 |     blankTitle: 'New tab',
110 |     debug: args.debug, // will open controlPanel's devtools
111 |     winOptions: {
112 |       autoHideMenuBar: args.debug == false,
113 |     },
114 |     viewWebPreferences: {
115 |       sandbox: true,
116 |       preload: `${__dirname}/eth-provider-preload.js`,
117 |       // We need preload script working on iframes, but options are a bit not clear
118 |       // nodeIntegrationInSubFrames activates preload but seems to implies nodeIntegration, but
119 |       // in fact does not if sandbox=true (cf https://github.com/electron/electron/security/advisories/GHSA-mq8j-3h7h-p8g7 )
120 |       // So, with the mix contextIsolation=true (default) and sandbox=true, preload script will only have 
121 |       // a polyfilled subset of Node.js APIs available used for IPC with main process 
122 |       // (cf https://www.electronjs.org/docs/latest/tutorial/sandbox#sandbox-behavior-in-electron)
123 |       nodeIntegrationInSubFrames: true,
124 |     }
125 |   });
126 | 
127 |   browser.on('closed', () => {
128 |     browser = null;
129 |   });
130 | }
131 | 
132 | // Register the evm protocol as priviledged (authorize the fetch API)
133 | // Must be done before the app is ready
134 | protocol.registerSchemesAsPrivileged([
135 |   // Standard : Add fonctionalities, such as localstorage, but will break some calls such 
136 |   // as web3://0x5a985f13345e820aa9618826b85f74c3986e1463:5/tokenSVG/1.svg ; to be debugged
137 |   // { scheme: 'web3', privileges: { standard:true, supportFetchAPI: true, stream: true } },
138 |   { scheme: 'web3', privileges: { supportFetchAPI: true } }
139 | ])
140 | 
141 | app.on('ready', async () => {
142 |   // Enable web3://
143 |   await registerWeb3Protocol(chainRpcOverrides, chainEnsOverrides);
144 | 
145 |   // Disable HTTP/HTTPS if not explicitely enabled
146 |   // By default : web3:// only
147 |   if(args.enableHttpHttps == false) {
148 |     // Disable HTTPS
149 |     protocol.handle('https', (req) => {
150 |       return new Response('HTTPS support is disabled. Enable with the --enable-http-https start option.', {
151 |         status: 400,
152 |         headers: { 'content-type': 'text/html' }
153 |       })
154 |     });
155 | 
156 |     // Disable HTTP
157 |     protocol.handle('http', (req) => {
158 |       return new Response('HTTP support is disabled. Enable with the --enable-http-https start option.', {
159 |         status: 400,
160 |         headers: { 'content-type': 'text/html' }
161 |       })
162 |     });
163 |   }
164 | 
165 |   createWindow();
166 | });
167 | 
168 | // Quit when all windows are closed.
169 | app.on('window-all-closed', () => {
170 |   // On macOS it is common for applications and their menu bar
171 |   // to stay active until the user quits explicitly with Cmd + Q
172 |   if (process.platform !== 'darwin') {
173 |     app.quit();
174 |   }
175 | });
176 | 
177 | app.on('activate', () => {
178 |   // On macOS it's common to re-create a window in the app when the
179 |   // dock icon is clicked and there are no other windows open.
180 |   if (browser === null) {
181 |     createWindow();
182 |   }
183 | });
184 | 
185 | app.on('web-contents-created', function (event, wc) {
186 |   wc.on('before-input-event', function (event, input) {
187 |     if(input.type == 'keyDown' && browser) {
188 |       // On ctrl-L : focus the URL bar
189 |       if (input.key === 'l' && input.control && !input.alt && !input.meta && !input.shift) {
190 |         browser.focusUrlBar();
191 |         event.preventDefault()
192 |       }
193 |       // On Ctrl-T : new tab, focus URL bar
194 |       else if (input.key === 't' && input.control && !input.alt && !input.meta && !input.shift) {
195 |         browser.newTab();
196 |         browser.focusUrlBar();
197 |         event.preventDefault()
198 |       }
199 |       // On Ctrl-W : close tab
200 |       else if (input.key === 'w' && input.control && !input.alt && !input.meta && !input.shift) {
201 |         browser.closeTab(browser.currentViewId)
202 |         event.preventDefault()
203 |       }
204 |       // On Ctrl-PageUp : move tab
205 |       else if (input.key === 'PageDown' && input.control && !input.alt && !input.meta && !input.shift) {
206 |         let tabIndex = browser.tabs.indexOf(browser.currentViewId)
207 |         if(tabIndex < browser.tabs.length - 1) {
208 |           browser.switchTab(browser.tabs[tabIndex + 1])
209 |         }
210 |         event.preventDefault()
211 |       }
212 |       // On Ctrl-PageDown : move tab
213 |       else if (input.key === 'PageUp' && input.control && !input.alt && !input.meta && !input.shift) {
214 |         let tabIndex = browser.tabs.indexOf(browser.currentViewId)
215 |         if(tabIndex > 0) {
216 |           browser.switchTab(browser.tabs[tabIndex - 1])
217 |         }
218 |         event.preventDefault()
219 |       }
220 |     }
221 |   })
222 | })
223 | 
224 | 
225 | 
226 | // Expose a JS file to inject in pages, that will populate window.ethereum with
227 | // https://github.com/floating/eth-provider, allowing the webpages to connect
228 | // to the Frame.sh wallet or local ethereum nodes, using the standard EIP-1193 way
229 | ipcMain.handle('getEthProviderJs', () => 
230 |     fs.readFileSync(`${__dirname}/../dist/eth-provider-injected.packed.js`).toString()
231 | )
232 | 
233 | 


--------------------------------------------------------------------------------
/src/renderer/control.html:
--------------------------------------------------------------------------------
 1 | 
 2 | 
 3 | 
 4 |   
 5 |   
 6 |   
 7 |   
 8 | 
 9 | 
10 |   
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/renderer/control.js: -------------------------------------------------------------------------------- 1 | const { ipcRenderer } = require('electron'); 2 | 3 | // Used in Renderer process 4 | 5 | /** 6 | * Tell browser view to load url 7 | * @param {string} url 8 | */ 9 | const sendEnterURL = url => ipcRenderer.send('url-enter', url); 10 | 11 | /** 12 | * Tell browser view url in address bar changed 13 | * @param {string} url 14 | */ 15 | const sendChangeURL = url => ipcRenderer.send('url-change', url); 16 | 17 | const sendAct = actName => { 18 | ipcRenderer.send('act', actName); 19 | }; 20 | 21 | /** 22 | * Tell browser view to goBack 23 | */ 24 | const sendGoBack = () => sendAct('goBack'); 25 | 26 | /** 27 | * Tell browser view to goForward 28 | */ 29 | const sendGoForward = () => sendAct('goForward'); 30 | 31 | // Tell browser view to reload 32 | const sendReload = () => sendAct('reload'); 33 | 34 | // Tell browser view to stop load 35 | const sendStop = () => sendAct('stop'); 36 | 37 | /** 38 | * Tell browser view to close tab 39 | * @param {TabID} id 40 | */ 41 | const sendCloseTab = id => ipcRenderer.send('close-tab', id); 42 | 43 | /** 44 | * Create a new tab 45 | * @param {string} [url] 46 | * @param {object} [references] 47 | */ 48 | const sendNewTab = (url, references) => ipcRenderer.send('new-tab', url); 49 | 50 | /** 51 | * Tell browser view to switch to specified tab 52 | * @param {TabID} id 53 | */ 54 | const sendSwitchTab = id => ipcRenderer.send('switch-tab', id); 55 | 56 | module.exports = { 57 | sendEnterURL, // sendEnterURL(url) to load url 58 | sendChangeURL, // sendChangeURL(url) on addressbar input change 59 | sendGoBack, 60 | sendGoForward, 61 | sendReload, 62 | sendStop, 63 | sendNewTab, // sendNewTab([url]) 64 | sendSwitchTab, // sendSwitchTab(toID) 65 | sendCloseTab // sendCloseTab(id) 66 | }; 67 | -------------------------------------------------------------------------------- /src/renderer/control.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import cx from 'classnames'; 4 | import useConnect from './useConnect'; 5 | import * as action from './control'; 6 | 7 | const IconLoading = () => ( 8 | 13 | 14 | 15 | 16 | 17 | 24 | 25 | 26 | 27 | 28 | ); 29 | 30 | const IconClose = () => ( 31 | 42 | ); 43 | 44 | const IconPlus = () => ( 45 | 57 | ); 58 | 59 | const IconReload = () => ( 60 | 71 | ); 72 | 73 | const IconLeft = () => ( 74 | 85 | ); 86 | 87 | const IconRight = () => ( 88 | 99 | ); 100 | 101 | function Control() { 102 | const { tabs, setTabs, tabIDs, activeID, urlInputRef } = useConnect(); 103 | 104 | const { canGoForward, canGoBack, isLoading } = tabs[activeID] || {}; 105 | 106 | const onUrlChange = e => { 107 | // Update URL locally 108 | const v = e.target.value; 109 | let tabsCopy = Object.assign({}, tabs); 110 | tabsCopy[activeID].url = v; 111 | setTabs(tabsCopy) 112 | 113 | // Disabled : this used to reset cursor position at end at each key stroke 114 | // // Sync to tab config 115 | // action.sendChangeURL(v); 116 | }; 117 | const onPressEnter = e => { 118 | if (e.keyCode !== 13) return; 119 | const v = e.target.value.trim(); 120 | if (!v) return; 121 | 122 | // If we don't have a protocol, add web3:// by default 123 | let href = v; 124 | if (!/^.*?:\/\//.test(v)) { 125 | // if(/^[a-z0-9-]+\.eth\/?/.test(v)) { 126 | href = `web3://${v}`; 127 | // } 128 | // else { 129 | // href = `http://${v}`; 130 | // } 131 | } 132 | 133 | // If we have a web3 url without the initial "/", add it 134 | // Otherwise break host relative links 135 | if (/^web3:\/\/[^\/]+$/.test(href)) { 136 | href = href + "/" 137 | } 138 | 139 | action.sendEnterURL(href); 140 | }; 141 | const close = (e, id) => { 142 | e.stopPropagation(); 143 | action.sendCloseTab(id); 144 | }; 145 | const newTab = () => { 146 | action.sendNewTab(); 147 | }; 148 | const switchTab = id => { 149 | action.sendSwitchTab(id); 150 | }; 151 | 152 | return ( 153 |
154 |
155 | <> 156 | {tabIDs.map(id => { 157 | // eslint-disable-next-line no-shadow 158 | const { title, isLoading, favicon } = tabs[id] || {}; 159 | return ( 160 |
switchTab(id)} 164 | > 165 | {isLoading ? : !!favicon && } 166 |
167 |
{title}
168 |
169 |
close(e, id)}> 170 | 171 |
172 |
173 | ); 174 | })} 175 | 176 | 177 | 178 | 179 |
180 |
181 |
182 |
183 |
187 | 188 |
189 |
193 | 194 |
195 |
196 | {isLoading ? : } 197 |
198 |
199 | 207 |
208 |
209 |
210 | ); 211 | } 212 | 213 | // eslint-disable-next-line no-undef 214 | ReactDOM.render(, document.getElementById('app')); 215 | -------------------------------------------------------------------------------- /src/renderer/style.css: -------------------------------------------------------------------------------- 1 | * { 2 | margin: 0; 3 | padding: 0; 4 | box-sizing: border-box; 5 | } 6 | body { 7 | background: #eef2f6; 8 | font-size: 14px; 9 | } 10 | .container { 11 | height: 100vh; 12 | padding-top: 10px; 13 | overflow: hidden; 14 | user-select: none; 15 | } 16 | .tabs { 17 | display: flex; 18 | align-items: center; 19 | height: 32px; 20 | margin-left: 90px; 21 | margin-right: 32px; 22 | overflow: auto; 23 | } 24 | .tab { 25 | flex: 1 1 0; 26 | display: flex; 27 | align-items: center; 28 | padding: 0 10px 0 15px; 29 | max-width: 185px; 30 | border-left: 1px solid #999; 31 | cursor: default; 32 | } 33 | .tab:first-child { 34 | border-left: none; 35 | } 36 | .tab:last-of-type { 37 | border-right: 1px solid #999; 38 | } 39 | .tab:not(.active):hover .title { 40 | color: #999; 41 | } 42 | .tab.active { 43 | height: 100%; 44 | border-radius: 4px 4px 0px 0px; 45 | border-left-color: transparent; 46 | border-right-color: transparent; 47 | background: #fff; 48 | } 49 | .tab.active + .tab { 50 | border-left: none; 51 | } 52 | .title { 53 | flex-grow: 1; 54 | display: flex; 55 | margin: 0 5px 0 10px; 56 | overflow: hidden; 57 | -webkit-mask-image: -webkit-gradient(linear, left top, right top, from(#000000), color-stop(80%, #000000), to(transparent)); 58 | } 59 | .title-content { 60 | flex-shrink: 0; 61 | } 62 | .close { 63 | display: flex; 64 | justify-content: center; 65 | align-items: center; 66 | width: 20px; 67 | height: 20px; 68 | padding: 5px; 69 | margin-top: 2px; 70 | border-radius: 100%; 71 | font-size: 10px; 72 | } 73 | .close:hover { 74 | background: #ddd; 75 | } 76 | .bars { 77 | padding: 10px 18px; 78 | background: #fff; 79 | } 80 | .bars .address { 81 | width: 100%; 82 | height: 32px; 83 | padding: 4px 11px; 84 | border: 1px solid #d9d9d9; 85 | border-radius: 4px; 86 | background: #eceff2; 87 | outline: none; 88 | font-size: 12px; 89 | } 90 | .bars .address:focus { 91 | background: #fff; 92 | } 93 | .bar { 94 | display: flex; 95 | align-items: center; 96 | } 97 | .actions { 98 | flex-shrink: 0; 99 | display: flex; 100 | justify-content: space-evenly; 101 | width: 118px; 102 | margin-right: 18px; 103 | } 104 | .action { 105 | display: flex; 106 | justify-content: center; 107 | align-items: center; 108 | width: 36px; 109 | height: 36px; 110 | border-radius: 100%; 111 | color: #333; 112 | font-size: 16px; 113 | } 114 | .action.disabled { 115 | color: #ccc; 116 | } 117 | .action:not(.disabled):hover { 118 | background: #eee; 119 | } 120 | -------------------------------------------------------------------------------- /src/renderer/useConnect.js: -------------------------------------------------------------------------------- 1 | const { ipcRenderer } = require('electron'); 2 | const { useEffect, useState, useRef } = require('react'); 3 | 4 | // Used in Renderer process 5 | 6 | const noop = () => {}; 7 | 8 | const useTextFocus = () => { 9 | const htmlElRef = useRef(null) 10 | const setFocus = () => { 11 | if(htmlElRef.current) { 12 | htmlElRef.current.focus(); 13 | htmlElRef.current.select() 14 | } 15 | } 16 | 17 | return [ htmlElRef, setFocus ] 18 | } 19 | 20 | /** 21 | * A custom hook to create ipc connection between BrowserView and ControlView 22 | * 23 | * @param {object} options 24 | * @param {function} options.onTabsUpdate - trigger after tabs updated(title, favicon, loading etc.) 25 | * @param {function} options.onTabActive - trigger after active tab changed 26 | */ 27 | module.exports = function useConnect(options = {}) { 28 | const { onTabsUpdate = noop, onTabActive = noop } = options; 29 | const [tabs, setTabs] = useState({}); 30 | const [tabIDs, setTabIDs] = useState([]); 31 | const [activeID, setActiveID] = useState(null); 32 | const [urlInputRef, setUrlInputFocus] = useTextFocus() 33 | 34 | const channels = [ 35 | [ 36 | 'tabs-update', 37 | (e, v) => { 38 | setTabIDs(v.tabs); 39 | setTabs(v.confs); 40 | onTabsUpdate(v); 41 | } 42 | ], 43 | [ 44 | 'active-update', 45 | (e, v) => { 46 | setActiveID(v); 47 | const activeTab = tabs[v] || {}; 48 | onTabActive(activeTab); 49 | } 50 | ], 51 | [ 52 | 'focus-url-bar', 53 | (e, v) => { 54 | setUrlInputFocus() 55 | } 56 | ] 57 | ]; 58 | 59 | useEffect(() => { 60 | ipcRenderer.send('control-ready'); 61 | 62 | channels.forEach(([name, listener]) => ipcRenderer.on(name, listener)); 63 | 64 | return () => { 65 | channels.forEach(([name, listener]) => ipcRenderer.removeListener(name, listener)); 66 | }; 67 | }, []); 68 | 69 | return { tabIDs, tabs, setTabs, activeID, urlInputRef }; 70 | }; 71 | -------------------------------------------------------------------------------- /src/web3-protocol.js: -------------------------------------------------------------------------------- 1 | const { protocol } = require('electron'); 2 | const { PassThrough } = require('stream'); 3 | const { Readable } = require('stream'); 4 | 5 | // 6 | // EIP-6860 web3:// protocol 7 | // 8 | 9 | const registerWeb3Protocol = async (chainRpcOverrides, chainEnsOverrides) => { 10 | 11 | // Import the web3protocol (esm) the commonjs way 12 | const { Client } = await import('web3protocol'); 13 | const { getDefaultChainList } = await import('web3protocol/chains'); 14 | 15 | // Get the default chains 16 | let chainList = getDefaultChainList() 17 | 18 | // Handle the RPC overrides 19 | chainRpcOverrides.forEach(chainOverride => { 20 | // Find if the chain already exist 21 | let alreadyDefinedChain = Object.entries(chainList).find(chain => chain[1].id == chainOverride.id) || null 22 | 23 | // If it exists, override RPCs 24 | if(alreadyDefinedChain) { 25 | chainList[alreadyDefinedChain[0]].rpcUrls = [...chainOverride.rpcUrls] 26 | } 27 | // If does not exist, create it 28 | else { 29 | let newChain = { 30 | id: chainOverride.id, 31 | name: 'custom-' + chainOverride.id, 32 | rpcUrls: [...chainOverride.rpcUrls], 33 | } 34 | chainList.push(newChain) 35 | } 36 | }) 37 | 38 | // Handle the ENS registry overrides 39 | chainEnsOverrides.forEach(chainOverride => { 40 | // Find if the chain already exist 41 | let alreadyDefinedChain = Object.entries(chainList).find(chain => chain[1].id == chainOverride.id) || null 42 | 43 | // If it exists, override ENS registry 44 | if(alreadyDefinedChain) { 45 | if(chainList[alreadyDefinedChain[0]].contracts === undefined) { 46 | chainList[alreadyDefinedChain[0]].contracts = {} 47 | } 48 | chainList[alreadyDefinedChain[0]].contracts.ensRegistry = { 49 | address: chainOverride.ensRegistry 50 | } 51 | } 52 | }) 53 | 54 | // Create the web3Client 55 | let web3Client = new Client(chainList) 56 | 57 | // Process a web3:// call 58 | let result = protocol.handle("web3", async (request) => { 59 | let debuggingHeaders = {} 60 | 61 | try { 62 | // Parse the web3:// URL 63 | let parsedUrl = await web3Client.parseUrl(request.url) 64 | 65 | // Fill the debugging headers 66 | if(parsedUrl.nameResolution.chainId) { 67 | debuggingHeaders['web3-nameservice-chainid'] = "" + parsedUrl.nameResolution.chainId; 68 | } 69 | debuggingHeaders['web3-target-chainid'] = "" + parsedUrl.chainId; 70 | debuggingHeaders['web3-contract-address'] = parsedUrl.contractAddress; 71 | debuggingHeaders['web3-resolve-mode'] = parsedUrl.mode; 72 | if(parsedUrl.contractCallMode == 'calldata') { 73 | debuggingHeaders['web3-calldata'] = parsedUrl.calldata 74 | } 75 | else if(parsedUrl.contractCallMode == 'method') { 76 | debuggingHeaders['web3-auto-method'] = parsedUrl.methodName 77 | debuggingHeaders['web3-auto-method-arg'] = JSON.stringify(parsedUrl.methodArgs) 78 | debuggingHeaders['web3-auto-method-arg-values'] = JSON.stringify(parsedUrl.methodArgValues, 79 | (key, value) => typeof value === "bigint" ? "0x" + value.toString(16) : value) 80 | debuggingHeaders['web3-auto-method-return'] = JSON.stringify(parsedUrl.methodReturn) 81 | } 82 | 83 | // Make the call 84 | let contractReturn = await web3Client.fetchContractReturn(parsedUrl) 85 | let callResult = await web3Client.processContractReturn(parsedUrl, contractReturn) 86 | 87 | // Send to the browser 88 | return new Response(new JavaScriptToNodeReadable(callResult.output), { 89 | status: callResult.httpCode, 90 | headers: Object.assign({}, callResult.httpHeaders, debuggingHeaders) 91 | }) 92 | return; 93 | } 94 | catch(err) { 95 | return displayError(err.toString(), debuggingHeaders) 96 | } 97 | }) 98 | 99 | 100 | // 101 | // Utilities 102 | // 103 | 104 | // Display an error on the browser. 105 | const displayError = (errorText, debuggingHeaders) => { 106 | output = '
' + errorText + '
'; 107 | 108 | const stream = new PassThrough() 109 | stream.push(output) 110 | stream.push(null) 111 | 112 | debuggingHeaders['Content-Type'] = 'text/html'; 113 | 114 | return new Response(stream, { 115 | status: 500, 116 | headers: debuggingHeaders 117 | }) 118 | } 119 | 120 | console.log('Web3 protocol registered: ', result) 121 | } 122 | 123 | // Create a Node.js ReadableStream from a JS ReadableStream 124 | class JavaScriptToNodeReadable extends Readable { 125 | constructor(jsReadableStream, options) { 126 | super(options); 127 | this.reader = jsReadableStream.getReader(); 128 | } 129 | 130 | // Implement the _read method to fulfill the ReadableStream contract 131 | _read() { 132 | // Use the reader from the browser-based ReadableStream to read chunks 133 | this.reader.read().then(({ done, value }) => { 134 | if (done) { 135 | this.push(null); 136 | } else { 137 | this.push(value); 138 | } 139 | }).catch((error) => { 140 | this.emit('error', error); 141 | }); 142 | } 143 | } 144 | 145 | 146 | module.exports = { registerWeb3Protocol } -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const TerserPlugin = require('terser-webpack-plugin'); 2 | 3 | module.exports = [ 4 | { 5 | mode: 'production', 6 | entry: './src/eth-provider-injected.js', 7 | output: { 8 | filename: 'eth-provider-injected.packed.js', 9 | path: `${__dirname}/dist`, 10 | }, 11 | }, 12 | { 13 | mode: 'production', 14 | entry: './src/renderer/control.jsx', 15 | output: { 16 | filename: 'control-compiled.js', 17 | path: `${__dirname}/src/renderer`, 18 | }, 19 | target: 'electron-renderer', 20 | module: { 21 | rules: [ 22 | { 23 | test: /\.?jsx$/, 24 | use: { 25 | loader: "babel-loader", 26 | options: { 27 | presets: ['@babel/preset-env', '@babel/preset-react'] 28 | } 29 | } 30 | }, 31 | ] 32 | }, 33 | optimization: { 34 | minimizer: [new TerserPlugin({ 35 | extractComments: false, 36 | })], 37 | } 38 | } 39 | ]; --------------------------------------------------------------------------------