├── 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 | 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 | 
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 | 
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 |
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;
--------------------------------------------------------------------------------