├── dist └── .keep ├── .npmignore ├── screenshot2.png ├── screenshot3.png ├── src ├── eth-provider-injected.js ├── renderer │ ├── control.html │ ├── control.js │ ├── useConnect.js │ ├── style.css │ └── control.jsx ├── eth-provider-preload.js ├── web3-protocol.js ├── index.js └── browser-like-window.js ├── babel.config.js ├── .editorconfig ├── webpack.config.js ├── LICENSE ├── .gitignore ├── forge.config.js ├── package.json ├── .github └── workflows │ ├── release.yml │ └── main.yml └── README.md /dist/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | docs/ 2 | example/ 3 | src/ 4 | -------------------------------------------------------------------------------- /screenshot2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/web3-protocol/evm-browser/HEAD/screenshot2.png -------------------------------------------------------------------------------- /screenshot3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/web3-protocol/evm-browser/HEAD/screenshot3.png -------------------------------------------------------------------------------- /src/eth-provider-injected.js: -------------------------------------------------------------------------------- 1 | const provider = require('eth-provider') 2 | 3 | window.ethereum = provider(); -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /src/renderer/control.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /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 | ]; -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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 | };


--------------------------------------------------------------------------------
/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 | 


--------------------------------------------------------------------------------
/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 | 


--------------------------------------------------------------------------------
/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 | 


--------------------------------------------------------------------------------
/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 | 


--------------------------------------------------------------------------------
/.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 }}


--------------------------------------------------------------------------------
/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 | 


--------------------------------------------------------------------------------
/.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 | 


--------------------------------------------------------------------------------
/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 | 


--------------------------------------------------------------------------------
/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 } -------------------------------------------------------------------------------- /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/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/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; --------------------------------------------------------------------------------