├── .gitignore ├── docs ├── imgs │ ├── logo.png │ ├── README_1.png │ └── logo.svg └── README.md ├── config.json ├── .eslintrc.json ├── README.md ├── ui ├── progress.css ├── tabs.css ├── search.css ├── index.css ├── index.html ├── tabs.js ├── utils.js ├── search.js └── ui.js ├── package.json ├── LICENSE.md ├── tests ├── build.js └── unit │ └── tabs.js └── index.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | downloads 3 | build 4 | -------------------------------------------------------------------------------- /docs/imgs/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/retrohacker/peerweb/HEAD/docs/imgs/logo.png -------------------------------------------------------------------------------- /docs/imgs/README_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/retrohacker/peerweb/HEAD/docs/imgs/README_1.png -------------------------------------------------------------------------------- /config.json: -------------------------------------------------------------------------------- 1 | { 2 | "--comment--" : "refer to index.js for comments", 3 | "protocol" : "peer", 4 | "downloadDir": "downloads" 5 | } 6 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env" : { 3 | "node": true 4 | }, 5 | "extends": [ 6 | "airbnb", 7 | "standard" 8 | ], 9 | "rules": { 10 | "prefer-arrow-callback": 0, 11 | "strict": 0 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | WebTorrent 3 |
4 |
5 | PeerWeb Browser 6 |

7 | 8 | 9 | ### Project Deprectated 10 | 11 | This project hasn't been maintained in ~4 years (at the time of this commit) 12 | -------------------------------------------------------------------------------- /ui/progress.css: -------------------------------------------------------------------------------- 1 | .progress { 2 | height: 5px; 3 | width: 100%; 4 | background-color: #EEEEEE; 5 | display: none; 6 | position: fixed; 7 | } 8 | 9 | .progress .complete { 10 | height: 100%; 11 | width: 0%; 12 | background-color: #FF5722; 13 | } 14 | 15 | /* only display the progress bar if it's corresponding tab is selected and 16 | * it's corresponding webview is loading 17 | */ 18 | .progress.selected.loading { 19 | display: block; 20 | } 21 | -------------------------------------------------------------------------------- /ui/tabs.css: -------------------------------------------------------------------------------- 1 | .tab-add, .tab { 2 | border-right: solid white 2px !important; 3 | height: 40px; 4 | display: inline-block; 5 | } 6 | 7 | .tab { 8 | width: 0px; 9 | max-width: 180px; 10 | flex: 1; 11 | padding: 0 10px; 12 | line-height: 40px; 13 | vertical-align: middle; 14 | color: white; 15 | display: flex; 16 | justify-content: space-between; 17 | } 18 | 19 | .tab-name { 20 | white-space: nowrap; 21 | text-overflow: ellipsis; 22 | overflow: hidden; 23 | } 24 | 25 | .close-tab:hover { 26 | color: red; 27 | padding-left: 5px; 28 | } 29 | 30 | 31 | -------------------------------------------------------------------------------- /ui/search.css: -------------------------------------------------------------------------------- 1 | #search-bar-wrap { 2 | border-radius: 10px; 3 | background-color: white; 4 | height: 30; 5 | margin: 5px 10px; 6 | padding-right: 10px; 7 | /* Grow as large as possible */ 8 | display: flex; 9 | flex-grow: 9999999; 10 | align-items: center; 11 | } 12 | 13 | #search-bar { 14 | font-size: 20px; 15 | padding: 2px 10px; 16 | width: 400px; 17 | border: none; 18 | height: 30; 19 | /* Grow as large as possible */ 20 | flex-grow: 9999999; 21 | } 22 | 23 | #search-bar:focus { 24 | border: none; 25 | outline: none; 26 | } 27 | 28 | #search-bar-wrap .logo { 29 | padding-left: 10px; 30 | display: flex; 31 | } 32 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "electron-debug": "^1.0.0", 4 | "fs-chunk-store": "1.3.5", 5 | "parse-torrent": "^5.8.0", 6 | "prettier-bytes": "^1.0.3", 7 | "request": "^2.74.0", 8 | "temp": "^0.8.3", 9 | "webtorrent": "0.81.2" 10 | }, 11 | "devDependencies": { 12 | "async": "^1.5.2", 13 | "devtron": "^1.1.0", 14 | "electron-packager": "7.0.1", 15 | "electron-prebuilt": "1.0.1", 16 | "eslint": "2.9.0", 17 | "eslint-config-airbnb": "9.0.1", 18 | "eslint-config-standard": "5.3.1", 19 | "eslint-plugin-import": "1.8.0", 20 | "eslint-plugin-jsx-a11y": "1.2.0", 21 | "eslint-plugin-promise": "1.1.0", 22 | "eslint-plugin-react": "5.1.1", 23 | "eslint-plugin-standard": "1.3.2", 24 | "mkdirp": "0.5.1", 25 | "rimraf": "2.5.2", 26 | "spectron": "3.0.0", 27 | "tape": "4.5.1" 28 | }, 29 | "scripts": { 30 | "start": "electron .", 31 | "pretest": "eslint --ignore-pattern 'node_modules' --ignore-pattern 'build' --ignore-pattern 'downloads' .", 32 | "test": "tape tests/*/**/*.js" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /ui/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0px; 3 | display: flex; 4 | flex-direction: column; 5 | } 6 | 7 | #content { 8 | display: flex; 9 | flex-grow: 999999999; 10 | } 11 | 12 | #nav-bar { 13 | display: flex; 14 | flex-direction: column; 15 | background-color: #222831; 16 | } 17 | 18 | .search-row { 19 | height: 40px; 20 | display: flex; 21 | } 22 | 23 | #tab-row { 24 | height: 40px; 25 | display: flex; 26 | } 27 | 28 | #search-back { 29 | transform: scaleX(-1); 30 | } 31 | 32 | .button.inactive { 33 | color: #889; 34 | } 35 | 36 | #search-refresh { 37 | font-weight: bold; 38 | transform: rotate(.25turn); 39 | } 40 | 41 | .button { 42 | border-right: solid transparent 2px; 43 | width: 38px; 44 | height: 40px; 45 | font-size: 35px; 46 | text-align: center; 47 | color: white; 48 | } 49 | 50 | .button:hover, .tab.selected { 51 | background-color: #2D4059; 52 | } 53 | 54 | webview { 55 | display: none; 56 | } 57 | 58 | webview.selected { 59 | display: flex; 60 | flex-grow: 99999999; 61 | } 62 | 63 | #webtorrent-stats { 64 | background-color: #bbc; 65 | padding: 5px; 66 | } 67 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 William Blankenship 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 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | Getting Started 2 | =============== 3 | 4 | This document will walk you through getting up and running with PeerWeb during it's early alpha release. Things will be changing fast, but an effort will be made to keep this document up-to-date. 5 | 6 | # Requirements 7 | 8 | We don't yet have a build of PeerWeb available for download. Luckily, there is no build step for running this locally, but you still will need `git` to clone the PeerWeb repository and the `nodejs` platform. 9 | 10 | Installation requirements: 11 | 12 | 1. [nodejs and npm](https://nodejs.org/en/download) 13 | 2. [git](https://git-scm.com/book/en/v2/Getting-Started-Installing-Git) 14 | 15 | # Installation 16 | 17 | Installation currently involves cloning the PeerWeb repository with `git`, using `npm` to install PeerWeb's dependencies, and then running `npm start`. 18 | 19 | ```text 20 | $ git clone https://github.com/retrohacker/peerweb.git 21 | $ cd peerweb 22 | $ NODE_ENV=DEV npm install 23 | $ npm start 24 | ``` 25 | 26 | From now on, from the `peerweb` directory, you can simply run `npm start` to start the browser. 27 | 28 | Moving forward, we will provide pre-built binaries for Windows, OSX, and various Linux distributions. 29 | 30 | # Unboxing Experience. 31 | 32 | When you first run the browser, you should be presented with the following UI: 33 | 34 | ![](./imgs/README_1.png) 35 | 36 | This UI has many shortcomings compared to other web browsers, I encourage you to open up [issues](https://github.com/retrohacker/peerweb/issues) with suggestions on how we can make the best first impression with this browser. 37 | 38 | # Sharing your own website 39 | 40 | While the browser currently can handle loading content from a torrent, there is not yet a way to share your own content through the UI. This is on the roadmap and is being tracked in [issue #5](https://github.com/retrohacker/issues/5). 41 | 42 | ## Ports 43 | 44 | Every router and network is a special little snowflake. The main thing here is NAT, and making sure that incomming connections make it to the bittorrent client they were destined for. NAT traversal is on the roadmap, but is no easy path to walk. 45 | 46 | For now, in order to use Bittorrent's DHT protocol, you will need to use a computer that receives all incomming traffic on the network. This is easiest on something like Digital Ocean. 47 | 48 | ## Webtorrent 49 | 50 | To share your website, follow these steps. 51 | 52 | ``` 53 | $ npm install -g webtorrent-cli 54 | $ webtorrent seed [website_folder] 55 | ``` 56 | 57 | This will show your your torrent hash. It will look something like: `000260db64bc4d57afb5458639f2364d628b6e1b` 58 | 59 | You can then take this hash and use it as a url in peerweb: [peer://000260db64bc4d57afb5458639f2364d628b6e1b](peer://000260db64bc4d57afb5458639f2364d628b6e1b) 60 | -------------------------------------------------------------------------------- /ui/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 38 | 50 | 51 | 52 | 74 |
75 |
76 | 77 | 78 | -------------------------------------------------------------------------------- /ui/tabs.js: -------------------------------------------------------------------------------- 1 | /* 2 | * tabs.js 3 | * 4 | * This file contains all of the code immediately relevant to the tabs in the 5 | * browser UI. 6 | * 7 | * All logic associated with updating the UI in response to an event triggered 8 | * by a tab is delegated to ui.js 9 | */ 10 | 11 | // Create a global singleton that scopes the globally exposed functions from 12 | // this application 13 | // eslint-disable-next-line prefer-const, no-use-before-define, no-var 14 | var peerWeb = peerWeb || {} 15 | 16 | // Scope all functions exposed by this file to tabs 17 | peerWeb.tabs = peerWeb.tabs || {} 18 | 19 | ;(function scope () { 20 | // The closeTab function is added as an event listener to a newly created tab 21 | // in newTab. It listens for a click event on the close-tab button of a tab, 22 | // and removes the tab and it's dependencies from the browser. 23 | peerWeb.tabs.closeTab = function closeTab (e) { 24 | // Get the id of the tab we are closing. The tab id will be set as the 25 | // CSS id of the parent element of the close icon on the tab. 26 | const id = peerWeb.utils.getId(e.target.parentElement) 27 | peerWeb.ui.remove(id) 28 | } 29 | 30 | // tabClick is registered as an event listener on every tab. Its purpose 31 | // is to determine if a click event represents a user trying to click on a 32 | // tab. If the click was meant to go to the tab, then the work is passed off 33 | // to the claimOwnership function from ui.js to handle updating the 34 | // browser accordingly. 35 | peerWeb.tabs.tabClick = function tabClick (e) { 36 | // Get the target DOM element of the event 37 | let target = e.target 38 | 39 | // Make sure the user wasn't trying to close the tab 40 | if (target.className === 'close-tab') { 41 | return null 42 | } 43 | 44 | // If the user clicked on 'tab-name' then they were clicking on the tab 45 | // itself, so select 'tab-name's parent, which is the tab container, and 46 | // use that as the target 47 | if (target.className === 'tab-name') { 48 | target = target.parentNode 49 | } 50 | 51 | // Get the id of the clicked tab 52 | const id = peerWeb.utils.getId(target) 53 | if (id == null) { 54 | // If we were unable to parse an id, there is nothing left for us to do 55 | // TODO: This would probably be a good place to report this error back to 56 | // the developers since this is an edge case that should never be reached 57 | return null 58 | } 59 | 60 | // Update the UI to reflect the new tab being active 61 | peerWeb.ui.claimOwnership(id) 62 | 63 | // All done! 64 | return null 65 | } 66 | 67 | // We use tabCount as a generator of unique IDs for the newTab function 68 | let tabCount = 0 69 | 70 | // newTab is an event listener that is wired up to the add tab button. When 71 | // clicked, it delegates a majority of its work to peerWeb.ui.add, and simply 72 | // keeps track of the tabCount UID generator. 73 | peerWeb.tabs.newTab = function newTab () { 74 | peerWeb.ui.add(tabCount++) 75 | } 76 | })() 77 | -------------------------------------------------------------------------------- /ui/utils.js: -------------------------------------------------------------------------------- 1 | /* 2 | * utils.js 3 | * 4 | * This file contains some handy dandy functions that handle some rather 5 | * tedious tasks such as DOM manipulation and adding 6 | */ 7 | 8 | // Create a global singleton that scopes the globally exposed functions from 9 | // this application 10 | // eslint-disable-next-line prefer-const, no-use-before-define, no-var 11 | var peerWeb = peerWeb || {} 12 | 13 | // Scope all functions exposed by this file to utils 14 | peerWeb.utils = peerWeb.utils || {} 15 | 16 | ;(function scope () { 17 | // addClass adds a CSS class to a DOM element and returns the new 18 | // string-separated list of class names as a result 19 | peerWeb.utils.addClass = function addClass (element, c) { 20 | // Ensure we don't add an empty space at the beginning if there are no 21 | // currently existing classes 22 | if (element.className == null || element.className.length === 0) { 23 | return c 24 | } 25 | 26 | return `${element.className} ${c}` 27 | } 28 | 29 | // removeClass removes a CSS class element from a DOM element and returns 30 | // the new string-separated list of class names as a result 31 | peerWeb.utils.removeClass = function removeClass (element, c) { 32 | // If there are no class names, we have no work to do 33 | if (element.className == null || element.className.length === 0) { 34 | return '' 35 | } 36 | 37 | // Grab the current list of space-separated class names 38 | return element.className 39 | // Split around spaces to get an array of class names 40 | .split(' ') 41 | // Filter the list, removing any name that matches the class 42 | .filter((v) => v !== c) 43 | // Turn it back into a space-separated list 44 | .join(' ') 45 | } 46 | 47 | // hasClass takes a DOM element and returns true if the DOM element has the 48 | // CSS class name provided 49 | peerWeb.utils.hasClass = function hasClass (element, c) { 50 | // If the element has no classNames, just return false 51 | if (element.className == null) { 52 | return false 53 | } 54 | 55 | // return true if the element has the class name, false otherwise 56 | return element.className 57 | .split(' ') 58 | // This is gross. To understand what is happening here, first read 59 | // the documentation on the reduce function for arrays in 60 | // JavaScript. We are starting the reduce function with the initial 61 | // value of `false`. We then or the previous value with the strict 62 | // equality of the class name we are looking for. If at any point, 63 | // a value in the array is equal to the class name provided to this 64 | // function, the `or` operation will evaluate to true, meaning the 65 | // rest of the checks will evaluate to true 66 | .reduce((p, v) => p || v === c, false) 67 | } 68 | 69 | // getId takes an element and returns the Unique ID of that element. This 70 | // assumes that the element conforms to the standard of `TYPE-ID` for the 71 | // CSS id name that has been established by this project 72 | peerWeb.utils.getId = function getId (element) { 73 | // We make sure that the element has an id, and that the id has the 74 | // function `split`. If so, we return null, since we cant determine an 75 | // id 76 | if (element.id == null || 77 | typeof element.id.split !== 'function') { 78 | return null 79 | } 80 | 81 | // Split the element around `-` and return the id which should be the 82 | // second component 83 | return element.id.split('-')[1] 84 | } 85 | })() 86 | -------------------------------------------------------------------------------- /tests/build.js: -------------------------------------------------------------------------------- 1 | /* 2 | * build.js 3 | * 4 | * This is a singleton responsible for generating an electron binary for the 5 | * tests to drive. The binary is built once, and then the path is cached for 6 | * subsequent requests. 7 | */ 8 | 'use strict' 9 | 10 | /* Require in deps */ 11 | const electronPackager = require('electron-packager') 12 | const path = require('path') 13 | const rimraf = require('rimraf') 14 | const mkdirp = require('mkdirp') 15 | const packageJson = require('../package.json') 16 | 17 | /* Define global variables for module */ 18 | 19 | // We will cache the path to the compiled electron app for future invocations 20 | let cachedBuild = null 21 | 22 | // This is the directory we will drop built artifacts into 23 | const outDir = path.join(__dirname, '..', 'build') 24 | 25 | // This is the name of the binary we will build 26 | const buildName = 'test-artifact' 27 | 28 | // Configuration settings for electron-packager 29 | const opts = { 30 | // We are only building the architecture for the current platform 31 | arch: process.arch, 32 | // We want to build the entire project directory 33 | dir: path.join(__dirname, '..'), 34 | // And we want to build this for the OS we are currently running 35 | platform: process.platform, 36 | // The directory to drop the builds in 37 | out: outDir, 38 | // Give it a meaningful name in the event we fail to cleanup 39 | name: buildName, 40 | // Give it a version to skip looking up the package.json, this avoids an 41 | // error condition we were reaching in the electron-packager module 42 | version: packageJson.devDependencies['electron-prebuilt'] 43 | } 44 | 45 | /* Done defining globals */ 46 | 47 | // Once all of the tests have been run or the process aborts, we want to delete 48 | // the built binary since it was only for these tests 49 | process.on('exit', function cleanupBuild () { 50 | // Disable globbing ensures that outDir doesn't delete more than one folder 51 | // NOTE: We are in the `exit` event handler, yielding the event loop here 52 | // causes the process to exit, so we use the sync version of rimraf 53 | // return rimraf.sync(outDir, { glob: false }) 54 | }) 55 | 56 | module.exports.getPath = function getPath (cb) { 57 | // If we have already built a binary, simply return the cached path 58 | if (cachedBuild) return cb(null, cachedBuild) 59 | 60 | // If a binary has not yet been built for this set of tests, delete the 61 | // current output directory if it exists, recreate it, and build a fresh 62 | // binary. 63 | return rimraf(outDir, function removeDirectory (rimrafError) { 64 | // If we fail to delete the directory, we can't recreate and build it so 65 | // there is nothing left for our tests to do. Report the error and crash. 66 | // Yes, throwing is an anti-pattern, but these are tests so it shouldn't 67 | // matter. Note: The callback is error first, even though we never return 68 | // an error. This is only for convention, since it doesn't make sense for 69 | // the process to continue when an error condition is encountered in this 70 | // method 71 | if (rimrafError) throw rimrafError 72 | mkdirp(outDir, function directoryExists (mkdirpError) { 73 | // If we fail to create the output directory, we can't build the binary and 74 | // there is nothing for our tests to do. Yes throwing is an anti-pattern, 75 | // but these are tests so it shouldn't matter. 76 | if (mkdirpError) throw mkdirpError 77 | 78 | // We now invoke electron-packager and let it build us a binary to test 79 | electronPackager(opts, function binaryBuilt (buildError, paths) { 80 | // If the package failed to build, there is nothing left for us to do 81 | if (buildError) throw buildError 82 | 83 | // If we have more than one binary built, our assumptions about the 84 | // options object are wrong and this module needs to be redesigned 85 | if (paths.length !== 1) { 86 | throw new Error(`electron-packager built ${paths.length} binaries`) 87 | } 88 | 89 | // Cache the path to our new binary 90 | cachedBuild = path.join(paths[0], buildName) 91 | return cb(null, cachedBuild) 92 | }) 93 | }) 94 | }) 95 | } 96 | -------------------------------------------------------------------------------- /ui/search.js: -------------------------------------------------------------------------------- 1 | /* 2 | * search.js 3 | * 4 | * This file contains all of the code immediately relevant to the search bar 5 | * at the top of the page. The search bar is an overloaded term, its really 6 | * just the bar you type URLs into for navigation. It currently offers no 7 | * search. 8 | */ 9 | 10 | // Create a global singleton that scopes the globally exposed functions from 11 | // this application 12 | // eslint-disable-next-line prefer-const, no-use-before-define, no-var 13 | var peerWeb = peerWeb || {} 14 | // eslint-disable-next-line prefer-const, no-use-before-define, no-var 15 | var url = require('url') 16 | 17 | // Scope all functions exposed by this file to search 18 | peerWeb.search = peerWeb.search || {} 19 | 20 | ;(function scope () { 21 | // Scrub the url to make it electron friendly 22 | /*eslint-disable no-param-reassign*/ 23 | peerWeb.search.scrubUrl = function scrubUrl (addr) { 24 | // Check if any valid protocol present 25 | // TODO:Improve Scrubber Logic 26 | if (!/\w+:\/\//.test(addr)) { 27 | addr = `https://${addr}` 28 | } 29 | if (url.parse(addr).path == null) { 30 | return `${addr}/` 31 | } 32 | return addr 33 | } 34 | 35 | // checkSubmit is an event listener for the search bar in the UI. It waits 36 | // until a user presses enter and then sends the user to the URL typed into 37 | // the search bar 38 | peerWeb.search.checkSubmit = function checkSubmit (e) { 39 | // If event is defined and the keyCode of the event is the enter key, go 40 | // ahead and try to send the user to the url they typed in 41 | if (e != null && e.keyCode === 13) { 42 | const addr = peerWeb.search.scrubUrl(e.target.value) 43 | document.getElementById('search-bar').value = addr 44 | peerWeb.ui.navigate(addr) 45 | } 46 | } 47 | 48 | // navigateBack will send the currently selected webview to it's previous 49 | // page if possible 50 | peerWeb.search.back = function navigateBack (e) { 51 | // Iterate through all webviews looking for the selected one 52 | const webviews = document.getElementsByTagName('webview') 53 | let webview = webviews[0] 54 | 55 | for (let i = 0; 56 | i < webviews.length && !peerWeb.utils.hasClass(webview, 'selected'); 57 | i++, webview = webviews[i]) {} // eslint-disable-line no-empty 58 | 59 | // If we didn't find a selected webview, then we have nothing to do 60 | if (webview == null || !peerWeb.utils.hasClass(webview, 'selected')) { 61 | return null 62 | } 63 | 64 | // If we can't navigate backwards, there is nothing to do 65 | if (!webview.canGoBack()) { 66 | return null 67 | } 68 | 69 | // Navigate back a page 70 | webview.goBack() 71 | 72 | // If we can't go back any further, reflect that in the UI 73 | if (!webview.canGoBack()) { 74 | const navBack = document.getElementById('search-back') 75 | navBack.className = peerWeb.utils.removeClass(navBack, 'inactive') 76 | navBack.className = peerWeb.utils.addClass(navBack, 'inactive') 77 | } 78 | 79 | return null 80 | } 81 | 82 | // navigateForward will send the currently selected webview to it's next 83 | // page if possible 84 | peerWeb.search.forward = function navigateForward (e) { 85 | // Iterate through all webviews looking for the selected one 86 | const webviews = document.getElementsByTagName('webview') 87 | let webview = webviews[0] 88 | 89 | for (let i = 0; 90 | i < webviews.length && !peerWeb.utils.hasClass(webview, 'selected'); 91 | i++, webview = webviews[i]) {} // eslint-disable-line no-empty 92 | 93 | // If we didn't find a selected webview, then we have nothing to do 94 | if (webview == null || !peerWeb.utils.hasClass(webview, 'selected')) { 95 | return null 96 | } 97 | 98 | // If we can't navigate forwards, there is nothing to do 99 | if (!webview.canGoForward()) { 100 | return null 101 | } 102 | 103 | // Navigate forward a page 104 | webview.goForward() 105 | 106 | // If we can't go forward any further, reflect that in the UI 107 | if (!webview.canGoForward()) { 108 | const navForward = document.getElementById('search-forward') 109 | navForward.className = peerWeb.utils.removeClass(navForward, 'inactive') 110 | navForward.className = peerWeb.utils.addClass(navForward, 'inactive') 111 | } 112 | 113 | return null 114 | } 115 | 116 | // refresh will refresh the webview's current page 117 | peerWeb.search.refresh = function refresh (e) { 118 | // Iterate through all webviews looking for the selected one 119 | const webviews = document.getElementsByTagName('webview') 120 | let webview = webviews[0] 121 | 122 | for (let i = 0; 123 | i < webviews.length && !peerWeb.utils.hasClass(webview, 'selected'); 124 | i++, webview = webviews[i]) {} // eslint-disable-line no-empty 125 | 126 | // If we didn't find a selected webview, then we have nothing to do 127 | if (webview == null || !peerWeb.utils.hasClass(webview, 'selected')) { 128 | return null 129 | } 130 | 131 | // refresh a page 132 | webview.reload() 133 | 134 | return null 135 | } 136 | })() 137 | -------------------------------------------------------------------------------- /tests/unit/tabs.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | /* eslint-disable func-names */ 4 | const test = require('tape') 5 | const buildBinary = require('../build.js') 6 | const spectron = require('spectron') 7 | const asyncLib = require('async') 8 | 9 | /* Define globals for tests */ 10 | let app = null 11 | /* Done defining globals */ 12 | 13 | // This is a hack. Since tape runs tests serially, we can have this test run 14 | // before everything else in this file. This lets us get a path to the binary 15 | // (the module will build it for us if necessary) and then the rest of the 16 | // tests can use the global variable defined above. 17 | test('Get path to binary', function (t) { 18 | buildBinary.getPath(function (e, path) { 19 | app = new spectron.Application({ 20 | path 21 | }) 22 | t.end() 23 | }) 24 | }) 25 | 26 | // We want to make sure the nav-bar loads and is visible, so we start the 27 | // browser and query the DOM 28 | test('UI Loads', function (t) { 29 | app.start() 30 | .then(function () { 31 | return app.client.isVisibleWithinViewport('#nav-bar') 32 | }) 33 | .then(function (isVisible) { 34 | t.equal(isVisible, true, 'Nav Bar should be visible') 35 | }) 36 | .catch(function (e) { 37 | t.fail(e) 38 | return 39 | }) 40 | .then(function () { 41 | return app.stop() 42 | }) 43 | .catch(function (e) { 44 | t.fail(e) 45 | return 46 | }) 47 | .then(function (e) { 48 | t.end() 49 | return 50 | }) 51 | }) 52 | 53 | // Lets make sure that clicking the "add tab" button actually adds a tab 54 | test('Tab functionality', function (t) { 55 | app.start() 56 | // Click the "add tab" button 3 times, resulting in 4 tabs on the page 57 | .then(function () { 58 | return app.client.click('#add-tab') 59 | }) 60 | .then(function () { 61 | return app.client.click('#add-tab') 62 | }) 63 | .then(function () { 64 | return app.client.click('#add-tab') 65 | }) 66 | .then(function (isVisible) { 67 | return app.client.elements('.tab') 68 | }) 69 | .then(function (tabs) { 70 | t.equal(tabs.value.length, 4, 'clicking add-tab 3 times added 3 tabs') 71 | return 72 | }) 73 | .then(function () { 74 | return app.client.click('#tab-2') 75 | }) 76 | .then(function () { 77 | return app.client.isVisibleWithinViewport('#webview-2') 78 | }) 79 | .then(function (isVisible) { 80 | t.equal(isVisible, 81 | true, 82 | 'webview becomes visible when corresponding tab is clicked') 83 | }) 84 | .then(function () { 85 | return app.client.click('.close-tab') 86 | }) 87 | .then(function () { 88 | return app.client.elements('.tab') 89 | }) 90 | .then(function (tabs) { 91 | t.equal(tabs.value.length, 3, 'clicking close-tab removes a tab') 92 | return 93 | }) 94 | .catch(function (e) { 95 | t.fail(e) 96 | return 97 | }) 98 | .then(function () { 99 | return app.stop() 100 | }) 101 | .catch(function (e) { 102 | t.fail(e) 103 | return 104 | }) 105 | .then(function (e) { 106 | t.end() 107 | return 108 | }) 109 | }) 110 | 111 | // Let's make sure the nav bar updates the tab after page load 112 | test('Navbar updates tab value', function (t) { 113 | app.start() 114 | // Set the URL to google and then press enter 115 | .then(function () { 116 | return app.client.setValue('#search-bar', 'http://www.google.com') 117 | }) 118 | .then(function () { 119 | return app.client.click('#search-bar') 120 | }) 121 | .then(function (bar) { 122 | return app.client.keys('Enter') 123 | }) 124 | // Keep checking to see if the page has loaded 125 | .then(function () { 126 | return new Promise(function (resolve, reject) { 127 | asyncLib.retry( 128 | { times: 10, interval: 2000 }, 129 | function (cb) { 130 | app.client.selectorExecute('webview', function (webview) { 131 | return `${webview[0].isLoading()}` 132 | }) 133 | .then(function (isLoading) { 134 | if (isLoading === 'false') { 135 | return cb() 136 | } 137 | return cb(new Error('WebView still loading...')) 138 | }) 139 | .catch(cb) 140 | }, 141 | function (e) { 142 | if (e) return reject(e) 143 | return resolve() 144 | } 145 | ) 146 | }) 147 | }) 148 | // Make sure tab value was updated 149 | .then(function () { 150 | return app.client.selectorExecute('.tab-name', function (tabs) { 151 | return tabs[0].innerHTML 152 | }) 153 | }) 154 | .then(function (text) { 155 | if (text !== 'Google') throw new Error(`Invalid tab title: ${text}`) 156 | return null 157 | }) 158 | .catch(function (e) { 159 | t.fail(e) 160 | return 161 | }) 162 | .then(function () { 163 | return app.stop() 164 | }) 165 | .catch(function (e) { 166 | t.fail(e) 167 | return 168 | }) 169 | .then(function (e) { 170 | t.end() 171 | return 172 | }) 173 | }) 174 | 175 | /* eslint-enable func-names */ 176 | -------------------------------------------------------------------------------- /docs/imgs/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 21 | 23 | 45 | 47 | 48 | 50 | image/svg+xml 51 | 53 | 54 | 55 | 56 | 57 | 62 | 78 | 81 | 84 | 90 | 96 | 102 | 108 | 114 | 115 | 131 | 133 | 143 | 153 | 163 | 173 | 183 | 193 | 194 | 195 | 196 | 197 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | /* Configuration: 2 | * 3 | * config.protocol: defines the name of the protocol that will serve static 4 | * websites over torrent. For example, if you set this to 'peer', then any 5 | * url prefixed with 'peer://' will be handled by our torrent protocol 6 | * 7 | */ 8 | const config = require('./config.json') 9 | require('electron-debug')({ showDevTools: true }) 10 | 11 | /* Begin Dependencies */ 12 | 13 | // We use path to locate files on the local filesystem 14 | const path = require('path') 15 | 16 | // We use url to parse incomming requests for their file path 17 | const url = require('url') 18 | 19 | // Electron is used to render a browser for the user 20 | const electron = require('electron') // eslint-disable-line import/no-unresolved 21 | 22 | // IPC is used to convey torrent information to rendering process 23 | const ipcMain = electron.ipcMain 24 | 25 | // WebTorrent gives our application the ability to download/seed torrents 26 | const WebTorrent = require('webtorrent') 27 | 28 | // prettier-bytes takes download/upload rates and makes them human friendly 29 | const pbytes = require('prettier-bytes') 30 | 31 | // parse-torrent lets us verify a torrent hash before passing it onto the 32 | // webtorrent handler 33 | const parseTorrent = require('parse-torrent') 34 | 35 | // request will be used for the http fallback when parse-torrent says a request 36 | // was not in the form of a torrent info 37 | const request = require('request') 38 | 39 | // temp will allow us to save html files fetched over the http fallback and 40 | // they will be purged on application shutdown 41 | const temp = require('temp') 42 | temp.track() 43 | 44 | // fs will allow us to write to our temporary file and load torrents from a 45 | // previous session 46 | const fs = require('fs') 47 | 48 | /* End Dependencies */ 49 | 50 | // Create a new client responsible for seeding/downloading torrents 51 | const client = new WebTorrent() 52 | 53 | // ipc Global Status channel , only for recieving Stat requests 54 | // Reply sent on : global-status-reply 55 | ipcMain.on('global-status', function updateStatus (event, arg) { 56 | // status contains WebTorrent Client Stats 57 | const status = { 58 | download: pbytes(client.downloadSpeed), 59 | upload: pbytes(client.uploadSpeed), 60 | torrents: client.torrents 61 | } 62 | // Send Asynchronous reply. Note that status is serialized internally 63 | event.sender.send('global-status-reply', status) 64 | }) 65 | 66 | // peerProtocolHandler resolves a peer:// request against a torrent, returning 67 | // the requested file as the result 68 | function peerProtocolHandler (req, callback) { 69 | // Take the incomming request and parse out the url components 70 | let requestedUrl = url.parse(req.url) 71 | 72 | // If the requested file has a trailing `/`, assume it is a folder and add 73 | // index.html to the end 74 | let requestedFile = requestedUrl.pathname 75 | 76 | if (requestedFile.substring(requestedFile.length - 1) === '/') { 77 | requestedFile += 'index.html' 78 | } 79 | 80 | // Make sure the path isn't prefixed with a slash 81 | if (requestedFile.substring(0, 1) === '/') { 82 | requestedFile = requestedFile.substring(1) 83 | } 84 | 85 | // Log the requested file 86 | console.log(requestedFile) // eslint-disable-line no-console 87 | 88 | // The hash is the hostname of the requested url 89 | const hash = requestedUrl.host 90 | 91 | // We create a directory using the torrent's hash and have webtorrent 92 | // download the website's contents there 93 | const opts = { 94 | path: path.join(__dirname, config.downloadDir, hash) 95 | } 96 | 97 | // Ensure the torrent hash is valid before we pass it on to webtorrent, if it 98 | // is not, try fetching it over http 99 | try { 100 | parseTorrent(hash) 101 | } catch (exception) { 102 | // eslint-disable-next-line no-console 103 | console.log('Not a torent identifier, falling back to http') 104 | requestedUrl.protocol = 'http' 105 | requestedUrl = url.format(requestedUrl) 106 | return request(requestedUrl, function httpFallback (e, resp, body) { 107 | // If we didn't get a 200 response, return an error 108 | if (e) { 109 | // eslint-disable-next-line no-console 110 | console.log(`Failed to fetch ${requestedUrl}: ${e}`) 111 | return callback(e) 112 | } else if (resp.statusCode !== 200) { 113 | // eslint-disable-next-line no-console 114 | console.log(`Failed to fetch ${requestedUrl}: ${resp.statusCode}`) 115 | return callback(resp.statusCode) 116 | } 117 | 118 | // Create a temporary file for our downloaded resource 119 | return temp.open('peerwebhttp', function tempFileOpened (e2, info) { 120 | if (e2) { 121 | // eslint-disable-next-line no-console 122 | console.log(`Failed to open temp file: ${e2}`) 123 | return callback(e2) 124 | } 125 | 126 | // We then write our response to the temporary file and return the path 127 | // back to electron to serve as the response 128 | return fs.write(info.fd, body, function tempFileWritten () { 129 | fs.close(info.fd, function tempFileClosed () { 130 | return callback(info.path) 131 | }) 132 | }) 133 | }) 134 | }) 135 | } 136 | 137 | // Lets kick off the download through webtorrent 138 | return client.add(hash, opts, function loaded (torrent) { 139 | // Search the torrent for the requestedFile 140 | let returnFile = null 141 | for (let i = 0; i < torrent.files.length; i++) { 142 | const file = torrent.files[i] 143 | // Webtorrent prepends the torrent name to the beginning of the file, 144 | // we want to remove that when searching for the requested file 145 | const name = file.path.substring((`${torrent.name}/`).length) 146 | if (name === requestedFile) { 147 | // found it! 148 | returnFile = file 149 | } 150 | } 151 | 152 | // If the requested file was not found, try assuming it is a directory and 153 | // look for index.html in that directory 154 | if (returnFile == null) { 155 | // eslint-disable-next-line no-console 156 | console.log(`Trying ${requestedFile}/index.html`) 157 | for (let i = 0; i < torrent.files.length; i++) { 158 | const file = torrent.files[i] 159 | // Webtorrent prepends the torrent name to the beginning of the file, 160 | // we want to remove that when searching for the requested file 161 | const name = file.path.substring((`${torrent.name}/`).length) 162 | if (name === `${requestedFile}/index.html`) { 163 | // found it! 164 | returnFile = file 165 | } 166 | } 167 | } 168 | 169 | // If the requested file wasn't a folder, try checking if there is a file 170 | // by the name of requestedFile.html in the torrent 171 | if (returnFile == null) { 172 | // eslint-disable-next-line no-console 173 | console.log(`Trying ${requestedFile}.html`) 174 | for (let i = 0; i < torrent.files.length; i++) { 175 | const file = torrent.files[i] 176 | // Webtorrent prepends the torrent name to the beginning of the file, 177 | // we want to remove that when searching for the requested file 178 | const name = file.path.substring((`${torrent.name}/`).length) 179 | if (name === `${requestedFile}.html`) { 180 | // found it! 181 | returnFile = file 182 | } 183 | } 184 | } 185 | 186 | // If it is still not found, tell electron we didn't find the file 187 | if (returnFile == null) { 188 | // eslint-disable-next-line no-console 189 | console.log(`${requestedFile} not found, returning null`) 190 | return callback(404) 191 | } 192 | 193 | // Wait for the file to become available, downloading from the network at 194 | // highest priority. This ensures we don't return a path to a file that 195 | // hasn't finished downloading yet. 196 | return returnFile.getBuffer(function getBuffer (e) { 197 | // We don't actually care about the buffer, we only care if the file 198 | // was downloaded 199 | if (e) return callback(e) 200 | // Generate the path to the file on the local fs 201 | const file = path.join(__dirname, 202 | config.downloadDir, 203 | hash, 204 | returnFile.path) 205 | 206 | // Give the file back to electron 207 | console.log(`Returning: ${file}`)// eslint-disable-line no-console 208 | return callback({ path: file }) 209 | }) 210 | }) 211 | } 212 | 213 | // registerTorrentProtocol takes an instance of electron and registers a 214 | // handler for our new protocol, allowing the instance of electron to resolve 215 | // requests against a torrent 216 | function registerTorrentProtocol (localElectron, cb) { 217 | localElectron 218 | .protocol 219 | .registerFileProtocol(config.protocol, peerProtocolHandler, 220 | function registeredProtocol (e) { 221 | if (e) { 222 | return cb(e) 223 | } 224 | // Don't treat our new protocol like http 225 | electron 226 | .protocol 227 | .registerStandardSchemes([config.protocol]) 228 | // Done setting up our new protocol 229 | return cb() 230 | }) 231 | } 232 | 233 | // Begin seeding torrents from previous sessions 234 | function restartTorrents (cb) { 235 | const dir = path.join(__dirname, config.downloadDir) 236 | fs.readdir(dir, function reloadTorrents (e, torrents) { 237 | if (e) return cb(e) 238 | // Each folder in that directory is a torrent hash, re-add them 239 | return torrents.forEach(function reloadTorrent (infoHash) { 240 | try { 241 | // Try parsing the torrent first to keep webtorrent from throwing in 242 | // async land 243 | parseTorrent(infoHash) 244 | client.add(infoHash, 245 | { path: path.join(__dirname, config.downloadDir, infoHash) }) 246 | } catch (exception) { 247 | // eslint-disable-next-line no-console 248 | console.error(`Unable to re-add torrent: ${infoHash}`) 249 | // eslint-disable-next-line no-console 250 | console.error(exception) 251 | } 252 | }) 253 | }) 254 | } 255 | 256 | // configureElectron registers the custom protocol with or electron app 257 | function configureElectron () { 258 | // electron is now ready to be configured 259 | registerTorrentProtocol(electron, function init2 (e) { 260 | if (e) { 261 | throw e 262 | } 263 | 264 | // peer:// protocol has been registered 265 | const opts = {} 266 | 267 | // Start the application 268 | const mainWindow = new electron.BrowserWindow(opts) 269 | 270 | // Hide the menubar 271 | mainWindow.setMenu(null) 272 | 273 | // Load the UI 274 | mainWindow.loadURL(`file://${path.join(__dirname, 'ui', 'index.html')}`) 275 | // Let's resume all the torrents from a previous session. No need for a 276 | // callback here since we do this after the UI has loaded and we don't take 277 | // any action when this completes. Just log the error if there is one. 278 | // eslint-disable-next-line no-console 279 | restartTorrents(console.error) 280 | }) 281 | } 282 | 283 | // The main logic of our application. This is what runs when called directly 284 | // from the command line 285 | function applicationLogic () { 286 | electron.app.on('ready', configureElectron) 287 | } 288 | 289 | // Kick off the application 290 | applicationLogic() 291 | -------------------------------------------------------------------------------- /ui/ui.js: -------------------------------------------------------------------------------- 1 | /* 2 | * ui.js 3 | * 4 | * At the time of writting, this is the most meta-thinking file in this 5 | * directory. I suspect it may not be immediately obvious why this file exists 6 | * and why it is necessary. 7 | * 8 | * Essentially, in our browser, we have a state that drives the entire user 9 | * experience. That state is the currently selected tab. When we change tabs, 10 | * a majority of the UI needs to be updated to reflect the newly selected tab. 11 | * This includes everything from the navigation buttons to the visible webview. 12 | * 13 | * ui.js has a collection of functions which together localize all of the logic 14 | * behind tab operations to this file. 15 | */ 16 | 17 | // Create a global singleton that scopes the globally exposed functions from 18 | // this application 19 | // eslint-disable-next-line prefer-const, no-use-before-define, no-var 20 | var peerWeb = peerWeb || {} 21 | 22 | // Scope all functions exposed by this file to ui 23 | peerWeb.ui = peerWeb.ui || {} 24 | 25 | // Scope all functions in this file to prevent polluting the global space 26 | ;(function scope () { 27 | // claimOwnership will take the ID of a tab, and handle all the logic 28 | // necessary to update the UI showing that the tab is selected 29 | peerWeb.ui.claimOwnership = function claimOwnership (id) { 30 | // We need to fetch all of the webviews on the page, remove the CSS class 31 | // `selected` from any that have it set, and add the CSS class name 32 | // `selected` to the webview that belongs to this tab. 33 | const webviews = window.document.getElementsByTagName('webview') 34 | for (let i = 0; i < webviews.length; i++) { 35 | const webview = webviews[i] 36 | // Remove `selected` from all of the webviews, even the one that is 37 | // being selected. This prevents us from adding `selected` to the same 38 | // element multiple times. 39 | webview.className = peerWeb.utils.removeClass(webview, 'selected') 40 | // If this webview matches the id of the selected tab, add the CSS class 41 | // `selected` to it. We force id to be a string for this comparison so 42 | // that we can use strict equality. 43 | if (peerWeb.utils.getId(webview) === id.toString()) { 44 | webview.className = peerWeb.utils.addClass(webview, 'selected') 45 | 46 | // Update the searchbar to match the webview's content 47 | const searchBar = window.document.getElementById('search-bar') 48 | searchBar.value = webview.src 49 | 50 | // Make the navigation buttons inactive if there is no history 51 | const navBack = window.document.getElementById('search-back') 52 | const navForward = window.document.getElementById('search-forward') 53 | navBack.className = peerWeb.utils.removeClass(navBack, 'inactive') 54 | navForward 55 | .className = peerWeb.utils.removeClass(navForward, 'inactive') 56 | navBack.className = peerWeb.utils.addClass(navBack, 'inactive') 57 | navForward.className = peerWeb.utils.addClass(navForward, 'inactive') 58 | 59 | try { 60 | if (webview.canGoBack()) { 61 | navBack.className = peerWeb.utils.removeClass(navBack, 'inactive') 62 | } 63 | 64 | if (webview.canGoForward()) { 65 | navForward 66 | .className = peerWeb.utils.removeClass(navForward, 'inactive') 67 | } 68 | } catch (e) { 69 | webview.addEventListener('dom-ready', function webviewReady () { 70 | if (webview.canGoBack()) { 71 | navBack.className = peerWeb.utils.removeClass(navBack, 'inactive') 72 | } 73 | 74 | if (webview.canGoForward()) { 75 | navForward 76 | .className = peerWeb.utils.removeClass(navForward, 'inactive') 77 | } 78 | }) 79 | } 80 | } 81 | } 82 | 83 | // Do the same thing for tabs that we did for webviews above 84 | const tabs = window.document.getElementsByClassName('tab') 85 | for (let i = 0; i < tabs.length; i++) { 86 | const tab = tabs[i] 87 | tab.className = peerWeb.utils.removeClass(tab, 'selected') 88 | if (peerWeb.utils.getId(tab) === id.toString()) { 89 | tab.className = peerWeb.utils.addClass(tab, 'selected') 90 | } 91 | } 92 | 93 | // Do the same thing for the progressBars 94 | const progressBars = window.document.getElementsByClassName('progress') 95 | for (let i = 0; i < progressBars.length; i++) { 96 | const progressBar = progressBars[i] 97 | progressBar.className = peerWeb.utils.removeClass(progressBar, 'selected') 98 | if (peerWeb.utils.getId(progressBar) === id.toString()) { 99 | progressBar.className = peerWeb.utils.addClass(progressBar, 'selected') 100 | } 101 | } 102 | } 103 | 104 | // remove will take the ID of a tab, and handle all the logic necessary to 105 | // remove the tab and it's dependencies from the UI. 106 | peerWeb.ui.remove = function remove (id) { 107 | // Lets get all of the webviews so we can find the one we need to remove 108 | const webviews = window.document.getElementsByTagName('webview') 109 | // Search through all the webviews until we find the one with the proper id 110 | for (let i = 0; i < webviews.length; i++) { 111 | const webview = webviews[i] 112 | // Check this webview for the id 113 | if (peerWeb.utils.getId(webview) === id) { 114 | // If this webview is the one we are looking for, simply remove it from 115 | // the document 116 | webview.remove() 117 | // Since we have found the webview, there is nothing left to do 118 | break 119 | } 120 | } 121 | 122 | // Do the same thing for the progressBars 123 | const progressBars = window.document.getElementsByClassName('progress') 124 | for (let i = 0; i < progressBars.length; i++) { 125 | const progressBar = progressBars[i] 126 | if (peerWeb.utils.getId(progressBar) === id.toString()) { 127 | progressBar.remove() 128 | break 129 | } 130 | } 131 | 132 | // Now we will do the same exact thing for tabs that we did for the 133 | // webviews above 134 | const tabs = window.document.getElementsByClassName('tab') 135 | 136 | for (let i = 0; i < tabs.length; i++) { 137 | const tab = tabs[i] 138 | if (peerWeb.utils.getId(tab) === id) { 139 | // We will also need to check if this tab is the currently selected 140 | // one. If so, we need to switch the user to another tab before 141 | // deleting the one they are currently using. 142 | if (peerWeb.utils.hasClass(tab, 'selected')) { 143 | if (tabs[i + 1] != null) { 144 | peerWeb.ui.claimOwnership(peerWeb.utils.getId(tabs[i + 1])) 145 | } else if (tabs[i - 1] != null) { 146 | peerWeb.ui.claimOwnership(peerWeb.utils.getId(tabs[i - 1])) 147 | } 148 | } 149 | 150 | tab.remove() 151 | 152 | break 153 | } 154 | } 155 | } 156 | 157 | // add will take the ID of a tab, and handle all the logic necessary to 158 | // create a new tab with that ID and add it to the browser. 159 | peerWeb.ui.add = function add (id) { 160 | // First, we create a new tab element 161 | const newTabElement = window.document.createElement('span') 162 | const tabName = window.document.createElement('span') 163 | let tabNameText = window.document.createTextNode('New Tab') 164 | tabName.appendChild(tabNameText) 165 | tabName.className = 'tab-name' 166 | newTabElement.appendChild(tabName) 167 | newTabElement.id = `tab-${id}` 168 | newTabElement.className = 'tab' 169 | const closeTabElement = window.document.createElement('span') 170 | closeTabElement.appendChild(window.document.createTextNode('x')) 171 | closeTabElement.className = 'close-tab' 172 | newTabElement.appendChild(closeTabElement) 173 | 174 | // Next, we wire up all of the listeners for the new tab 175 | closeTabElement.addEventListener('click', peerWeb.tabs.closeTab) 176 | newTabElement.addEventListener('click', peerWeb.tabs.tabClick) 177 | 178 | // Next, we will create the webview that the tab will own 179 | const newWebViewElement = window.document.createElement('webview') 180 | newWebViewElement.id = `webview-${id}` 181 | 182 | // Register event listeners for the webview to wire up its state to the UI 183 | newWebViewElement.addEventListener('did-stop-loading', 184 | peerWeb.ui.endLoading) 185 | newWebViewElement.addEventListener('did-start-loading', 186 | peerWeb.ui.beginLoading) 187 | 188 | // Next, we create the progress bar that will belong to the webview 189 | const newProgressBar = window.document.createElement('div') 190 | newProgressBar.id = `progress-${id}` 191 | newProgressBar.className = 'progress' 192 | const newProgressComplete = window.document.createElement('div') 193 | newProgressComplete.className = 'complete' 194 | newProgressBar.appendChild(newProgressComplete) 195 | 196 | // Finally, we add everything to the document so the user can see it 197 | window.document.getElementById('tab-row').appendChild(newTabElement) 198 | window.document.getElementById('content').appendChild(newProgressBar) 199 | window.document.getElementById('content').appendChild(newWebViewElement) 200 | 201 | // Give the user something to look at for their new tab 202 | newWebViewElement.src = 'http://will.blankenship.io/peerweb' 203 | 204 | // Wire up the tab's title to the webview 205 | newWebViewElement 206 | .addEventListener('page-title-updated', function updateTabTitle (event) { 207 | tabNameText.remove() 208 | tabNameText = window.document.createTextNode(event.title) 209 | tabName.appendChild(tabNameText) 210 | }) 211 | 212 | // If the webview element is currently selected, update the navbar to 213 | // reflect its src when this function is called 214 | function updateNavBar (url) { 215 | // If the webview isn't selected, there is nothing to do 216 | if (!peerWeb.utils.hasClass(newWebViewElement, 'selected')) { 217 | return null 218 | } 219 | 220 | // Update the UI to reflect the new src 221 | return peerWeb.ui.claimOwnership(id) 222 | } 223 | 224 | // Whenever the webview changes its src, update the navbar 225 | newWebViewElement.addEventListener('did-navigate', updateNavBar) 226 | newWebViewElement.addEventListener('did-navigate-in-page', updateNavBar) 227 | 228 | peerWeb.ui.claimOwnership(id) 229 | } 230 | 231 | // beginLoading handles updating the browser state to reflect that a tab has 232 | // started loading new content. It is an event listener that should be 233 | // registered in response to an event emitted from a webview 234 | peerWeb.ui.beginLoading = function beginLoading () { 235 | // `this` refers to the webview that triggered the event 236 | const id = peerWeb.utils.getId(this) 237 | const progressBar = window.document.getElementById(`progress-${id}`) 238 | // We will use the `progress` attribute to calculate how much time has 239 | // passed since the download began. The width of the `completed` bar inside 240 | // the progress bar will be a function of the amount of time that has 241 | // passed 242 | progressBar.setAttribute('progress', Date.now()) 243 | progressBar.className = peerWeb.utils.addClass(progressBar, 'loading') 244 | } 245 | 246 | // endLoading handles updating the browser state to reflect that a tab has 247 | // finished loading new content. It is an event listener that should be 248 | // registered in response to an event emitted from a webview 249 | peerWeb.ui.endLoading = function endLoading () { 250 | // `this` refers to the webview that triggered the event 251 | const id = peerWeb.utils.getId(this) 252 | const progressBar = window.document.getElementById(`progress-${id}`) 253 | // We no longer need to track the progress of this download 254 | progressBar.removeAttribute('progress') 255 | progressBar.className = peerWeb.utils.removeClass(progressBar, 'loading') 256 | } 257 | 258 | // navigate will take a url and will update the browser state to prepare for 259 | // the user to be sent to a new webpage. This makes the assumption that the 260 | // navigation is happening on the _currently selected_ tab. 261 | peerWeb.ui.navigate = function navigate (url) { 262 | // collect all the webviews and find which one is currently selected 263 | const webviews = window.document.getElementsByTagName('webview') 264 | // We create the webview variable outside of the for loop, and terminate 265 | // the loop immediately when we find the webview we are looking for. This 266 | // ensures webview will either be the currently selected webview or that 267 | // we will navigate the last tab in the event there is no selected webview 268 | // (which would be a bug) 269 | let webview = null 270 | for (let i = 0; i < webviews.length; i++) { 271 | webview = webviews[i] 272 | if (peerWeb.utils.hasClass(webview, 'selected')) { 273 | break 274 | } 275 | } 276 | 277 | // We have found the webview we are looking for as defined above, now lets 278 | // send it to the page we are looking for 279 | webview.loadURL(url) 280 | } 281 | 282 | // updateProgress will go through the UI and update all progress bars that 283 | // belong to a webview that is loading content and is visible 284 | peerWeb.ui.updateProgress = function updateProgress () { 285 | // Get the currently loading and visible progress bar if one exists. 286 | // There will only ever be one of these at a time, since two progressBars 287 | // can not be visible, and it is possible that the visible webview will not 288 | // be loading so there may not be work to do here 289 | const progressBar = window.document 290 | .querySelector('.progress.selected.loading .complete') 291 | 292 | // If we didn't find a visible progress bar that belonged to a loading 293 | // webview, we don't have any work left to do 294 | if (progressBar == null) { 295 | return null 296 | } 297 | 298 | // We store the initial start time of the webview download as the progress 299 | // attribute on the DOM element of the progress bar, so lets grab that 300 | // and turn it into a JavaScript date. 301 | const started = progressBar.parentElement.getAttribute('progress') 302 | 303 | // Lets get how much time has passed since the download started 304 | const progress = Date.now() - started 305 | 306 | // Lets calculate the width of the progress bar using a logaritmic function 307 | // that starts out having the progress bar be "really" fast, and then slows 308 | // down quickly to inch its way towards 100%. This function will take quite 309 | // a while to reach 100%. You may wonder why the width is not a function of 310 | // the actual download progress and instead a function of time. Well, we 311 | // would be hard pressed to know how much data any one website needs to 312 | // pull over the wire, since a successfully downloaded file could trigger 313 | // the download of even more files. Instead of implementing the extremely 314 | // complex logic of tracking the files pending download, and updating that 315 | // when new files are added and reflecting all of that in the progress bar, 316 | // we opted in for a simpler feedback mechanism. 317 | let width = Math.log(progress) * 10 318 | 319 | // Make sure progress never exceeds 100% 320 | if (width > 99.9) width = 99.9 321 | 322 | // Finally, lets update the width of the progress bar 323 | progressBar.setAttribute('style', `width:${width}`) 324 | 325 | return null 326 | } 327 | 328 | // Create an interval that updates all progress bars that are visible and 329 | // loading. I suspect this will be hot code, and I'm a little concerned about 330 | // querying the DOM in this. We will gather metrics in the future to see how 331 | // this is impacting performance 332 | setInterval(peerWeb.ui.updateProgress, 100) 333 | })() 334 | --------------------------------------------------------------------------------