├── .github ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── ISSUE_TEMPLATE │ ├── BUG_REPORT.md │ └── FEATURE_REQUEST.md ├── PULL_REQUEST_TEMPLATE.md └── workflows │ └── build.yml ├── .gitignore ├── LICENSE ├── README.md ├── banner.png ├── docs ├── README.md ├── Theme.md └── images │ └── browser-theme-protocol-example.png ├── package-lock.json ├── package.json ├── public ├── icon.icns └── icon.png └── src ├── actions.js ├── auto-updater.js ├── context-menu.js ├── find-menu.js ├── main.js ├── pages ├── 404.html ├── about.html ├── clock.js ├── home.html ├── index.html ├── nav-box.js ├── p2p │ ├── chat │ │ ├── app.js │ │ ├── index.html │ │ ├── send.svg │ │ └── styles.css │ ├── editor │ │ ├── codeEditor.js │ │ ├── common.js │ │ ├── dweb.js │ │ ├── index.html │ │ └── styles.css │ ├── upload │ │ └── index.html │ └── wiki │ │ ├── index.html │ │ ├── script.js │ │ └── static │ │ ├── assets │ │ ├── favicon.ico │ │ └── wikipedia-on-ipfs.png │ │ └── styles.css ├── peer-bar.js ├── plan1.html ├── preload.js ├── static │ ├── assets │ │ ├── logo.png │ │ ├── redwoods.jpg │ │ └── svg │ │ │ ├── build.svg │ │ │ ├── chat.svg │ │ │ ├── close.svg │ │ │ ├── down.svg │ │ │ ├── home.svg │ │ │ ├── left.svg │ │ │ ├── people.svg │ │ │ ├── plus.svg │ │ │ ├── reload.svg │ │ │ ├── right.svg │ │ │ ├── up.svg │ │ │ ├── upload.svg │ │ │ └── wikipedia.svg │ ├── elves │ │ ├── elf.js │ │ ├── goodbye-world.js │ │ ├── hello-world.js │ │ └── qr-code.js │ └── js │ │ └── vendor │ │ └── qr-creator │ │ ├── LICENSE │ │ └── qr-creator.js ├── theme │ ├── base.css │ ├── home.css │ ├── index.css │ ├── plan1.css │ ├── style.css │ └── vars.css └── track-box.js ├── protocols ├── config.js ├── helia │ ├── directoryListingTemplate.js │ ├── helia.js │ └── libp2p.js ├── hyper-handler.js ├── ipfs-handler.js ├── peersky-protocol.js ├── theme-handler.js └── web3-handler.js ├── renderer.js ├── utils.js └── window-manager.js /.github/CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at contact@p2plabs.xyz. 63 | All complaints will be reviewed and investigated promptly and fairly. 64 | 65 | All community leaders are obligated to respect the privacy and security of the 66 | reporter of any incident. 67 | 68 | ## Enforcement Guidelines 69 | 70 | Community leaders will follow these Community Impact Guidelines in determining 71 | the consequences for any action they deem in violation of this Code of Conduct: 72 | 73 | ### 1. Correction 74 | 75 | **Community Impact**: Use of inappropriate language or other behavior deemed 76 | unprofessional or unwelcome in the community. 77 | 78 | **Consequence**: A private, written warning from community leaders, providing 79 | clarity around the nature of the violation and an explanation of why the 80 | behavior was inappropriate. A public apology may be requested. 81 | 82 | ### 2. Warning 83 | 84 | **Community Impact**: A violation through a single incident or series 85 | of actions. 86 | 87 | **Consequence**: A warning with consequences for continued behavior. No 88 | interaction with the people involved, including unsolicited interaction with 89 | those enforcing the Code of Conduct, for a specified period of time. This 90 | includes avoiding interactions in community spaces as well as external channels 91 | like social media. Violating these terms may lead to a temporary or 92 | permanent ban. 93 | 94 | ### 3. Temporary Ban 95 | 96 | **Community Impact**: A serious violation of community standards, including 97 | sustained inappropriate behavior. 98 | 99 | **Consequence**: A temporary ban from any sort of interaction or public 100 | communication with the community for a specified period of time. No public or 101 | private interaction with the people involved, including unsolicited interaction 102 | with those enforcing the Code of Conduct, is allowed during this period. 103 | Violating these terms may lead to a permanent ban. 104 | 105 | ### 4. Permanent Ban 106 | 107 | **Community Impact**: Demonstrating a pattern of violation of community 108 | standards, including sustained inappropriate behavior, harassment of an 109 | individual, or aggression toward or disparagement of classes of individuals. 110 | 111 | **Consequence**: A permanent ban from any sort of public interaction within 112 | the community. 113 | 114 | ## Attribution 115 | 116 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 117 | version 2.0, available at 118 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 119 | 120 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 121 | enforcement ladder](https://github.com/mozilla/diversity). 122 | 123 | [homepage]: https://www.contributor-covenant.org 124 | 125 | For answers to common questions about this code of conduct, see the FAQ at 126 | https://www.contributor-covenant.org/faq. Translations are available at 127 | https://www.contributor-covenant.org/translations. 128 | -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | > Contributions are always welcome! 4 | 5 | ## Issues 6 | 7 | * Do not hesitate and [create a new Issue](https://github.com/p2plabsxyz/peersky-browser/issues/new/choose) if you see a bug, room for improvement or simply have a question. 8 | * Feel free to work on issues that are [not assigned yet](https://github.com/p2plabsxyz/peersky-browser/issues?utf8=✓&q=is%3Aissue+is%3Aopen+no%3Aassignee). 9 | * Do not create a pull request without an issue before discussing the problem. 10 | 11 | ## Pull Requests 12 | 13 | * Make sure your PR comes with its own tests. 14 | * Always be descriptive in your PR -> add screenshots, explain in detail what improvements you did, or bugs you solved. 15 | 16 | ## Commits 17 | 18 | * Your commit messages "should" follow the conventional commits guidelines. Learn more about it [here](https://www.conventionalcommits.org/en/v1.0.0/). 19 | 20 | ## Code Style 21 | 22 | * Make sure to commit in the same style that we are committing until now on the project. 23 | * Run `prettier` in each code file. 24 | 25 | ## Questions 26 | * Please reach out to us at contact@p2plabs.xyz. 27 | 28 | *Hope to see your username on our list of [contributors](https://github.com/p2plabsxyz/peersky-browser/graphs/contributors) 🎉* 29 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/BUG_REPORT.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Issue Title 3 | about: Create a bug-report to help us address errors in the repo. 4 | name: Bug 5 | label: Bug 6 | Assignee: '' 7 | 8 | --- 9 | 10 | Define You: 11 | 12 | - [ ] Contributor 13 | 14 | 15 | **Describe the Bug** 16 | 17 | 18 | 19 | **Steps to Reproduce** 20 | 21 | Steps to reproduce the behavior: 22 | 23 | 1. 24 | 2. 25 | 3. 26 | 4. 27 | 28 | **Expected Behavior** 29 | 30 | 31 | 32 | **Actual Behavior** 33 | 34 | 35 | 36 | **Screenshots** 37 | 38 | 39 | 40 | **Additional Details** 41 | 42 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/FEATURE_REQUEST.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Issue Title 3 | about: Suggest an idea for this project 4 | name: Feature Request 5 | label: Feature Request 6 | Assignee: '' 7 | 8 | --- 9 | 10 | Define You: 11 | 12 | - [ ] Contributor 13 | 14 | 15 | **Is your feature request related to a problem? Please describe.** 16 | 17 | 18 | 19 | **Describe the solution you'd like...** 20 | 21 | 22 | 23 | **Describe alternatives you've considered?** 24 | 25 | 26 | 27 | **Approach to be followed (optional):** 28 | 29 | 30 | 31 | **Additional context** 32 | 33 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## Related Issue (if any) 2 | 3 | 4 | 5 | Closes: #[issue number that will be closed through this PR] 6 | 7 | ### Describe the add-ons or changes you've made 8 | 9 | 10 | 11 | ## Type of change 12 | 13 | 14 | 18 | - [ ] Bug fix (non-breaking change which fixes an issue) 19 | - [ ] New feature (non-breaking change which adds functionality) 20 | - [ ] Code style update (formatting, local variables) 21 | - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) 22 | - [ ] This change requires a documentation update 23 | 24 | ## How Has This Been Tested? 25 | 26 | 28 | 29 | ## Checklist: 30 | 34 | - [ ] My code follows the "contribution guidelines" of this project. 35 | - [ ] I have performed a self-review of my own code. 36 | - [ ] I have commented my code, particularly wherever it was hard to understand. 37 | - [ ] My changes generate no new warnings. 38 | - [ ] Any dependent changes have been merged and published in downstream modules. 39 | 40 | ## Screenshots (Only for Front End and UI/UX Designers) 41 | 42 | Original | Updated 43 | :--------------------: |:--------------------: 44 | Original Screenshot | Updated Screenshot | 45 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | # https://github.com/AgregoreWeb/agregore-browser/blob/master/.github/workflows/build.yml 2 | 3 | name: Build/release 4 | 5 | on: 6 | push: 7 | ## Run on tags starting with `v*` 8 | tags: 9 | - 'v*' 10 | 11 | # Allows you to run this workflow manually from the Actions tab 12 | workflow_dispatch: 13 | 14 | jobs: 15 | release: 16 | continue-on-error: true 17 | runs-on: ${{ matrix.os }} 18 | 19 | strategy: 20 | matrix: 21 | os: [macos-latest, ubuntu-latest, windows-latest] 22 | 23 | steps: 24 | - name: Install libarchive-tools for pacman build # Related https://github.com/electron-userland/electron-builder/issues/4181 25 | if: startsWith(matrix.os, 'ubuntu') 26 | run: sudo apt-get install libarchive-tools 27 | 28 | - name: Check out Git repository 29 | uses: actions/checkout@v3 30 | with: 31 | submodules: true 32 | 33 | - name: Install Node.js, NPM and Yarn 34 | uses: actions/setup-node@v3 35 | with: 36 | node-version: 'lts/*' 37 | 38 | - name: Non-tag specific build step 39 | if: ${{ !startsWith(github.ref, 'refs/tags/v') }} 40 | run: echo "This build was triggered without a tag." 41 | 42 | - name: Build binaries with electron-builder 43 | uses: coparse-inc/action-electron-builder@29a7606c7d726b5b0f4dc2f334026f58bea0e1bb # v1.6.0 but safer than a tag that can be changed 44 | with: 45 | max_attempts: 2 46 | github_token: ${{ secrets.github_token }} 47 | release: ${{ startsWith(github.ref, 'refs/tags/v') }} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | .pnpm-debug.log* 9 | 10 | # Diagnostic reports (https://nodejs.org/api/report.html) 11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # Snowpack dependency directory (https://snowpack.dev/) 46 | web_modules/ 47 | 48 | # TypeScript cache 49 | *.tsbuildinfo 50 | 51 | # Optional npm cache directory 52 | .npm 53 | 54 | # Optional eslint cache 55 | .eslintcache 56 | 57 | # Optional stylelint cache 58 | .stylelintcache 59 | 60 | # Microbundle cache 61 | .rpt2_cache/ 62 | .rts2_cache_cjs/ 63 | .rts2_cache_es/ 64 | .rts2_cache_umd/ 65 | 66 | # Optional REPL history 67 | .node_repl_history 68 | 69 | # Output of 'npm pack' 70 | *.tgz 71 | 72 | # Yarn Integrity file 73 | .yarn-integrity 74 | 75 | # dotenv environment variable files 76 | .env 77 | .env.development.local 78 | .env.test.local 79 | .env.production.local 80 | .env.local 81 | 82 | # parcel-bundler cache (https://parceljs.org/) 83 | .cache 84 | .parcel-cache 85 | 86 | # Next.js build output 87 | .next 88 | out 89 | 90 | # Nuxt.js build / generate output 91 | .nuxt 92 | dist 93 | 94 | # Gatsby files 95 | .cache/ 96 | # Comment in the public line in if your project uses Gatsby and not Next.js 97 | # https://nextjs.org/blog/next-9-1#public-directory-support 98 | # public 99 | 100 | # vuepress build output 101 | .vuepress/dist 102 | 103 | # vuepress v2.x temp and cache directory 104 | .temp 105 | .cache 106 | 107 | # Docusaurus cache and generated files 108 | .docusaurus 109 | 110 | # Serverless directories 111 | .serverless/ 112 | 113 | # FuseBox cache 114 | .fusebox/ 115 | 116 | # DynamoDB Local files 117 | .dynamodb/ 118 | 119 | # TernJS port file 120 | .tern-port 121 | 122 | # Stores VSCode versions used for testing VSCode extensions 123 | .vscode-test 124 | 125 | # yarn v2 126 | .yarn/cache 127 | .yarn/unplugged 128 | .yarn/build-state.yml 129 | .yarn/install-state.gz 130 | .pnp.* 131 | 132 | # Electron-builder output 133 | build/ 134 | 135 | # macOS system files 136 | .DS_Store 137 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 p2plabs.xyz 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |

4 | 5 |

Peersky Browser

6 | 7 |
8 | GitHub Actions Workflow Status 9 | platform 10 | GitHub Pre-release 11 | 12 | Mastodon Follow 13 | Peersky, displaying multiple overlapping windows. The foreground window shows a search bar with DuckDuckGo, while the background contains a Wikipedia page on ipns:// about Earth and a private hyper:// chat. The central window features a scenic forest image with sunlight filtering through tall trees, and a clock 14 |
15 | 16 | 💻 [Download](https://peersky.p2plabs.xyz/) 17 | 18 | ## Roadmap 19 | 20 | - [x] Basic browser navigation: 21 | 22 | - [x] Back 23 | - [x] Forward 24 | - [x] Reload 25 | - [x] Browser protocol (peersky://) 26 | - [x] Home page (peersky://home) 27 | - [x] Cross browser themeing ([browser://theme/](https://github.com/p2plabsxyz/peersky-browser/blob/main/docs/Theme.md)) 28 | - [x] Search engine 29 | - DuckDuckGo (default) 30 | - Ecosia 31 | - [ ] [Tabs](https://github.com/p2plabsxyz/peersky-browser/issues/11) 32 | 33 | - [x] IPFS protocol handler: 34 | 35 | - [x] Run a local [Helia](https://helia.io/) node 36 | - [x] `ipfs://` / `ipns://` native URLs support 37 | - [x] Directory listings support 38 | - [x] Native ENS domain resolution: 39 | - [x] Resolve `.eth` domains directly to IPFS/IPNS content without centralized gateways (e.g., `ipfs://vitalik.eth`). 40 | - [x] Local caching for resolved ENS content to enhance performance and reduce RPC calls. 41 | 42 | - [x] Hypercore protocol handler: 43 | 44 | - [x] Run a local [hyper](https://holepunch.to/) node 45 | - [x] `hyper://` native URLs support 46 | 47 | - [x] Web3 protocol handler: 48 | 49 | - [x] Run a local [web3 protocol](https://docs.web3url.io/) node 50 | - [x] Access on-chain websites. 51 | - [x] Fetch data from smart contracts using auto, manual, and resource request resolve modes. 52 | - [x] Query account balances or other data directly from smart contracts. 53 | 54 | - [x] P2P Applications: 55 | 56 | - [x] `peersky://p2p/chat/` 57 | - Peer-to-peer messaging over Hyper 58 | - [x] `peersky://p2p/upload/` 59 | - Decentralized file storage 60 | - [x] `peersky://p2p/editor/` 61 | - Build and publish websites 62 | - [x] `peersky://p2p/wiki/` 63 | - Browse Wikipedia over IPFS 64 | - [x] [reader.p2plabs.xyz](https://reader.distributed.press/) 65 | - A p2p offline ActivityPub client for reading and following microblogs on the fediverse. 66 | 67 | - [x] Electron’s Auto-updater: 68 | 69 | - [x] Download and install the latest release from Github automatically 70 | 71 | - [x] Context menu: 72 | 73 | - [x] Back / Forward 74 | - [x] Reload 75 | - [x] Inspect 76 | - [x] Undo / Redo 77 | - [x] Cut / Copy / Paste 78 | - [x] Copy Link Address 79 | - [x] Open Link in New Tab 80 | 81 | - [x] Find in page: 82 | - [x] Search for text within a document or web page 83 | 84 | - [x] Window state persistence: 85 | - [x] Save and restore open windows on app launch 86 | 87 | - [x] Keyboard shortcuts: 88 | 89 | - [x] New Window: `CommandOrControl+N` 90 | - [x] Back: `CommandOrControl+[` 91 | - [x] Forward: `CommandOrControl+]` 92 | - [x] Reload: `CommandOrControl+R` 93 | - [x] Find in Page: `CommandOrControl+F` 94 | - [x] Open Dev Tools: `CommandOrControl+Shift+I` 95 | - [x] Focus URL Bar: `CommandOrControl+L` 96 | - [x] Minimize: `CommandOrControl+M` 97 | - [x] Close: `CommandOrControl+W` 98 | - [x] Toggle Full Screen: `F11` 99 | 100 | - [ ] Settings (peersky://settings): 101 | 102 | - [ ] Switch search engines 103 | - [ ] Set custom home page wallpapers 104 | - [ ] Hide/show the home page clock 105 | - [ ] Change themes 106 | - [ ] Clear browser cache 107 | 108 | - [ ] [Web extensions](https://github.com/p2plabsxyz/peersky-browser/issues/19): 109 | - [ ] Ability to add and manage extensions 110 | - [ ] Default extensions 111 | - [Ad-blocker](https://github.com/gorhill/uBlock) 112 | - [DScan](https://github.com/p2plabsxyz/dscan) 113 | - .. 114 | 115 | - [ ] History (peersky://history): 116 | 117 | - [ ] Suggestions based on the browser history when typing in URL prompt 118 | 119 | - [ ] QR Code generator: 120 | 121 | - [ ] Option to generate QR Code for every page in the URL prompt 122 | 123 | - [ ] Bookmarks (peersky://bookmarks): 124 | 125 | - [ ] Option to add favourite pages in the nav bar (peersky://bookmarks) 126 | 127 | - [ ] Archive (peersky://archive): 128 | 129 | - [ ] List and showcase published content from `peersky://p2p/` apps for enhanced discoverability. 130 | - [ ] Provide metadata (e.g., creation date, content type) to improve navigation and usability. 131 | - [ ] Ability to download all the hashes of published data in a .json file. 132 | 133 | ## Development 134 | 135 | ### Node.js and npm Setup 136 | 137 | Please refer to the [Node.js official documentation](https://nodejs.org/) to install Node.js. Once installed, npm (Node Package Manager) will be available, allowing you to run commands like `npx` and `npm`. 138 | 139 | - **npm**: Comes bundled with Node.js. Verify installation by running: 140 | ```bash 141 | node -v 142 | npm -v 143 | ``` 144 | 145 | ### Install dependencies 146 | 147 | ```bash 148 | npm install 149 | ``` 150 | 151 | ### Start the app 152 | 153 | ```bash 154 | npm start 155 | ``` 156 | 157 | ### Build 158 | After development of the browser, run the following command. This will create a production build. 159 | 160 | ```bash 161 | npm run build 162 | # For Intel and M1 macs 163 | ``` 164 | 165 | Now, the `dist` folder will appear in the root directory. 166 | 167 | ```bash 168 | npm run build-all 169 | # For macOS, Linux, and Windows 170 | ``` 171 | 172 | ## Contribute 173 | 174 | - Thanks for your interest in contributing to Peersky Browser. There are many ways you can contribute to the project. 175 | - To start, take a few minutes to read the "[contribution guide](https://github.com/p2plabsxyz/peersky-browser/blob/main/.github/CONTRIBUTING.md)". 176 | - We look forward to your [pull requests](https://github.com/p2plabsxyz/peersky-browser/pulls) and / or involvement in our [issues page](https://github.com/p2plabsxyz/peersky-browser/issues). 177 | 178 | ## License 179 | 180 | Peersky Browser is licensed under the [MIT License](https://github.com/p2plabsxyz/peersky-browser/blob/main/LICENSE). 181 | -------------------------------------------------------------------------------- /banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/p2plabsxyz/peersky-browser/884f0e70e38540494e2d541ea092a19fe4622239/banner.png -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # Peersky Browser Documentation 2 | 3 | - [Theme Protocol](Theme.md) -------------------------------------------------------------------------------- /docs/Theme.md: -------------------------------------------------------------------------------- 1 | # Theme Protocol (`browser://theme/`) 2 | 3 | ## Overview 4 | 5 | The `browser://theme/` protocol provides a standardized way for web applications to access browser-level CSS styles and theme variables in [Peersky](https://peersky.p2plabs.xyz/) and other compatible browsers, such as [Agregore](https://agregore.mauve.moe/). This protocol ensures consistent theming across different browsers by serving CSS files with a common set of variables. It allows developers to build applications that adapt to the browser's theme without needing browser-specific code. 6 | 7 | ![DWeb Scratchpad in Peersky and Agregore](./images/browser-theme-protocol-example.png) 8 | 9 | ## Purpose 10 | 11 | The goal of the `browser://theme/` protocol is to: 12 | 13 | - Enable cross-browser compatibility for theming in any browser, including p2p browsers like Peersky and Agregore. 14 | - Provide a unified set of theme variables using standardized `--browser-theme-` prefixes. 15 | - Allow web applications to import styles or variables without hardcoding browser-specific protocols (e.g., `peersky://` or `agregore://`). 16 | 17 | ## Implementation 18 | 19 | ### Protocol Handler 20 | 21 | The `browser://theme/` protocol is implemented in Peersky via a custom Electron protocol handler (`theme-handler.js`). It serves CSS files from the `src/pages/theme/` directory when requests are made to URLs like `browser://theme/vars.css` or `browser://theme/style.css`. 22 | 23 | - **Location**: Files are stored in `src/pages/theme/` (e.g., `vars.css`, `style.css`, `base.css`, `index.css`). 24 | - **URL Structure**: Requests to `browser://theme/` map to `src/pages/theme/`. 25 | - **Example**: `browser://theme/vars.css` serves `src/pages/theme/vars.css`. 26 | 27 | ### Theme Variable Standardization 28 | 29 | The `browser://theme/` protocol provides standardized theme variables prefixed with `--browser-theme-`, such as `--browser-theme-font-family`, `--browser-theme-background`, `--browser-theme-text-color`, `--browser-theme-primary-highlight`, and `--browser-theme-secondary-highlight`. These variables allow web applications to adapt to the host browser's theme without needing browser-specific code. 30 | 31 | Each browser implements these standardized variables by mapping them to their internal theme variables. For example: 32 | 33 | - In Peersky, `--browser-theme-background` is mapped to `--base01`, which is part of the Base16 color palette [Base16 Framework](https://github.com/chriskempson/base16). 34 | - In Agregore, `--browser-theme-background` is mapped to `--ag-theme-background`, which is defined in Agregore's theme configuration. 35 | 36 | This ensures that applications built for one browser can work seamlessly in another, as long as they use the standardized `--browser-theme-` variables. 37 | 38 | ### Cross-Browser Compatibility 39 | 40 | The `browser://theme/` protocol enables apps built for one browser to work seamlessly in another by providing standardized theme variables prefixed with `--browser-theme-`. These variables are mapped to each browser's internal theme variables, ensuring consistent theming across different browsers. 41 | 42 | For example: 43 | 44 | - In Peersky, `--browser-theme-background` is mapped to `--base01`, which is part of the Base16 color palette. 45 | - In Agregore, `--browser-theme-background` is mapped to `--ag-theme-background`, which is defined in Agregore's theme configuration. 46 | 47 | As a result, an app using `--browser-theme-background` will render with the appropriate background color for each browser, whether it's based on Base16 (as in Peersky) or another theme system (as in Agregore). 48 | 49 | Additionally, apps can use the full set of variables provided by each browser for more advanced theming, but for cross-browser compatibility, it's recommended to use the standardized `--browser-theme-` variables. 50 | 51 | ## Usage 52 | 53 | ### Importing Theme Styles 54 | 55 | Web applications can import theme styles or variables using ` 67 | ``` 68 | 69 | - **Import Default Styles**: 70 | 71 | ```html 72 | 73 | ``` 74 | 75 | - **Use Browser-Specific Variables** (for Agregore apps in Peersky): 76 | ```html 77 | 85 | ``` 86 | 87 | ## Theme Files (`browser://theme/`) 88 | 89 | - `vars.css`: Defines standardized `--browser-theme-`, Base16, and Peersky-specific CSS variables for theming. 90 | - `base.css`: Applies minimal default styles for unstyled pages, auto-injected by preload. 91 | - `style.css`: Opt-in comprehensive styling for web apps 92 | - `index.css`: Styles Peersky’s browser UI (e.g., navigation bar, URL input). 93 | - `home.css`: Styles Peersky’s home page with a background image and sidebar. 94 | -------------------------------------------------------------------------------- /docs/images/browser-theme-protocol-example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/p2plabsxyz/peersky-browser/884f0e70e38540494e2d541ea092a19fe4622239/docs/images/browser-theme-protocol-example.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "peersky-browser", 3 | "version": "1.0.0-beta.4", 4 | "description": "A minimal local-first p2p web browser: access, communicate, and publish offline.", 5 | "keywords": [ 6 | "peersky", 7 | "ipfs", 8 | "hyper", 9 | "web3", 10 | "browser", 11 | "dweb" 12 | ], 13 | "license": "MIT", 14 | "author": "Akhilesh Thite (https://akhilesh.art/)", 15 | "main": "src/main.js", 16 | "type": "module", 17 | "repository": "https://github.com/p2plabsxyz/peersky-browser", 18 | "scripts": { 19 | "test": "echo \"Error: no test specified\" && exit 1", 20 | "start": "electron .", 21 | "build": "electron-builder build --publish never", 22 | "build-all": "electron-builder build -mwl", 23 | "postinstall": "electron-builder install-app-deps" 24 | }, 25 | "build": { 26 | "buildDependenciesFromSource": true, 27 | "npmRebuild": true, 28 | "asar": true, 29 | "appId": "peersky.p2plabs.xyz", 30 | "productName": "Peersky Browser", 31 | "directories": { 32 | "output": "dist" 33 | }, 34 | "files": [ 35 | "node_modules/**/*", 36 | "package.json", 37 | "public/*", 38 | "src/**/*", 39 | "src/*" 40 | ], 41 | "mac": { 42 | "artifactName": "${name}-${version}-${os}-${arch}.${ext}", 43 | "gatekeeperAssess": false, 44 | "target": [ 45 | { 46 | "target": "default", 47 | "arch": [ 48 | "x64", 49 | "arm64" 50 | ] 51 | } 52 | ], 53 | "icon": "./public/icon.icns" 54 | }, 55 | "win": { 56 | "target": [ 57 | "nsis", 58 | "portable" 59 | ], 60 | "icon": "./public/icon.png" 61 | }, 62 | "linux": { 63 | "artifactName": "${name}-${version}-${os}-${arch}.${ext}", 64 | "executableArgs": [ 65 | "--enable-accelerated-video" 66 | ], 67 | "target": [ 68 | "deb", 69 | "AppImage", 70 | "apk", 71 | "pacman" 72 | ], 73 | "category": "Network;WebBrowser" 74 | }, 75 | "protocols": [ 76 | { 77 | "name": "webpages", 78 | "schemes": [ 79 | "http", 80 | "https" 81 | ], 82 | "role": "Viewer" 83 | }, 84 | { 85 | "name": "ipfs", 86 | "schemes": [ 87 | "ipfs", 88 | "ipns", 89 | "ipld" 90 | ], 91 | "role": "Viewer" 92 | }, 93 | { 94 | "name": "hyper", 95 | "schemes": [ 96 | "hyper", 97 | "dat" 98 | ], 99 | "role": "Viewer" 100 | }, 101 | { 102 | "name": "web3", 103 | "schemes": [ 104 | "web3" 105 | ], 106 | "role": "Viewer" 107 | } 108 | ], 109 | "publish": [ 110 | { 111 | "provider": "github", 112 | "owner": "p2plabsxyz", 113 | "repo": "peersky-browser", 114 | "releaseType": "release" 115 | } 116 | ] 117 | }, 118 | "dependencies": { 119 | "@chainsafe/libp2p-gossipsub": "^13.0.0", 120 | "@chainsafe/libp2p-noise": "^15.0.0", 121 | "@chainsafe/libp2p-yamux": "^6.0.2", 122 | "@helia/ipns": "^8.1.0", 123 | "@helia/unixfs": "^3.0.6", 124 | "@libp2p/bootstrap": "^10.0.24", 125 | "@libp2p/circuit-relay-v2": "^3.1.12", 126 | "@libp2p/identify": "^3.0.18", 127 | "@libp2p/kad-dht": "^12.0.17", 128 | "@libp2p/mdns": "^11.0.8", 129 | "@libp2p/mplex": "^10.0.24", 130 | "@libp2p/peer-id": "^5.0.8", 131 | "@libp2p/tcp": "^9.0.26", 132 | "@libp2p/webrtc": "^5.0.23", 133 | "@libp2p/websockets": "^8.0.24", 134 | "b4a": "^1.6.7", 135 | "content-hash": "^2.5.2", 136 | "content-type": "^1.0.5", 137 | "electron-find": "^1.0.7", 138 | "electron-log": "^5.3.0", 139 | "electron-updater": "^6.2.1", 140 | "ethers": "^6.13.4", 141 | "find-process": "^1.4.7", 142 | "fs-extra": "^11.2.0", 143 | "helia": "^5.2.0", 144 | "hyper-sdk": "^5.1.0", 145 | "hypercore-crypto": "^3.4.2", 146 | "hypercore-fetch": "^9.9.1", 147 | "hyperdht": "^6.20.1", 148 | "hyperswarm": "^4.8.4", 149 | "jquery": "^3.7.1", 150 | "libp2p": "^1.6.0", 151 | "libp2p-gossipsub": "^0.13.0", 152 | "mime-types": "^2.1.35", 153 | "multiformats": "^13.3.2", 154 | "node-cache": "^5.1.2", 155 | "scoped-fs": "^1.4.1", 156 | "web3protocol": "^0.6.0" 157 | }, 158 | "devDependencies": { 159 | "electron": "^29.0.1", 160 | "electron-builder": "^24.12.0" 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /public/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/p2plabsxyz/peersky-browser/884f0e70e38540494e2d541ea092a19fe4622239/public/icon.icns -------------------------------------------------------------------------------- /public/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/p2plabsxyz/peersky-browser/884f0e70e38540494e2d541ea092a19fe4622239/public/icon.png -------------------------------------------------------------------------------- /src/actions.js: -------------------------------------------------------------------------------- 1 | import { app, BrowserWindow, globalShortcut } from "electron"; 2 | import WindowManager from './window-manager.js'; 3 | 4 | export function createActions(windowManager) { 5 | const actions = { 6 | OpenDevTools: { 7 | label: "Open Dev Tools", 8 | accelerator: "CommandOrControl+Shift+I", 9 | click: (focusedWindow) => { 10 | if (focusedWindow) { 11 | focusedWindow.webContents.openDevTools({ mode: "detach" }); 12 | } 13 | }, 14 | }, 15 | NewWindow: { 16 | label: "New Window", 17 | accelerator: "CommandOrControl+N", 18 | click: () => { 19 | windowManager.open(); 20 | }, 21 | }, 22 | Forward: { 23 | label: "Forward", 24 | accelerator: "CommandOrControl+]", 25 | click: (focusedWindow) => { 26 | if (focusedWindow) { 27 | focusedWindow.webContents.executeJavaScript(`{ 28 | const webview = document.querySelector('webview'); 29 | if (webview && webview.canGoForward()) { 30 | webview.goForward(); 31 | } 32 | }`); 33 | } 34 | }, 35 | }, 36 | Back: { 37 | label: "Back", 38 | accelerator: "CommandOrControl+[", 39 | click: (focusedWindow) => { 40 | if (focusedWindow) { 41 | focusedWindow.webContents.executeJavaScript(`{ 42 | const webview = document.querySelector('webview'); 43 | if (webview && webview.canGoBack()) { 44 | webview.goBack(); 45 | } 46 | }`); 47 | } 48 | }, 49 | }, 50 | FocusURLBar: { 51 | label: "Focus URL Bar", 52 | accelerator: "CommandOrControl+L", 53 | click: (focusedWindow) => { 54 | if (focusedWindow) { 55 | focusedWindow.webContents.executeJavaScript(` 56 | document.getElementById('url').focus(); 57 | `); 58 | } 59 | }, 60 | }, 61 | Reload: { 62 | label: "Reload", 63 | accelerator: "CommandOrControl+R", 64 | click: (focusedWindow) => { 65 | if (focusedWindow) { 66 | focusedWindow.webContents.executeJavaScript(`{ 67 | const webview = document.querySelector('webview'); 68 | if (webview) { 69 | webview.reload(); 70 | } 71 | }`); 72 | } 73 | }, 74 | }, 75 | Minimize: { 76 | label: "Minimize", 77 | accelerator: "CommandOrControl+M", 78 | click: (focusedWindow) => { 79 | if (focusedWindow) { 80 | focusedWindow.minimize(); 81 | } 82 | }, 83 | }, 84 | Close: { 85 | label: "Close", 86 | accelerator: "CommandOrControl+W", 87 | click: (focusedWindow) => { 88 | if (focusedWindow) { 89 | focusedWindow.close(); 90 | } 91 | }, 92 | }, 93 | FullScreen: { 94 | label: "Toggle Full Screen", 95 | accelerator: "F11", 96 | click: (focusedWindow) => { 97 | if (focusedWindow) { 98 | focusedWindow.setFullScreen(!focusedWindow.isFullScreen()); 99 | } 100 | }, 101 | }, 102 | FindInPage: { 103 | label: "Find in Page", 104 | accelerator: "CommandOrControl+F", 105 | click: (focusedWindow) => { 106 | if (focusedWindow) { 107 | focusedWindow.webContents.executeJavaScript(` 108 | var findMenu = document.querySelector('find-menu'); 109 | if (findMenu) { 110 | findMenu.toggle(); 111 | setTimeout(() => { 112 | var input = findMenu.querySelector('.find-menu-input'); 113 | if (input) { 114 | input.focus(); 115 | } 116 | }, 100); // Timeout to ensure the menu is visible and ready to receive focus 117 | } 118 | `); 119 | } 120 | }, 121 | }, 122 | }; 123 | 124 | return actions; 125 | } 126 | 127 | export function registerShortcuts(windowManager) { 128 | const actions = createActions(windowManager); 129 | 130 | const registerFindShortcut = (focusedWindow) => { 131 | if (focusedWindow) { 132 | globalShortcut.register("CommandOrControl+F", () => { 133 | actions.FindInPage.click(focusedWindow); 134 | }); 135 | } 136 | }; 137 | 138 | const unregisterFindShortcut = () => { 139 | globalShortcut.unregister("CommandOrControl+F"); 140 | }; 141 | 142 | // Register and unregister `Ctrl+F` based on focus 143 | app.on("browser-window-focus", (event, win) => { 144 | registerFindShortcut(win); 145 | }); 146 | 147 | app.on("browser-window-blur", () => { 148 | unregisterFindShortcut(); 149 | }); 150 | 151 | // Register remaining shortcuts 152 | Object.keys(actions).forEach((key) => { 153 | const action = actions[key]; 154 | if (key !== "FindInPage") { 155 | // Register other shortcuts except `Ctrl+F` 156 | globalShortcut.register(action.accelerator, () => { 157 | const focusedWindow = BrowserWindow.getFocusedWindow(); 158 | if (focusedWindow) action.click(focusedWindow); 159 | }); 160 | } 161 | }); 162 | } 163 | -------------------------------------------------------------------------------- /src/auto-updater.js: -------------------------------------------------------------------------------- 1 | import { app, dialog } from 'electron'; 2 | import pkg from 'electron-updater'; 3 | const { autoUpdater } = pkg; 4 | import log from 'electron-log'; 5 | 6 | // Uncomment while locally testing the AutoUpdater 7 | // Object.defineProperty(app, 'isPackaged', { 8 | // value: true 9 | // }); 10 | 11 | function setupAutoUpdater() { 12 | autoUpdater.setFeedURL({ 13 | provider: 'github', 14 | repo: 'peersky-browser', 15 | owner: 'p2plabsxyz', 16 | }); 17 | 18 | // Allow pre-release updates 19 | autoUpdater.allowPrerelease = true; 20 | 21 | // Configure electron-log 22 | log.transports.file.level = 'info'; 23 | log.transports.console.level = 'info'; 24 | autoUpdater.logger = log; 25 | 26 | autoUpdater.on('checking-for-update', () => { 27 | log.info('Checking for update...'); 28 | }); 29 | 30 | autoUpdater.on('update-available', (info) => { 31 | log.info('Update available:', info); 32 | }); 33 | 34 | autoUpdater.on('update-not-available', (info) => { 35 | log.info('Update not available:', info); 36 | }); 37 | 38 | autoUpdater.on('download-progress', (progressObj) => { 39 | log.info(`Download speed: ${progressObj.bytesPerSecond} - Downloaded ${progressObj.percent}%`); 40 | }); 41 | 42 | autoUpdater.on('update-downloaded', (info) => { 43 | const message = `Version ${info.version} has been downloaded. Restart now to install it or select Later to postpone the update.`; 44 | const response = dialog.showMessageBoxSync({ 45 | type: 'info', 46 | buttons: ['Restart Now', 'Later'], 47 | title: 'Update Ready', 48 | message: message, 49 | }); 50 | if (response === 0) { 51 | autoUpdater.quitAndInstall(); 52 | } 53 | }); 54 | 55 | autoUpdater.on('error', (error) => { 56 | log.error('Auto-update error:', error); 57 | }); 58 | 59 | // Initiate update check after 10 seconds 60 | setTimeout(() => { 61 | autoUpdater.checkForUpdates(); 62 | }, 10000); 63 | } 64 | 65 | export { setupAutoUpdater }; 66 | -------------------------------------------------------------------------------- /src/context-menu.js: -------------------------------------------------------------------------------- 1 | import { Menu, MenuItem, clipboard } from "electron"; 2 | import WindowManager from "./window-manager.js"; 3 | 4 | const isMac = process.platform === "darwin"; 5 | 6 | // Ensure a single instance of WindowManager is used 7 | let windowManagerInstance = null; 8 | 9 | export function setWindowManager(instance) { 10 | windowManagerInstance = instance; 11 | } 12 | 13 | export function attachContextMenus(browserWindow, windowManager) { 14 | // Assign the WindowManager instance if not already set 15 | if (!windowManagerInstance) { 16 | windowManagerInstance = windowManager; 17 | } 18 | 19 | const attachMenuToWebContents = (webContents) => { 20 | webContents.on("context-menu", (event, params) => { 21 | const menu = new Menu(); 22 | 23 | // Add Undo, Redo for editable text fields with platform-specific accelerators 24 | if (params.isEditable) { 25 | menu.append( 26 | new MenuItem({ 27 | label: "Undo", 28 | role: "undo", 29 | accelerator: "CommandOrControl+Z", 30 | }) 31 | ); 32 | menu.append( 33 | new MenuItem({ 34 | label: "Redo", 35 | role: "redo", 36 | accelerator: isMac ? "Command+Shift+Z" : "Control+Y", 37 | }) 38 | ); 39 | menu.append(new MenuItem({ type: "separator" })); 40 | } 41 | 42 | // Cut, Copy, Paste, Delete, and Select All with accelerators 43 | if (params.isEditable || params.selectionText.trim().length > 0) { 44 | menu.append( 45 | new MenuItem({ 46 | label: "Cut", 47 | role: "cut", 48 | accelerator: "CommandOrControl+X", 49 | enabled: params.editFlags.canCut, 50 | }) 51 | ); 52 | menu.append( 53 | new MenuItem({ 54 | label: "Copy", 55 | role: "copy", 56 | accelerator: "CommandOrControl+C", 57 | enabled: params.editFlags.canCopy, 58 | }) 59 | ); 60 | menu.append( 61 | new MenuItem({ 62 | label: "Paste", 63 | role: "paste", 64 | accelerator: "CommandOrControl+V", 65 | enabled: params.editFlags.canPaste, 66 | }) 67 | ); 68 | menu.append( 69 | new MenuItem({ 70 | label: "Delete", 71 | role: "delete", 72 | }) 73 | ); 74 | menu.append( 75 | new MenuItem({ 76 | label: "Select All", 77 | role: "selectAll", 78 | accelerator: "CommandOrControl+A", 79 | }) 80 | ); 81 | menu.append(new MenuItem({ type: "separator" })); 82 | } 83 | 84 | // Navigation controls with no accelerators 85 | menu.append( 86 | new MenuItem({ 87 | label: "Back", 88 | enabled: webContents.canGoBack(), 89 | click: () => webContents.goBack(), 90 | }) 91 | ); 92 | menu.append( 93 | new MenuItem({ 94 | label: "Forward", 95 | enabled: webContents.canGoForward(), 96 | click: () => webContents.goForward(), 97 | }) 98 | ); 99 | menu.append( 100 | new MenuItem({ 101 | label: "Reload", 102 | click: () => webContents.reload(), 103 | }) 104 | ); 105 | 106 | // Element inspection 107 | menu.append( 108 | new MenuItem({ 109 | label: "Inspect", 110 | click: () => { 111 | if (!webContents.isDevToolsOpened()) { 112 | webContents.openDevTools({ mode: "detach" }); 113 | } 114 | webContents.inspectElement(params.x, params.y); 115 | }, 116 | }) 117 | ); 118 | 119 | // Link handling 120 | if (params.linkURL) { 121 | menu.append( 122 | new MenuItem({ 123 | label: "Copy Link Address", 124 | click: () => clipboard.writeText(params.linkURL), 125 | }) 126 | ); 127 | menu.append( 128 | new MenuItem({ 129 | label: "Open Link in New Window", 130 | click: () => { 131 | if (windowManagerInstance) { 132 | windowManagerInstance.open({ url: params.linkURL }); 133 | } else { 134 | console.error("WindowManager instance not set."); 135 | } 136 | }, 137 | }) 138 | ); 139 | } 140 | 141 | menu.popup(); 142 | }); 143 | }; 144 | 145 | // Attach to main window's webContents 146 | attachMenuToWebContents(browserWindow.webContents); 147 | 148 | // Attach to all existing webviews 149 | browserWindow.webContents.on( 150 | "did-attach-webview", 151 | (event, webviewWebContents) => { 152 | attachMenuToWebContents(webviewWebContents); 153 | 154 | webviewWebContents.setWindowOpenHandler(({ url }) => { 155 | if (windowManagerInstance) { 156 | windowManagerInstance.open({ url }); 157 | } else { 158 | console.error("WindowManager instance not set."); 159 | } 160 | return { action: "deny" }; 161 | }); 162 | } 163 | ); 164 | } 165 | -------------------------------------------------------------------------------- /src/find-menu.js: -------------------------------------------------------------------------------- 1 | class FindMenu extends HTMLElement { 2 | constructor() { 3 | super(); 4 | 5 | this.currentSearchValue = ''; 6 | this.matchCase = false; 7 | this.currentRequestId = null; 8 | this.matchesCount = 0; 9 | this.currentMatchIndex = 0; 10 | this.isPdf = false; // Track if current document is PDF 11 | this.wrappingBackward = false; // Tracks if we're wrapping around to the end 12 | this.updateTimeout = null; 13 | 14 | this.addEventListener('keydown', ({ key }) => { 15 | if (key === 'Escape') this.hide(); 16 | }); 17 | } 18 | 19 | async connectedCallback() { 20 | this.innerHTML = ` 21 | 22 | 23 | 24 | 25 | 26 | `; 27 | 28 | this.input = this.querySelector('.find-menu-input'); 29 | this.matchCountDisplay = this.querySelector('.match-count'); 30 | this.previousButton = this.querySelector('.find-menu-previous'); 31 | this.nextButton = this.querySelector('.find-menu-next'); 32 | this.hideButton = this.querySelector('.find-menu-hide'); 33 | 34 | await this.loadSVG(this.previousButton, 'peersky://static/assets/svg/up.svg'); 35 | await this.loadSVG(this.nextButton, 'peersky://static/assets/svg/down.svg'); 36 | await this.loadSVG(this.hideButton, 'peersky://static/assets/svg/close.svg'); 37 | 38 | // Setup foundInPage listener on webview 39 | this.setupFoundInPageListener(); 40 | 41 | // Setup webview navigation events to detect PDFs 42 | this.setupWebviewNavigationListener(); 43 | 44 | this.input.addEventListener('input', (e) => { 45 | const { value } = this; 46 | if (!value) { 47 | this.stopFindInPage('clearSelection'); 48 | return; 49 | } 50 | 51 | this.findInWebview(value, { forward: true }); 52 | }); 53 | 54 | this.input.addEventListener('keydown', ({ keyCode, shiftKey }) => { 55 | if (keyCode === 13) { 56 | const { value } = this; 57 | if (!value) return this.hide(); 58 | 59 | const forward = !shiftKey; 60 | this.findInWebview(value, { forward, findNext: true }); 61 | } 62 | }); 63 | 64 | this.previousButton.addEventListener('click', () => { 65 | const { value } = this; 66 | if (!value) return; 67 | this.findInWebview(value, { forward: false, findNext: true }); 68 | }); 69 | 70 | this.nextButton.addEventListener('click', () => { 71 | const { value } = this; 72 | if (!value) return; 73 | this.findInWebview(value, { forward: true, findNext: true }); 74 | }); 75 | 76 | this.hideButton.addEventListener('click', () => this.hide()); 77 | } 78 | 79 | setupWebviewNavigationListener() { 80 | const webview = this.getWebviewElement(); 81 | if (!webview) return; 82 | 83 | // Listen for did-navigate events to detect content type 84 | webview.addEventListener('did-navigate', () => { 85 | this.detectContentType(); 86 | }); 87 | 88 | webview.addEventListener('did-navigate-in-page', () => { 89 | this.detectContentType(); 90 | }); 91 | 92 | // Also check when loading finishes 93 | webview.addEventListener('did-finish-load', () => { 94 | this.detectContentType(); 95 | }); 96 | } 97 | 98 | async detectContentType() { 99 | if (this.isPdf !== null) return; // detect only once 100 | const webview = this.getWebviewElement(); 101 | if (!webview) return; 102 | 103 | try { 104 | // Check if current page is a PDF by examining the URL or content 105 | const url = await webview.getURL(); 106 | this.isPdf = url.toLowerCase().endsWith('.pdf') || 107 | url.toLowerCase().includes('application/pdf'); 108 | 109 | // If we need more precise detection, we can use executeJavaScript 110 | if (!this.isPdf) { 111 | const contentType = await webview.executeJavaScript(` 112 | document.contentType || 113 | (document.querySelector('embed[type="application/pdf"]') ? 'application/pdf' : '') 114 | `); 115 | this.isPdf = contentType === 'application/pdf'; 116 | } 117 | } catch (error) { 118 | console.error('Error detecting content type:', error); 119 | } 120 | } 121 | // fix (orignal code) 122 | setupFoundInPageListener() { 123 | // Get the webview element 124 | const webview = this.getWebviewElement(); 125 | if (!webview) return; 126 | 127 | // Listen for found-in-page events 128 | webview.addEventListener('found-in-page', (event) => { 129 | const { requestId, matches, activeMatchOrdinal } = event.result; 130 | console.log('found-in-page', requestId, matches, activeMatchOrdinal); 131 | 132 | // Ensure this is a response to our current request 133 | if (requestId !== this.currentRequestId) return; 134 | 135 | // updates the match count display only if 136 | // 1. matchesCount is 0 (first search) or 137 | // 2. search value has changed 138 | if (this.matchesCount === 0 || this.currentSearchValue !== this.input.value) { 139 | this.matchesCount = matches || 0; 140 | } 141 | if(matches > 0) { 142 | this.currentMatchIndex = activeMatchOrdinal; 143 | if(this.currentMatchIndex > this.matchesCount) { 144 | console.log('wrapping', this.currentMatchIndex, this.matchesCount); 145 | this.currentMatchIndex = 1; 146 | } 147 | else if(this.currentMatchIndex < 1 && this.matchesCount > 0) { 148 | this.currentMatchIndex = this.matchesCount 149 | } 150 | } 151 | else{ 152 | this.currentMatchIndex = 0; 153 | } 154 | 155 | if (this.matchesCount > 0) { 156 | this.matchCountDisplay.textContent = `${this.currentMatchIndex} of ${this.matchesCount}`; 157 | } else { 158 | this.matchCountDisplay.textContent = 'No matches'; 159 | } 160 | }); 161 | } 162 | 163 | 164 | findInWebview(value, options = {}) { 165 | const webview = this.getWebviewElement(); 166 | if (!webview) return; 167 | 168 | // If search value changed, reset the search 169 | if (value !== this.currentSearchValue) { 170 | this.stopFindInPage('clearSelection'); 171 | this.currentSearchValue = value; 172 | options.findNext = false; 173 | 174 | // Reset counters when starting a new search 175 | this.matchesCount = 0; 176 | this.currentMatchIndex = 0; 177 | } 178 | 179 | // Use Electron's findInPage API for both HTML and PDF content 180 | try { 181 | this.currentRequestId = webview.findInPage(value, { 182 | forward: options.forward !== false, 183 | findNext: options.findNext || false, 184 | matchCase: this.matchCase, 185 | }); 186 | } catch (error) { 187 | console.error('Error using findInPage:', error); 188 | } 189 | } 190 | 191 | stopFindInPage(action = 'keepSelection') { 192 | const webview = this.getWebviewElement(); 193 | if (webview) { 194 | webview.stopFindInPage(action); 195 | if (action === 'clearSelection') { 196 | this.currentSearchValue = ''; 197 | this.matchCountDisplay.textContent = ''; 198 | this.matchesCount = 0; 199 | this.currentMatchIndex = 0; 200 | } 201 | } 202 | } 203 | 204 | getWebviewElement() { 205 | // First try getting tracked-box's webviewElement property 206 | const trackedBox = document.querySelector('tracked-box'); 207 | if (trackedBox && trackedBox.webviewElement) { 208 | return trackedBox.webviewElement; 209 | } 210 | // Fallback to direct webview element 211 | return document.querySelector('webview'); 212 | } 213 | 214 | async loadSVG(button, svgPath) { 215 | const response = await fetch(svgPath); 216 | const svgContent = await response.text(); 217 | const svgContainer = document.createElement("div"); 218 | svgContainer.innerHTML = svgContent; 219 | svgContainer.querySelector("svg").setAttribute("width", "14"); 220 | svgContainer.querySelector("svg").setAttribute("height", "14"); 221 | svgContainer.querySelector("svg").setAttribute("fill", "currentColor"); 222 | button.appendChild(svgContainer.firstChild); 223 | } 224 | 225 | resetSearch() { 226 | this.stopFindInPage('clearSelection'); 227 | } 228 | 229 | get value() { 230 | return this.input.value; 231 | } 232 | 233 | show() { 234 | this.classList.toggle('hidden', false); 235 | // Check content type when showing search 236 | this.detectContentType(); 237 | setTimeout(() => { 238 | this.focus(); 239 | }, 10); 240 | } 241 | 242 | hide() { 243 | this.stopFindInPage('clearSelection'); 244 | this.classList.toggle('hidden', true); 245 | this.dispatchEvent(new CustomEvent('hide')); 246 | } 247 | 248 | toggle() { 249 | const isHidden = this.classList.contains('hidden'); 250 | this.classList.toggle('hidden'); 251 | if (isHidden) { 252 | this.detectContentType(); 253 | this.focus(); 254 | } else { 255 | this.stopFindInPage('clearSelection'); 256 | this.dispatchEvent(new CustomEvent('hide')); 257 | } 258 | } 259 | 260 | focus() { 261 | this.input.focus(); 262 | this.input.select(); 263 | } 264 | } 265 | 266 | customElements.define('find-menu', FindMenu); -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | import { app, session, protocol as globalProtocol } from "electron"; 2 | import { createHandler as createBrowserHandler } from "./protocols/peersky-protocol.js"; 3 | import { createHandler as createBrowserThemeHandler } from "./protocols/theme-handler.js"; 4 | import { createHandler as createIPFSHandler } from "./protocols/ipfs-handler.js"; 5 | import { createHandler as createHyperHandler } from "./protocols/hyper-handler.js"; 6 | import { createHandler as createWeb3Handler } from "./protocols/web3-handler.js"; 7 | import { ipfsOptions, hyperOptions } from "./protocols/config.js"; 8 | import { registerShortcuts } from "./actions.js"; 9 | import WindowManager from "./window-manager.js"; 10 | import { attachContextMenus, setWindowManager } from "./context-menu.js"; 11 | // import { setupAutoUpdater } from "./auto-updater.js"; 12 | 13 | const P2P_PROTOCOL = { 14 | standard: true, 15 | secure: true, 16 | allowServiceWorkers: true, 17 | supportFetchAPI: true, 18 | bypassCSP: false, 19 | corsEnabled: true, 20 | stream: true, 21 | }; 22 | 23 | const BROWSER_PROTOCOL = { 24 | standard: false, 25 | secure: true, 26 | allowServiceWorkers: false, 27 | supportFetchAPI: true, 28 | bypassCSP: false, 29 | corsEnabled: true, 30 | }; 31 | 32 | let windowManager; 33 | 34 | globalProtocol.registerSchemesAsPrivileged([ 35 | { scheme: "peersky", privileges: BROWSER_PROTOCOL }, 36 | { scheme: "browser", privileges: BROWSER_PROTOCOL }, 37 | { scheme: "ipfs", privileges: P2P_PROTOCOL }, 38 | { scheme: "ipns", privileges: P2P_PROTOCOL }, 39 | { scheme: "pubsub", privileges: P2P_PROTOCOL }, 40 | { scheme: "hyper", privileges: P2P_PROTOCOL }, 41 | { scheme: "web3", privileges: P2P_PROTOCOL }, 42 | ]); 43 | 44 | app.whenReady().then(async () => { 45 | windowManager = new WindowManager(); 46 | 47 | // Set the WindowManager instance in context-menu.js 48 | setWindowManager(windowManager); 49 | await setupProtocols(session.defaultSession); 50 | 51 | // Load saved windows or open a new one 52 | await windowManager.openSavedWindows(); 53 | if (windowManager.all.length === 0) { 54 | windowManager.open({ isMainWindow: true }); 55 | } 56 | 57 | registerShortcuts(windowManager); // Pass windowManager to registerShortcuts 58 | 59 | windowManager.startSaver(); 60 | 61 | // Initialize AutoUpdater after windowManager is ready 62 | // console.log("App is prepared, setting up AutoUpdater..."); 63 | // setupAutoUpdater(); 64 | }); 65 | 66 | // Introduce a flag to prevent multiple 'before-quit' handling 67 | let isQuitting = false; 68 | 69 | app.on("before-quit", (event) => { 70 | if (isQuitting) { 71 | return; 72 | } 73 | event.preventDefault(); // Prevent the default quit behavior 74 | 75 | console.log("Before quit: Saving window states..."); 76 | 77 | isQuitting = true; // Set the quitting flag 78 | 79 | windowManager.setQuitting(true); // Inform WindowManager that quitting is happening 80 | 81 | windowManager 82 | .saveOpened() 83 | .then(() => { 84 | console.log("Window states saved successfully."); 85 | windowManager.stopSaver(); 86 | app.quit(); // Proceed to quit the app 87 | }) 88 | .catch((error) => { 89 | console.error("Error saving window states on quit:", error); 90 | windowManager.stopSaver(); 91 | app.quit(); // Proceed to quit the app even if saving fails 92 | }); 93 | }); 94 | 95 | async function setupProtocols(session) { 96 | const { protocol: sessionProtocol } = session; 97 | 98 | app.setAsDefaultProtocolClient("peersky"); 99 | app.setAsDefaultProtocolClient("browser"); 100 | app.setAsDefaultProtocolClient("ipfs"); 101 | app.setAsDefaultProtocolClient("ipns"); 102 | app.setAsDefaultProtocolClient("hyper"); 103 | app.setAsDefaultProtocolClient("web3"); 104 | 105 | const browserProtocolHandler = await createBrowserHandler(); 106 | sessionProtocol.registerStreamProtocol("peersky", browserProtocolHandler, BROWSER_PROTOCOL); 107 | 108 | const browserThemeHandler = await createBrowserThemeHandler(); 109 | sessionProtocol.registerStreamProtocol("browser", browserThemeHandler, BROWSER_PROTOCOL); 110 | 111 | const ipfsProtocolHandler = await createIPFSHandler(ipfsOptions, session); 112 | sessionProtocol.registerStreamProtocol("ipfs", ipfsProtocolHandler, P2P_PROTOCOL); 113 | sessionProtocol.registerStreamProtocol("ipns", ipfsProtocolHandler, P2P_PROTOCOL); 114 | sessionProtocol.registerStreamProtocol("pubsub", ipfsProtocolHandler, P2P_PROTOCOL); 115 | 116 | const hyperProtocolHandler = await createHyperHandler(hyperOptions, session); 117 | sessionProtocol.registerStreamProtocol("hyper", hyperProtocolHandler, P2P_PROTOCOL); 118 | 119 | const web3ProtocolHandler = await createWeb3Handler(); 120 | sessionProtocol.registerStreamProtocol("web3", web3ProtocolHandler, P2P_PROTOCOL); 121 | } 122 | 123 | app.on("window-all-closed", () => { 124 | if (process.platform !== "darwin") { 125 | app.quit(); 126 | } 127 | }); 128 | 129 | app.on("activate", () => { 130 | if (windowManager.all.length === 0) { 131 | windowManager.open({ isMainWindow: true }); 132 | } 133 | }); 134 | 135 | export { windowManager }; 136 | -------------------------------------------------------------------------------- /src/pages/404.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Peersky Browser | 404 6 | 15 |

404 Page not found!

16 | -------------------------------------------------------------------------------- /src/pages/about.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | About 6 |

List of Peersky URLs

7 | peersky://home 8 |
... 9 | -------------------------------------------------------------------------------- /src/pages/clock.js: -------------------------------------------------------------------------------- 1 | class Clock extends HTMLElement { 2 | constructor() { 3 | super(); 4 | this.updateTime(); 5 | } 6 | 7 | connectedCallback() { 8 | this.render(); 9 | this.startClock(); 10 | } 11 | 12 | render() { 13 | this.style.position = "absolute"; 14 | this.style.top = "20px"; 15 | this.style.right = "20px"; 16 | this.style.color = "#FFFFFF"; 17 | this.style.fontFamily = "'Helvetica Neue', Arial, sans-serif"; 18 | this.style.fontSize = "30px"; 19 | this.style.fontWeight = "200"; 20 | this.style.padding = "8px"; 21 | this.style.borderRadius = "12px"; 22 | this.style.backgroundColor = "rgba(255, 255, 255, 0.1)"; 23 | this.style.backdropFilter = "blur(10px) saturate(180%)"; 24 | this.style.border = "1px solid rgba(255, 255, 255, 0.2)"; 25 | this.style.boxShadow = "0 4px 10px rgba(0, 0, 0, 0.2)"; 26 | this.textContent = this.formatTime(this.currentTime); 27 | } 28 | 29 | startClock() { 30 | setInterval(() => { 31 | this.updateTime(); 32 | this.render(); 33 | }, 1000); 34 | } 35 | 36 | updateTime() { 37 | this.currentTime = new Date(); 38 | } 39 | 40 | formatTime(date) { 41 | const hours = String(date.getHours()).padStart(2, "0"); 42 | const minutes = String(date.getMinutes()).padStart(2, "0"); 43 | return `${hours}:${minutes}`; 44 | } 45 | } 46 | 47 | customElements.define("simple-clock", Clock); 48 | -------------------------------------------------------------------------------- /src/pages/home.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | Home 10 |
11 | 12 | 13 |
14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /src/pages/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 8 | Peersky Browser 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /src/pages/nav-box.js: -------------------------------------------------------------------------------- 1 | class NavBox extends HTMLElement { 2 | constructor() { 3 | super(); 4 | this.isLoading = false; 5 | this.buildNavBox(); 6 | this.attachEvents(); 7 | } 8 | 9 | buildNavBox() { 10 | this.id = "navbox"; 11 | const buttons = [ 12 | { id: "back", svg: "left.svg", position: "start" }, 13 | { id: "forward", svg: "right.svg", position: "start" }, 14 | { id: "refresh", svg: "reload.svg", position: "start" }, 15 | { id: "home", svg: "home.svg", position: "start" }, 16 | { id: "plus", svg: "plus.svg", position: "end" }, 17 | ]; 18 | 19 | this.buttonElements = {}; 20 | 21 | // Create buttons that should appear before the URL input 22 | buttons 23 | .filter((btn) => btn.position === "start") 24 | .forEach((button) => { 25 | const btnElement = this.createButton( 26 | button.id, 27 | `peersky://static/assets/svg/${button.svg}` 28 | ); 29 | this.appendChild(btnElement); 30 | this.buttonElements[button.id] = btnElement; 31 | }); 32 | 33 | const urlInput = document.createElement("input"); 34 | urlInput.type = "text"; 35 | urlInput.id = "url"; 36 | urlInput.placeholder = "Search with DuckDuckGo or type a P2P URL"; 37 | this.appendChild(urlInput); 38 | 39 | // Create buttons that should appear after the URL input 40 | buttons 41 | .filter((btn) => btn.position === "end") 42 | .forEach((button) => { 43 | const btnElement = this.createButton( 44 | button.id, 45 | `peersky://static/assets/svg/${button.svg}` 46 | ); 47 | this.appendChild(btnElement); 48 | this.buttonElements[button.id] = btnElement; 49 | }); 50 | } 51 | 52 | createButton(id, svgPath) { 53 | const button = document.createElement("button"); 54 | button.className = "nav-button"; 55 | button.id = id; 56 | 57 | // Create a container for the SVG to manage icons 58 | const svgContainer = document.createElement("div"); 59 | svgContainer.className = "svg-container"; 60 | button.appendChild(svgContainer); 61 | 62 | this.loadSVG(svgContainer, svgPath); 63 | 64 | return button; 65 | } 66 | 67 | loadSVG(container, svgPath) { 68 | fetch(svgPath) 69 | .then((response) => response.text()) 70 | .then((svgContent) => { 71 | container.innerHTML = svgContent; 72 | const svgElement = container.querySelector("svg"); 73 | if (svgElement) { 74 | svgElement.setAttribute("width", "18"); 75 | svgElement.setAttribute("height", "18"); 76 | svgElement.setAttribute("fill", "currentColor"); 77 | } 78 | }) 79 | .catch((error) => { 80 | console.error(`Error loading SVG from ${svgPath}:`, error); 81 | }); 82 | } 83 | 84 | updateButtonIcon(button, svgFileName) { 85 | const svgPath = `peersky://static/assets/svg/${svgFileName}`; 86 | const svgContainer = button.querySelector(".svg-container"); 87 | if (svgContainer) { 88 | this.loadSVG(svgContainer, svgPath); 89 | } else { 90 | console.error("SVG container not found within the button."); 91 | } 92 | } 93 | 94 | setLoading(isLoading) { 95 | this.isLoading = isLoading; 96 | const refreshButton = this.buttonElements["refresh"]; 97 | if (refreshButton) { 98 | if (isLoading) { 99 | this.updateButtonIcon(refreshButton, "close.svg"); 100 | } else { 101 | this.updateButtonIcon(refreshButton, "reload.svg"); 102 | } 103 | } else { 104 | console.error("Refresh button not found."); 105 | } 106 | } 107 | 108 | setNavigationButtons(canGoBack, canGoForward) { 109 | const backButton = this.buttonElements["back"]; 110 | const forwardButton = this.buttonElements["forward"]; 111 | 112 | if (backButton) { 113 | if (canGoBack) { 114 | backButton.classList.add("active"); 115 | backButton.removeAttribute("disabled"); 116 | } else { 117 | backButton.classList.remove("active"); 118 | backButton.setAttribute("disabled", "true"); 119 | } 120 | } 121 | 122 | if (forwardButton) { 123 | if (canGoForward) { 124 | forwardButton.classList.add("active"); 125 | forwardButton.removeAttribute("disabled"); 126 | } else { 127 | forwardButton.classList.remove("active"); 128 | forwardButton.setAttribute("disabled", "true"); 129 | } 130 | } 131 | } 132 | 133 | attachEvents() { 134 | this.addEventListener("click", (event) => { 135 | const button = event.target.closest("button"); 136 | if (button) { 137 | if (button.id === "refresh") { 138 | if (this.isLoading) { 139 | this.dispatchEvent(new CustomEvent("stop")); 140 | } else { 141 | this.dispatchEvent(new CustomEvent("reload")); 142 | } 143 | } else if (button.id === "plus") { 144 | this.dispatchEvent(new CustomEvent("new-window")); 145 | } else if (!button.disabled) { 146 | this.navigate(button.id); 147 | } 148 | } 149 | }); 150 | 151 | const urlInput = this.querySelector("#url"); 152 | if (urlInput) { 153 | urlInput.addEventListener("keypress", (event) => { 154 | if (event.key === "Enter") { 155 | const url = event.target.value.trim(); 156 | this.dispatchEvent(new CustomEvent("navigate", { detail: { url } })); 157 | } 158 | }); 159 | } else { 160 | console.error("URL input not found within nav-box."); 161 | } 162 | } 163 | 164 | navigate(action) { 165 | this.dispatchEvent(new CustomEvent(action)); 166 | } 167 | } 168 | 169 | customElements.define("nav-box", NavBox); 170 | -------------------------------------------------------------------------------- /src/pages/p2p/chat/app.js: -------------------------------------------------------------------------------- 1 | const apiBase = "hyper://chat"; 2 | let offlineMessages = []; 3 | const displayedMessages = {}; 4 | let currentEventSource = null; 5 | 6 | // Utility function to format timestamps 7 | function formatTimestamp(ts) { 8 | const now = new Date(); 9 | const date = new Date(ts); 10 | const diffMs = now - date; 11 | const diffDays = diffMs / (1000 * 60 * 60 * 24); 12 | 13 | if (diffDays > 30) { 14 | const ds = date.toLocaleDateString([], { 15 | year: "numeric", 16 | month: "2-digit", 17 | day: "2-digit", 18 | }); 19 | const tsStr = date.toLocaleTimeString([], { 20 | hour: "2-digit", 21 | minute: "2-digit", 22 | }); 23 | return `${ds} ${tsStr}`; 24 | } else { 25 | const sec = Math.floor(diffMs / 1000); 26 | const min = Math.floor(sec / 60); 27 | const hrs = Math.floor(min / 60); 28 | const day = Math.floor(hrs / 24); 29 | const wk = Math.floor(day / 7); 30 | const mon = Math.floor(day / 30); 31 | 32 | if (sec < 60) return `${sec} sec${sec !== 1 ? "s" : ""} ago`; 33 | if (min < 60) return `${min} min${min !== 1 ? "s" : ""} ago`; 34 | if (hrs < 24) return `${hrs} hr${hrs !== 1 ? "s" : ""} ago`; 35 | if (day < 7) return `${day} d${day !== 1 ? "s" : ""} ago`; 36 | if (wk < 4) return `${wk} w${wk !== 1 ? "s" : ""} ago`; 37 | return `${mon} mo${mon !== 1 ? "s" : ""} ago`; 38 | } 39 | } 40 | 41 | // Create a new chat room and then join it 42 | async function createChatRoom() { 43 | try { 44 | const response = await fetch(`${apiBase}?action=create-key`, { 45 | method: "POST", 46 | }); 47 | if (!response.ok) { 48 | throw new Error(`Failed to create chat room: ${response.statusText}`); 49 | } 50 | const data = await response.json(); 51 | const { roomKey } = data; 52 | console.log(`Chat room created with key: ${roomKey}`); 53 | 54 | await joinChatRoom(roomKey); 55 | startChatRoom(roomKey); 56 | } catch (error) { 57 | console.error("Error creating chat room:", error); 58 | alert(`Error creating chat room: ${error.message}`); 59 | } 60 | } 61 | 62 | // Join an existing chat room using its key 63 | async function joinChatRoom(roomKey) { 64 | try { 65 | const response = await fetch(`${apiBase}?action=join&roomKey=${roomKey}`, { 66 | method: "POST", 67 | }); 68 | if (!response.ok) { 69 | throw new Error(`Failed to join chat room: ${response.statusText}`); 70 | } 71 | const data = await response.json(); 72 | console.log(data.message); 73 | } catch (error) { 74 | console.error("Error joining chat room:", error); 75 | alert(`Error joining chat room: ${error.message}`); 76 | throw error; 77 | } 78 | } 79 | 80 | // Start chat room UI and setup message receiver with the given roomKey 81 | function startChatRoom(roomKey) { 82 | document.querySelector("#setup").style.display = "none"; 83 | document.querySelector("#chat").style.display = "flex"; 84 | document.querySelector("#chat-room-info").style.display = "flex"; 85 | document.querySelector("#chat-room-topic").textContent = roomKey; 86 | setupMessageReceiver(roomKey); 87 | } 88 | 89 | // Event listeners for creating and joining chat rooms 90 | document 91 | .querySelector("#create-chat-room") 92 | .addEventListener("click", async () => { 93 | await createChatRoom(); 94 | }); 95 | 96 | document.querySelector("#join-form").addEventListener("submit", async (e) => { 97 | e.preventDefault(); 98 | const topic = document.querySelector("#join-chat-room-topic").value.trim(); 99 | // Validate room key format: 64-character hexadecimal string 100 | const roomKeyPattern = /^[a-f0-9]{64}$/i; 101 | if (!roomKeyPattern.test(topic)) { 102 | alert("Invalid room key! Please enter a valid 64-character hexadecimal key."); 103 | return; 104 | } 105 | try { 106 | await joinChatRoom(topic); 107 | startChatRoom(topic); 108 | } catch (error) { 109 | // Error already handled in joinChatRoom 110 | } 111 | }); 112 | 113 | // Event listener for sending a chat message 114 | document 115 | .querySelector("#message-form") 116 | .addEventListener("submit", async (e) => { 117 | e.preventDefault(); 118 | const messageInput = document.querySelector("#message"); 119 | const message = messageInput.value.trim(); 120 | if (!message) { 121 | alert("Cannot send an empty message."); 122 | return; 123 | } 124 | messageInput.value = ""; 125 | sendMessage("You", message); 126 | }); 127 | 128 | // Function to send a message 129 | async function sendMessage(sender, message) { 130 | const roomKey = document.querySelector("#chat-room-topic").textContent; 131 | if (navigator.onLine) { 132 | try { 133 | await postMessage(sender, message, roomKey); 134 | } catch (e) { 135 | console.error("Error sending message:", e); 136 | alert(e.message); 137 | } 138 | } else { 139 | offlineMessages.push({ sender, message }); 140 | alert("Offline message stored."); 141 | } 142 | } 143 | 144 | // Function to post a message to the backend 145 | async function postMessage(sender, message, roomKey) { 146 | const url = `${apiBase}?action=send&roomKey=${roomKey}`; 147 | const resp = await fetch(url, { 148 | method: "POST", 149 | headers: { "Content-Type": "application/json" }, 150 | body: JSON.stringify({ sender, message }), 151 | }); 152 | if (!resp.ok) { 153 | throw new Error(`Failed to send message: ${resp.statusText}`); 154 | } 155 | } 156 | 157 | // Setup the message receiver using SSE for a particular room 158 | function setupMessageReceiver(roomKey) { 159 | // Close any previous EventSource connection 160 | if (currentEventSource) { 161 | currentEventSource.close(); 162 | } 163 | const es = new EventSource(`${apiBase}?action=receive&roomKey=${roomKey}`); 164 | currentEventSource = es; 165 | 166 | es.onmessage = (ev) => { 167 | const { sender, message, timestamp } = JSON.parse(ev.data); 168 | onMessageReceived(sender, message, timestamp, roomKey); 169 | }; 170 | 171 | es.addEventListener("peersCount", (ev) => { 172 | updatePeersCount(ev.data); 173 | }); 174 | 175 | es.onerror = () => { 176 | console.error("EventSource error"); 177 | alert("SSE stream failed."); 178 | }; 179 | 180 | // When coming back online, synchronize offline messages 181 | window.addEventListener("online", () => syncOfflineMessages(roomKey)); 182 | } 183 | 184 | // Sync any messages stored while offline when the connection is restored 185 | async function syncOfflineMessages(roomKey) { 186 | if (offlineMessages.length > 0) { 187 | for (const { sender, message } of offlineMessages) { 188 | try { 189 | await postMessage(sender, message, roomKey); 190 | onMessageReceived(sender, message, Date.now(), roomKey); 191 | } catch (e) { 192 | console.error("Error syncing offline message:", e); 193 | } 194 | } 195 | offlineMessages = []; 196 | } 197 | } 198 | 199 | // Add the received message to the chat window 200 | function onMessageReceived(sender, message, timestamp, roomKey) { 201 | // Avoid displaying duplicate messages 202 | if (!displayedMessages[roomKey]) displayedMessages[roomKey] = new Set(); 203 | const msgID = `${sender}-${timestamp}-${message}`; 204 | if (displayedMessages[roomKey].has(msgID)) return; 205 | displayedMessages[roomKey].add(msgID); 206 | 207 | const container = document.querySelector("#messages"); 208 | const msgDiv = document.createElement("div"); 209 | const textDiv = document.createElement("div"); 210 | const metaDiv = document.createElement("div"); 211 | 212 | const dispTime = formatTimestamp(timestamp); 213 | 214 | msgDiv.classList.add("message"); 215 | textDiv.innerHTML = formatMessageWithLinks(message); 216 | metaDiv.textContent = `${sender} · ${dispTime}`; 217 | 218 | // Style messages according to the sender 219 | if (sender === "You") { 220 | msgDiv.classList.add("message-right"); 221 | textDiv.classList.add("message-text-right"); 222 | metaDiv.classList.add("sender-right"); 223 | } else { 224 | msgDiv.classList.add("message-left"); 225 | textDiv.classList.add("message-text-left"); 226 | metaDiv.classList.add("sender-left"); 227 | } 228 | msgDiv.appendChild(textDiv); 229 | msgDiv.appendChild(metaDiv); 230 | 231 | container.appendChild(msgDiv); 232 | container.scrollTop = container.scrollHeight; 233 | } 234 | 235 | // Convert detected URLs in the message into clickable links 236 | function formatMessageWithLinks(msg) { 237 | const pattern = /(\b(https?|ftp|file|hyper|ipfs|ipns):\/\/\S+)/gi; 238 | return msg.replace(pattern, (url) => { 239 | const isCustom = /^(hyper|ipfs|ipns):\/\//i.test(url); 240 | if (isCustom) { 241 | return `${url}`; 242 | } 243 | return `${url}`; 244 | }); 245 | } 246 | 247 | // Open custom protocol links (hyper://, ipfs://, ipns://) externally via Electron 248 | document.addEventListener("click", (ev) => { 249 | const t = ev.target; 250 | if (t.tagName === "A") { 251 | const href = t.getAttribute("href"); 252 | if ( 253 | href.startsWith("hyper://") || 254 | href.startsWith("ipfs://") || 255 | href.startsWith("ipns://") 256 | ) { 257 | ev.preventDefault(); 258 | const { shell } = require("electron"); 259 | shell.openExternal(href); 260 | } 261 | } 262 | }); 263 | 264 | // Update the displayed peer count in the UI 265 | function updatePeersCount(count) { 266 | document.querySelector("#peers-count").textContent = count; 267 | } 268 | -------------------------------------------------------------------------------- /src/pages/p2p/chat/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Hyper Chat 8 | 9 | 10 |
11 |
12 | 13 |
- or -
14 |
15 | 22 | 23 |
24 |
25 | 26 |
27 |
28 |
RoomKey:
29 |
Peers: 0
30 |
31 |
32 |
33 | 39 | 42 |
43 |
44 |
45 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /src/pages/p2p/chat/send.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/pages/p2p/chat/styles.css: -------------------------------------------------------------------------------- 1 | @import url("browser://theme/index.css"); 2 | 3 | :root { 4 | --hyper-chat-bg: #121313; 5 | --hyper-chat-text-color: var(--browser-theme-text-color); 6 | --hyper-chat-nav-bg: var(--peersky-nav-background); 7 | --hyper-chat-input-bg: var(--peersky-background-url-input); 8 | --hyper-chat-border: #444; 9 | --hyper-chat-message-left: #3c3c41; 10 | --hyper-chat-message-right: #303131; 11 | --hyper-chat-button-hover: #3c3c41; 12 | } 13 | 14 | html, 15 | body { 16 | margin: 0; 17 | padding: 0; 18 | width: 100%; 19 | height: 100%; 20 | background-color: var(--hyper-chat-bg); 21 | font-family: var(--browser-theme-font-family); 22 | color: var(--hyper-chat-text-color); 23 | } 24 | 25 | main { 26 | display: flex; 27 | flex-direction: column; 28 | align-items: center; 29 | justify-content: center; 30 | height: 100vh; 31 | padding: 1rem; 32 | box-sizing: border-box; 33 | } 34 | 35 | #setup { 36 | display: flex; 37 | flex-direction: column; 38 | align-items: center; 39 | justify-content: center; 40 | gap: 1.25rem; 41 | margin-bottom: 2rem; 42 | } 43 | 44 | #setup > div { 45 | color: var(--peersky-nav-button-color); 46 | } 47 | 48 | #join-chat-room-topic { 49 | margin-right: 6px; 50 | } 51 | 52 | button, 53 | input { 54 | border: none; 55 | outline: none; 56 | border-radius: 6px; 57 | font-family: var(--browser-theme-font-family); 58 | font-size: 1rem; 59 | color: var(--hyper-chat-text-color); 60 | padding: 0.6rem 1rem; 61 | transition: background-color 0.2s, color 0.2s; 62 | box-sizing: border-box; 63 | } 64 | 65 | button { 66 | background-color: var(--hyper-chat-nav-bg); 67 | cursor: pointer; 68 | } 69 | button:hover { 70 | background-color: var(--hyper-chat-button-hover); 71 | } 72 | 73 | input { 74 | background-color: var(--hyper-chat-input-bg); 75 | border: 1px solid var(--hyper-chat-border); 76 | } 77 | 78 | input::placeholder { 79 | color: #888; 80 | } 81 | 82 | #chat { 83 | display: none; 84 | flex-direction: column; 85 | width: 100%; 86 | max-width: 600px; 87 | height: 80vh; 88 | box-sizing: border-box; 89 | background-color: var(--browser-theme-background); 90 | border: 1px solid var(--hyper-chat-border); 91 | border-radius: 8px; 92 | overflow: hidden; 93 | } 94 | 95 | #chat-room-info { 96 | display: none; 97 | flex-direction: column; 98 | align-items: flex-start; 99 | font-size: 0.8rem; 100 | color: #aaa; 101 | width: 100%; 102 | background-color: var(--hyper-chat-nav-bg); 103 | padding: 0.75rem 1rem; 104 | box-sizing: border-box; 105 | border-bottom: 1px solid var(--hyper-chat-border); 106 | } 107 | #chat-room-info div { 108 | margin-bottom: 0.2rem; 109 | } 110 | 111 | #messages { 112 | flex: 1; 113 | overflow-y: auto; 114 | padding: 1rem; 115 | box-sizing: border-box; 116 | } 117 | 118 | #messages::-webkit-scrollbar { 119 | width: 6px; 120 | } 121 | #messages::-webkit-scrollbar-track { 122 | background: var(--browser-theme-background); 123 | } 124 | #messages::-webkit-scrollbar-thumb { 125 | background-color: var(--hyper-chat-border); 126 | border-radius: 3px; 127 | } 128 | 129 | .message { 130 | display: flex; 131 | flex-direction: column; 132 | margin-bottom: 0.75rem; 133 | max-width: 75%; 134 | line-height: 1.3; 135 | } 136 | 137 | .message-left { 138 | align-items: flex-start; 139 | text-align: left; 140 | } 141 | 142 | .message-right { 143 | margin-left: auto; 144 | align-items: flex-end; 145 | text-align: right; 146 | } 147 | 148 | .message-text-left, 149 | .message-text-right { 150 | padding: 0.65rem 0.9rem; 151 | border-radius: 16px; 152 | word-wrap: break-word; 153 | font-size: 0.95rem; 154 | } 155 | 156 | .message-text-left { 157 | background-color: var(--hyper-chat-message-left); 158 | border-top-left-radius: 0; 159 | } 160 | 161 | .message-text-right { 162 | background-color: var(--hyper-chat-message-right); 163 | border-top-right-radius: 0; 164 | } 165 | 166 | .sender-left, 167 | .sender-right { 168 | margin-top: 0.3rem; 169 | font-size: 0.7rem; 170 | color: #aaa; 171 | } 172 | 173 | #message-form { 174 | display: flex; 175 | padding: 0.5rem 1rem; 176 | background-color: var(--hyper-chat-nav-bg); 177 | box-sizing: border-box; 178 | border-top: 1px solid var(--hyper-chat-border); 179 | gap: 0.5rem; 180 | } 181 | 182 | #message { 183 | flex: 1; 184 | border: 1px solid var(--hyper-chat-border); 185 | border-radius: 6px; 186 | background-color: var(--hyper-chat-input-bg); 187 | color: var(--hyper-chat-text-color); 188 | padding: 0.5rem 0.75rem; 189 | } 190 | 191 | #send-button { 192 | all: unset; 193 | cursor: pointer; 194 | display: flex; 195 | align-items: center; 196 | justify-content: center; 197 | padding: 0.4rem; 198 | border-radius: 50%; 199 | transition: background-color 0.2s; 200 | } 201 | 202 | #send-button img { 203 | width: 24px; 204 | height: 24px; 205 | filter: invert(60%); 206 | transition: filter 0.2s ease-in-out, transform 0.2s ease-in-out; 207 | } 208 | 209 | #send-button img:hover { 210 | filter: invert(100%); 211 | transform: scale(1.1); 212 | } 213 | 214 | a { 215 | color: var(--browser-theme-primary-highlight); 216 | text-decoration: underline; 217 | } 218 | a:hover { 219 | text-decoration: none; 220 | } 221 | -------------------------------------------------------------------------------- /src/pages/p2p/editor/codeEditor.js: -------------------------------------------------------------------------------- 1 | import { $, loadingSpinner, backdrop, iframe } from './common.js'; // Import common functions 2 | 3 | // Attach event listeners directly using the $ selector function 4 | [$('#htmlCode'), $('#javascriptCode'), $('#cssCode')].forEach(element => { 5 | element.addEventListener('input', () => update()); 6 | }); 7 | 8 | // CSS for published files: default white background, black text 9 | export let basicCSS = ` 10 | body { 11 | font-size: 1.2rem; 12 | margin: 0; 13 | padding: 0; 14 | background: #FFFFFF; 15 | color: #000000; 16 | } 17 | `; 18 | 19 | // CSS for iframe preview: Peersky P2P theme 20 | const previewCSS = ` 21 | :root { 22 | --browser-theme-background: #18181b; 23 | --browser-theme-text-color: #FFFFFF; 24 | } 25 | body { 26 | font-size: 1.2rem; 27 | margin: 0; 28 | padding: 0; 29 | background: var(--browser-theme-background); 30 | color: var(--browser-theme-text-color); 31 | } 32 | `; 33 | 34 | // Function for live rendering 35 | export function update() { 36 | let htmlCode = $('#htmlCode').value; 37 | console.log('HTML Code:', htmlCode); 38 | let cssCode = $('#cssCode').value; 39 | console.log('CSS Code:', cssCode); 40 | let javascriptCode = $('#javascriptCode').value; 41 | console.log('JavaScript Code:', javascriptCode); 42 | // Assemble all elements for the iframe preview, using previewCSS 43 | let iframeContent = ` 44 | 45 | 46 | 47 | ${htmlCode} 48 | `; 49 | 50 | let iframeDoc = iframe.contentWindow.document; 51 | iframeDoc.open(); 52 | iframeDoc.write(iframeContent); 53 | iframeDoc.close(); 54 | } 55 | 56 | // Show or hide the loading spinner 57 | export function showSpinner(show) { 58 | backdrop.style.display = show ? 'block' : 'none'; 59 | loadingSpinner.style.display = show ? 'block' : 'none'; 60 | } -------------------------------------------------------------------------------- /src/pages/p2p/editor/common.js: -------------------------------------------------------------------------------- 1 | // Common module for exports 2 | export function $(query) { 3 | return document.querySelector(query); 4 | } 5 | 6 | export const uploadButton = $('#uploadButton'); 7 | export const protocolSelect = $('#protocolSelect'); 8 | export const loadingSpinner = $('#loadingSpinner'); 9 | export const backdrop = $('#backdrop'); 10 | export const iframe = $('#viewer'); 11 | export const fetchButton = $('#fetchButton'); 12 | export const fetchCidInput = $('#fetchCidInput'); 13 | -------------------------------------------------------------------------------- /src/pages/p2p/editor/dweb.js: -------------------------------------------------------------------------------- 1 | import { update, showSpinner, basicCSS } from './codeEditor.js'; 2 | import { $, uploadButton, protocolSelect, fetchButton, fetchCidInput } from './common.js'; 3 | 4 | // assemble code before uploading 5 | export async function assembleCode() { 6 | // Display loading spinner 7 | showSpinner(true); 8 | 9 | // Combine your code into a single HTML file 10 | let combinedCode = ` 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | ${document.getElementById("htmlCode").value} 19 | 20 | 21 | `; 22 | 23 | // Convert the combined code into a Blob 24 | const blob = new Blob([combinedCode], { type: 'text/html' }); 25 | const file = new File([blob], "index.html", { type: 'text/html' }); 26 | 27 | // Upload the file 28 | await uploadFile(file); 29 | showSpinner(false); 30 | } 31 | 32 | uploadButton.addEventListener('click', assembleCode); 33 | 34 | // Upload code to Dweb 35 | async function uploadFile(file) { 36 | const protocol = protocolSelect.value; 37 | console.log(`[uploadFile] Uploading ${file.name}, protocol: ${protocol}`); 38 | 39 | if (protocol === 'hyper') { 40 | const hyperdriveUrl = await generateHyperdriveKey(); 41 | const url = `${hyperdriveUrl}${encodeURIComponent(file.name)}`; 42 | const cleanUrl = url.replace(/index\.html$/, ''); 43 | console.log(`[uploadFile] Hyper URL: ${url}`); 44 | 45 | try { 46 | const response = await fetch(url, { 47 | method: 'PUT', 48 | body: file, // Send raw file bytes 49 | headers: { 50 | 'Content-Type': file.type || 'text/html' 51 | } 52 | }); 53 | 54 | console.log(`[uploadFile] Response status: ${response.status}, ok: ${response.ok}`); 55 | if (!response.ok) { 56 | const errorText = await response.text(); 57 | console.error(`[uploadFile] Error uploading ${file.name}: ${errorText}`); 58 | addError(file.name, errorText); 59 | return; 60 | } 61 | 62 | addURL(cleanUrl); 63 | } catch (error) { 64 | console.error(`[uploadFile] Error uploading ${file.name}:`, error); 65 | addError(file.name, error.message); 66 | } finally { 67 | showSpinner(false); 68 | } 69 | } else { 70 | // IPFS upload with FormData 71 | const formData = new FormData(); 72 | console.log(`[uploadFile] Appending file for IPFS: ${file.name}`); 73 | formData.append('file', file, file.name); 74 | 75 | const url = `ipfs://bafyaabakaieac/`; 76 | console.log(`[uploadFile] IPFS URL: ${url}`); 77 | 78 | try { 79 | const response = await fetch(url, { 80 | method: 'PUT', 81 | body: formData, 82 | }); 83 | 84 | console.log(`[uploadFile] IPFS Response status: ${response.status}, ok: ${response.ok}`); 85 | if (!response.ok) { 86 | const errorText = await response.text(); 87 | console.error(`[uploadFile] IPFS Error: ${errorText}`); 88 | addError(file.name, errorText); 89 | return; 90 | } 91 | 92 | const locationHeader = response.headers.get('Location'); 93 | console.log(`[uploadFile] IPFS Location header: ${locationHeader}`); 94 | addURL(locationHeader); 95 | } catch (error) { 96 | console.error(`[uploadFile] Error uploading to IPFS:`, error); 97 | addError(file.name, error.message); 98 | } finally { 99 | showSpinner(false); 100 | } 101 | } 102 | } 103 | 104 | async function generateHyperdriveKey() { 105 | // Generate a unique name using timestamp and random string 106 | const timestamp = Date.now(); 107 | const randomStr = Math.random().toString(36).substring(2, 8); 108 | const uniqueName = `p2p-editor-${timestamp}-${randomStr}`; 109 | console.log(`[generateHyperdriveKey] Generating key for name: ${uniqueName}`); 110 | 111 | try { 112 | const response = await fetch(`hyper://localhost/?key=${encodeURIComponent(uniqueName)}`, { method: 'POST' }); 113 | if (!response.ok) { 114 | throw new Error(`Failed to generate Hyperdrive key: ${response.statusText}`); 115 | } 116 | const hyperUrl = await response.text(); 117 | console.log(`[generateHyperdriveKey] Hyperdrive URL: ${hyperUrl}`); 118 | return hyperUrl; // Returns the hyper:// URL 119 | } catch (error) { 120 | console.error('[generateHyperdriveKey] Error generating Hyperdrive key:', error); 121 | throw error; 122 | } 123 | } 124 | 125 | function addURL(url) { 126 | console.log(`[addURL] Adding URL: ${url}`); 127 | const listItem = document.createElement('li'); 128 | const link = document.createElement('a'); 129 | link.href = url; 130 | link.textContent = url; 131 | 132 | const copyContainer = document.createElement('span'); 133 | const copyIcon = '⊕'; 134 | copyContainer.innerHTML = copyIcon; 135 | copyContainer.onclick = function() { 136 | navigator.clipboard.writeText(url).then(() => { 137 | copyContainer.textContent = ' Copied!'; 138 | setTimeout(() => { 139 | copyContainer.innerHTML = copyIcon; 140 | }, 3000); 141 | }).catch(err => { 142 | console.error('[addURL] Error in copying text: ', err); 143 | }); 144 | }; 145 | 146 | listItem.appendChild(link); 147 | listItem.appendChild(copyContainer); 148 | uploadListBox.appendChild(listItem); 149 | } 150 | 151 | function addError(name, text) { 152 | console.log(`[addError] Error in ${name}: ${text}`); 153 | uploadListBox.innerHTML += `
  • Error in ${name}: ${text}
  • `; 154 | } 155 | 156 | // The fetchFromDWeb function detects which protocol is used and fetches the content 157 | async function fetchFromDWeb(url) { 158 | console.log(`[fetchFromDWeb] Fetching URL: ${url}`); 159 | if (!url) { 160 | alert("Please enter a CID or Name."); 161 | return; 162 | } 163 | 164 | if (!url.startsWith('ipfs://') && !url.startsWith('hyper://')) { 165 | alert("Invalid protocol. URL must start with ipfs:// or hyper://"); 166 | return; 167 | } 168 | 169 | try { 170 | const response = await fetch(url); 171 | console.log(`[fetchFromDWeb] Response status: ${response.status}`); 172 | const data = await response.text(); 173 | parseAndDisplayData(data); 174 | } catch (error) { 175 | console.error("[fetchFromDWeb] Error fetching from DWeb:", error); 176 | alert("Failed to fetch from DWeb."); 177 | } 178 | } 179 | 180 | // Modified event listener for fetchButton 181 | fetchButton.addEventListener('click', () => { 182 | const cidOrName = fetchCidInput.value; 183 | fetchFromDWeb(cidOrName); 184 | }); 185 | 186 | // Parse the data and display it in the code editor 187 | function parseAndDisplayData(data) { 188 | console.log(`[parseAndDisplayData] Parsing received data`); 189 | const parser = new DOMParser(); 190 | const doc = parser.parseFromString(data, 'text/html'); 191 | 192 | // Extracting CSS 193 | const styleElements = Array.from(doc.querySelectorAll('style')); 194 | 195 | // Remove the first element (agregore theme CSS) 196 | styleElements.shift(); 197 | 198 | // Now combine the CSS from the remaining 43 |
    44 | 53 | 54 |
    55 | 56 | 57 | 58 | 59 |
    60 |
      61 |
      62 | -------------------------------------------------------------------------------- /src/pages/p2p/wiki/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 10 | 11 | IPFS Wikipedia Search 12 | 15 | 16 | 17 |
      18 | Wikipedia on IPFS (credit: https://github.com/ipfs/distributed-wikipedia-mirror) 22 |
      23 | 30 | 31 |
      32 |
        33 |
        34 |
        35 | 36 |
        37 | Source Code | 38 | ipns:// 39 | hyper:// 40 |
        41 | 42 | 43 | 44 | 45 | -------------------------------------------------------------------------------- /src/pages/p2p/wiki/script.js: -------------------------------------------------------------------------------- 1 | const searchInput = document.getElementById("searchInput"); 2 | const suggestionsList = document.getElementById("suggestionsList"); 3 | const searchForm = document.getElementById("searchForm"); 4 | const errorMessage = document.getElementById("errorMessage"); 5 | let debounceTimeout; 6 | 7 | function isP2PEnvironment() { 8 | const protocol = window.location.protocol; 9 | const isIpfs = protocol.startsWith("ipfs") || protocol.startsWith("ipns"); 10 | const ua = navigator.userAgent.toLowerCase(); 11 | const isPeersky = ua.includes("peersky"); 12 | const isAgregore = ua.includes("agregore"); 13 | return isIpfs || isPeersky || isAgregore; 14 | } 15 | 16 | function getIpfsBaseUrl() { 17 | // If in a true P2P environment (or recognized one like Peersky or Agregore), use IPNS:// 18 | if (isP2PEnvironment()) { 19 | return "ipns://en.wikipedia-on-ipfs.org/"; 20 | } else { 21 | // Otherwise, fallback to the HTTP gateway 22 | return "https://en-wikipedia--on--ipfs-org.ipns.dweb.link/"; 23 | } 24 | } 25 | 26 | // Helper: fallback formatting in case we don’t get a resolved title. 27 | // It capitalizes each word and replaces spaces with underscores. 28 | function formatQuery(query) { 29 | return query 30 | .trim() 31 | .split(" ") 32 | .filter((word) => word.length) 33 | .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) 34 | .join("_"); 35 | } 36 | 37 | // Redirect to the final article link (IPNS or gateway). 38 | function navigateToArticle(query) { 39 | const formattedQuery = formatQuery(query); 40 | const finalUrl = getIpfsBaseUrl() + "wiki/" + formattedQuery; 41 | window.location.href = finalUrl; 42 | } 43 | 44 | /** 45 | * Use the Official Wikipedia API (over HTTPS) to find the canonical title for `query`. 46 | * Then we redirect to our IPFS version of that canonical title. 47 | * This step ensures that e.g. searching “computer science” 48 | * will get a properly capitalized official title. 49 | */ 50 | function resolveQuery(query) { 51 | const apiUrl = 52 | "https://en.wikipedia.org/w/api.php?origin=*&action=query&format=json&titles=" + 53 | encodeURIComponent(query); 54 | 55 | return fetch(apiUrl) 56 | .then((response) => response.json()) 57 | .then((data) => { 58 | // If there's any normalization (e.g. “computer science” → “Computer science”), use it. 59 | let resolvedTitle = query; 60 | if (data.query.normalized && data.query.normalized.length > 0) { 61 | resolvedTitle = data.query.normalized[0].to; 62 | } 63 | 64 | // Check if the page exists 65 | const pages = data.query.pages; 66 | const pageKey = Object.keys(pages)[0]; 67 | if (pages[pageKey].missing !== undefined) { 68 | // The page does not exist 69 | return null; 70 | } 71 | return resolvedTitle; 72 | }); 73 | } 74 | 75 | /** 76 | * Returns suggestions from Wikipedia on IPFS 77 | */ 78 | function fetchSuggestions(query) { 79 | const apiUrl = 80 | "https://en.wikipedia.org/w/api.php?origin=*&action=opensearch&format=json&search=" + 81 | encodeURIComponent(query); 82 | 83 | fetch(apiUrl) 84 | .then((response) => response.json()) 85 | .then((data) => { 86 | // data format: [searchTerm, [suggestions], [descriptions], [links]] 87 | const suggestions = data[1]; 88 | renderSuggestions(suggestions); 89 | }) 90 | .catch((error) => { 91 | console.error("Error fetching suggestions:", error); 92 | suggestionsList.innerHTML = ""; 93 | }); 94 | } 95 | 96 | // Display the list of suggestions as clickable items. 97 | function renderSuggestions(suggestions) { 98 | suggestionsList.innerHTML = ""; 99 | if (!suggestions || suggestions.length === 0) { 100 | return; 101 | } 102 | suggestions.forEach((suggestion) => { 103 | const li = document.createElement("li"); 104 | li.textContent = suggestion; 105 | 106 | li.addEventListener("click", () => { 107 | // When a suggestion is clicked, we first do a canonical resolution: 108 | resolveQuery(suggestion).then((resolvedTitle) => { 109 | if (resolvedTitle) { 110 | const finalTitle = resolvedTitle.replace(/ /g, "_"); 111 | const finalUrl = getIpfsBaseUrl() + "wiki/" + finalTitle; 112 | window.location.href = finalUrl; 113 | } else { 114 | // If no canonical resolution, just attempt the fallback format 115 | navigateToArticle(suggestion); 116 | } 117 | }); 118 | }); 119 | 120 | suggestionsList.appendChild(li); 121 | }); 122 | } 123 | 124 | // Listen for input events and fetch suggestions (debounced). 125 | searchInput.addEventListener("input", () => { 126 | const query = searchInput.value; 127 | clearTimeout(debounceTimeout); 128 | 129 | if (query.trim().length < 3) { 130 | suggestionsList.innerHTML = ""; 131 | return; 132 | } 133 | 134 | debounceTimeout = setTimeout(() => { 135 | fetchSuggestions(query); 136 | }, 300); 137 | }); 138 | 139 | // Handle the form submission: canonicalize via the official Wikipedia API, then go to IPFS link. 140 | searchForm.addEventListener("submit", (e) => { 141 | e.preventDefault(); 142 | errorMessage.textContent = ""; 143 | 144 | const query = searchInput.value.trim(); 145 | if (!query) return; 146 | 147 | const button = searchForm.querySelector("button"); 148 | button.textContent = "Loading..."; 149 | 150 | resolveQuery(query) 151 | .then((resolvedTitle) => { 152 | if (resolvedTitle) { 153 | const finalTitle = resolvedTitle.replace(/ /g, "_"); 154 | const finalUrl = getIpfsBaseUrl() + "wiki/" + finalTitle; 155 | window.location.href = finalUrl; 156 | } else { 157 | // Show an error if page not found and revert button text. 158 | errorMessage.textContent = `No Wikipedia article found for "${query}". Please check your spelling or try another term.`; 159 | button.textContent = "Search"; 160 | } 161 | }) 162 | .catch((err) => { 163 | console.error("Error resolving query:", err); 164 | errorMessage.textContent = `An error occurred while searching for "${query}". Please try again later.`; 165 | button.textContent = "Search"; 166 | }); 167 | }); 168 | -------------------------------------------------------------------------------- /src/pages/p2p/wiki/static/assets/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/p2plabsxyz/peersky-browser/884f0e70e38540494e2d541ea092a19fe4622239/src/pages/p2p/wiki/static/assets/favicon.ico -------------------------------------------------------------------------------- /src/pages/p2p/wiki/static/assets/wikipedia-on-ipfs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/p2plabsxyz/peersky-browser/884f0e70e38540494e2d541ea092a19fe4622239/src/pages/p2p/wiki/static/assets/wikipedia-on-ipfs.png -------------------------------------------------------------------------------- /src/pages/p2p/wiki/static/styles.css: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | margin: 0; 4 | padding: 0; 5 | height: 100%; 6 | font-family: Arial, sans-serif; 7 | background: #f9f9f9; 8 | } 9 | 10 | body { 11 | display: flex; 12 | flex-direction: column; 13 | justify-content: center; 14 | align-items: center; 15 | } 16 | 17 | .search-container { 18 | background: #fff; 19 | padding: 20px 25px; 20 | border-radius: 8px; 21 | box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); 22 | width: 320px; 23 | text-align: center; 24 | margin-bottom: 20px; 25 | margin-top: 30px; 26 | } 27 | .search-container img { 28 | max-width: 150px; 29 | height: auto; 30 | margin-bottom: 20px; 31 | } 32 | form { 33 | display: flex; 34 | flex-direction: column; 35 | align-items: stretch; 36 | } 37 | input[type="text"] { 38 | padding: 10px; 39 | font-size: 16px; 40 | border: 1px solid #ccc; 41 | border-radius: 4px; 42 | box-sizing: border-box; 43 | } 44 | button { 45 | margin-top: 10px; 46 | padding: 10px; 47 | font-size: 16px; 48 | border: none; 49 | border-radius: 4px; 50 | background-color: #007bff; 51 | color: #fff; 52 | cursor: pointer; 53 | } 54 | button:hover { 55 | background-color: #0056b3; 56 | } 57 | 58 | .suggestions { 59 | margin-top: 10px; 60 | list-style: none; 61 | padding: 0; 62 | color: oklch(0.556 0 0); 63 | border: 1px solid #eee; 64 | border-radius: 4px; 65 | max-height: 200px; 66 | overflow-y: auto; 67 | text-align: left; 68 | scrollbar-width: none; /* Firefox */ 69 | -ms-overflow-style: none; /* IE 10+ */ 70 | } 71 | .suggestions::-webkit-scrollbar { 72 | display: none; /* Chrome, Safari, Opera */ 73 | } 74 | .suggestions li { 75 | padding: 8px 10px; 76 | border-bottom: 1px solid #eee; 77 | cursor: pointer; 78 | } 79 | .suggestions li:hover { 80 | background: #f0f0f0; 81 | } 82 | .suggestions li:last-child { 83 | border-bottom: none; 84 | } 85 | 86 | #errorMessage { 87 | margin-top: 10px; 88 | color: oklch(0.708 0 0); 89 | font-size: 14px; 90 | min-height: 20px; 91 | } 92 | 93 | .info-container { 94 | font-size: 12px; 95 | color: oklch(0.556 0 0); 96 | margin-bottom: 20px; 97 | text-align: center; 98 | } 99 | .info-container a { 100 | text-decoration: underline dotted; 101 | color: oklch(0.556 0 0); 102 | } 103 | .info-container a:hover { 104 | text-decoration: none; 105 | } 106 | .search-container img:hover { 107 | animation: heartbeat 1s infinite; 108 | } 109 | @keyframes heartbeat { 110 | 0% { 111 | transform: scale(1); 112 | } 113 | 25% { 114 | transform: scale(1.1); 115 | } 116 | 50% { 117 | transform: scale(1); 118 | } 119 | 75% { 120 | transform: scale(1.1); 121 | } 122 | 100% { 123 | transform: scale(1); 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /src/pages/peer-bar.js: -------------------------------------------------------------------------------- 1 | class PeerBar extends HTMLElement { 2 | constructor() { 3 | super(); 4 | this.build(); 5 | } 6 | 7 | build() { 8 | const container = document.createElement('div'); 9 | container.className = 'peerbar'; 10 | 11 | const links = [ 12 | { href: 'peersky://p2p/chat/', img: 'chat.svg', alt: 'Peersky Chat' }, 13 | { href: 'peersky://p2p/upload/', img: 'upload.svg', alt: 'Peersky Upload' }, 14 | { href: 'peersky://p2p/editor/', img: 'build.svg', alt: 'Peersky Build' }, 15 | { href: 'peersky://p2p/wiki/', img: 'wikipedia.svg', alt: 'Peersky Wiki' }, 16 | { href: 'https://reader.distributed.press/', img: 'people.svg', alt: 'Social Reader' } 17 | ]; 18 | 19 | links.forEach(link => { 20 | const a = document.createElement('a'); 21 | a.href = link.href; 22 | const img = document.createElement('img'); 23 | img.src = `peersky://static/assets/svg/${link.img}`; 24 | img.alt = link.alt; 25 | a.appendChild(img); 26 | container.appendChild(a); 27 | }); 28 | 29 | this.appendChild(container); 30 | } 31 | } 32 | 33 | window.customElements.define('peer-bar', PeerBar); 34 | -------------------------------------------------------------------------------- /src/pages/plan1.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 8 | Plan 1 9 |
        10 |

        Plan 1

        11 |

        12 | A love letter to the grateful dead internet hypothesis 13 |

        14 | 15 |

        Elves

        16 |

        17 | Elves are discreet hypertext helpers. 18 |

        19 | 20 |

        Hello World

        21 |

        22 | The first elvish taught to most humans is hello-world. 23 |

        24 | 25 |

        Demo

        26 |
        27 | 28 |
        29 | 30 |

        QR Code

        31 |

        32 | qr-code required attributes: 33 |

        34 | 35 |
          36 |
        • src: a url
        • 37 |
        38 | 39 |

        40 | qr-code optional attributes 41 |

        42 | 43 |
          44 |
        • data-bg: custom background color
        • 45 |
        • data-fg: custom foreground color
        • 46 |
        47 | 48 |

        Demo

        49 | 50 |
        51 | 52 |
        53 | 54 |
        55 | 56 |

        Elvish

        57 |

        58 | "Learn to draw in style and when to teach them" - the mantra of the elves 59 |

        60 | 61 |

        Befriending Elves

        62 |

        63 | The function of an "elf" is to be a hypertext helper. Known elves speak the same universal dialect of elvish. 64 |

        65 | 66 |

        67 | The goodbye-world elf is known by the following definition: 68 |

        69 | 70 |
        const $ = elf('goodbye-world', { planet: 'World' })
        71 | 72 |

        Learn

        73 |

        74 | Elves tuck information away in what humans have categorized as "JSON", better known as Jason. 75 |

        76 | 77 |
        const data = $.learn()
        78 | 79 |

        Draw

        80 |

        81 | When elves draw, they have the full hypertext target to work with. Hypertext returned from the callback will be drawn automatically. The target is available in the draw callback, as well as optional hooks before and after the draw callback is updated, when Jason has new information to learn. 82 |

        83 | 84 |
        $.draw((target) => {
         85 |     const data = $.learn()
         86 |     return '<button>Hello ${data.planet}!</button>'
         87 |   }, {
         88 |   beforeUpdate: (target) => null,
         89 |   afterUpdate: (target) => null
         90 | })
        91 | 92 |

        Style

        93 |

        94 | Elves style the same way humans do, with cascading style sheets. 95 |

        96 | 97 |
        $.style(`
         98 |   & {
         99 |     color: red;
        100 |   }
        101 | `)
        102 | 103 |

        When

        104 |

        105 | When elves have detected a human interaction, they can process the event. 106 |

        107 | 108 |
        $.when('click', 'button', (event) =>  {
        109 |   $.teach({ planet: pluto })
        110 | })
        111 | 112 |

        Teach

        113 |

        114 | Elves can teach Jason basic data with a single object. An optional merge function may be passed to teach Jason more specifically. 115 |

        116 | 117 |
        $.teach(
        118 |   { planet: 'pluto'},
        119 |   (state, payload) => {
        120 |     return {
        121 |       ...state,
        122 |       defunctPlanets: [...new Set([...state.defunctPlanets, payload.planet])]
        123 |     }
        124 | })
        125 | 126 |

        Demo

        127 |

        128 | The demo below is inspired by the documentation above, but not exact. 129 |

        130 |
        131 | 132 |
        133 |
        134 | 135 | 136 | -------------------------------------------------------------------------------- /src/pages/preload.js: -------------------------------------------------------------------------------- 1 | const { ipcRenderer } = require('electron') 2 | 3 | const HAS_SHEET = ` 4 | [...document.styleSheets].some(s => { 5 | try { return !!s.cssRules } catch { return false } 6 | }) || !!document.querySelector('style,link[rel="stylesheet"]') 7 | ` 8 | 9 | window.addEventListener('DOMContentLoaded', async () => { 10 | try { 11 | // 1) skip pages that already have CSS 12 | const has = (new Function(`return ${HAS_SHEET}`))() 13 | if (!has) { 14 | // 2) ask main for vars.css & base.css 15 | const [varsCss, baseCss] = await Promise.all([ 16 | ipcRenderer.invoke('peersky-read-css', 'vars'), 17 | ipcRenderer.invoke('peersky-read-css', 'base') 18 | ]) 19 | 20 | // 3) inject as inline 80 | `; 81 | 82 | document.body.insertAdjacentHTML("beforeend", styles) 83 | } 84 | 85 | export function learn(link) { 86 | insight('elf:learn', link) 87 | return store.get(link) || {} 88 | } 89 | 90 | export function teach(link, knowledge, nuance = (s, p) => ({...s,...p})) { 91 | insight('elf:teach', link) 92 | store.set(link, knowledge, nuance) 93 | } 94 | 95 | export function when(link1, type, link2, callback) { 96 | const link = `${link1} ${link2}` 97 | insight('elf:when:'+type, link) 98 | listen.call(this, type, link, callback) 99 | } 100 | 101 | export default function elf(link, initialState = {}) { 102 | insight('elf', link) 103 | teach(link, initialState) 104 | 105 | return { 106 | link, 107 | learn: learn.bind(this, link), 108 | draw: draw.bind(this, link), 109 | style: style.bind(this, link), 110 | when: when.bind(this, link), 111 | teach: teach.bind(this, link), 112 | } 113 | } 114 | 115 | export function subscribe(fun) { 116 | notifications[fun.toString] = fun 117 | } 118 | 119 | export function unsubscribe(fun) { 120 | if(notifications[fun.toString]) { 121 | delete notifications[fun.toString] 122 | } 123 | } 124 | 125 | export function listen(type, link, handler = () => null) { 126 | const callback = (event) => { 127 | if( 128 | event.target && 129 | event.target.matches && 130 | event.target.matches(link) 131 | ) { 132 | 133 | insight('elf:listen:'+type, link) 134 | handler.call(this, event); 135 | } 136 | }; 137 | 138 | document.addEventListener(type, callback, true); 139 | 140 | if(observableEvents.includes(type)) { 141 | observe(link); 142 | } 143 | 144 | return function unlisten() { 145 | if(type === CREATE_EVENT) { 146 | disregard(link); 147 | } 148 | 149 | document.removeEventListener(type, callback, true); 150 | } 151 | } 152 | 153 | let links = [] 154 | 155 | function observe(link) { 156 | links = [...new Set([...links, link])]; 157 | maybeCreateReactive([...document.querySelectorAll(link)]) 158 | } 159 | 160 | function disregard(link) { 161 | const index = links.indexOf(link); 162 | if(index >= 0) { 163 | links = [ 164 | ...links.slice(0, index), 165 | ...links.slice(index + 1) 166 | ]; 167 | } 168 | } 169 | 170 | function maybeCreateReactive(targets) { 171 | targets 172 | .filter(x => !x.reactive) 173 | .forEach(dispatchCreate) 174 | } 175 | 176 | function getSubscribers({ target }) { 177 | if(links.length > 0) 178 | return [...target.querySelectorAll(links.join(', '))]; 179 | else 180 | return [] 181 | } 182 | 183 | function dispatchCreate(target) { 184 | insight('elf:create', target.localName) 185 | if(!target.id) target.id = self.crypto.randomUUID() 186 | target.dispatchEvent(new Event(CREATE_EVENT)) 187 | target.reactive = true 188 | } 189 | 190 | function elves() { 191 | new MutationObserver((mutationsList) => { 192 | const targets = [...mutationsList] 193 | .map(getSubscribers) 194 | .flatMap(x => x) 195 | maybeCreateReactive(targets) 196 | lazy() 197 | }).observe(document.body, { childList: true, subtree: true }); 198 | 199 | lazy() 200 | } 201 | 202 | function lazy() { 203 | const tags = new Set( 204 | [...document.querySelectorAll(':not(:defined)')] 205 | .map(({ tagName }) => tagName.toLowerCase()) 206 | ) 207 | 208 | tags.forEach(async (tag) => { 209 | const url = `peersky://static/elves/${tag}.js` 210 | const exists = (await fetch(url, { method: 'HEAD' })).ok 211 | if(!exists) return 212 | let definable = true 213 | await import(url).catch((e) => { 214 | definable = false 215 | console.error(e) 216 | }) 217 | try { 218 | definable = definable && document.querySelector(tag) && document.querySelector(tag).matches(':not(:defined)') 219 | if(definable) { 220 | customElements.define(tag, class WebComponent extends HTMLElement { 221 | constructor() { 222 | super(); 223 | } 224 | }); 225 | } 226 | } catch(e) { 227 | console.log('Error defining module:', tag, e) 228 | } 229 | }) 230 | } 231 | 232 | try { 233 | elves() 234 | } catch(e) { 235 | setTimeout(elves,1000) 236 | } 237 | 238 | function createStore(initialState = {}, subscribe = () => null) { 239 | let state = { 240 | ...initialState 241 | }; 242 | 243 | return { 244 | set: function(link, knowledge, nuance) { 245 | const wisdom = nuance(state[link] || {}, knowledge); 246 | 247 | state = { 248 | ...state, 249 | [link]: wisdom 250 | }; 251 | 252 | subscribe(link); 253 | }, 254 | 255 | get: function(link) { 256 | return state[link]; 257 | } 258 | } 259 | } 260 | 261 | -------------------------------------------------------------------------------- /src/pages/static/elves/goodbye-world.js: -------------------------------------------------------------------------------- 1 | import elf from 'peersky://static/elves/elf.js' 2 | 3 | const $ = elf('goodbye-world', { 4 | planet: 'World', 5 | defunctPlanets: [] 6 | }) 7 | 8 | $.draw((target) => { 9 | const data = $.learn() 10 | return `` 11 | }, { 12 | afterUpdate: (target) => { 13 | { 14 | const data = $.learn() 15 | if(data.defunctPlanets.includes('pluto')) { 16 | alert('bring back pluto') 17 | } 18 | } 19 | } 20 | }) 21 | 22 | $.when('click', 'button', (event) => { 23 | $.teach( 24 | { planet: 'pluto'}, 25 | (state, payload) => { 26 | return { 27 | ...state, 28 | defunctPlanets: [...new Set([...state.defunctPlanets, payload.planet])] 29 | } 30 | }) 31 | }) 32 | -------------------------------------------------------------------------------- /src/pages/static/elves/hello-world.js: -------------------------------------------------------------------------------- 1 | import elf from 'peersky://static/elves/elf.js' 2 | 3 | elf('hello-world') 4 | .draw((_target) => `Hello World`) 5 | -------------------------------------------------------------------------------- /src/pages/static/elves/qr-code.js: -------------------------------------------------------------------------------- 1 | import elf from 'peersky://static/elves/elf.js' 2 | import QrCreator from 'peersky://static/js/vendor/qr-creator/qr-creator.js' 3 | 4 | // utilize this to hop off the bifrost 5 | function sleep(D) { return new Promise(x => setTimeout(x,D))} 6 | 7 | const $ = elf('qr-code') 8 | 9 | $.draw(target => { 10 | const codes = $.learn() 11 | const code = target.getAttribute('src') 12 | const image = codes[code] 13 | const { fg='saddlebrown', bg='lemonchiffon' } = target.dataset 14 | generate(target, code, {fg, bg}) 15 | return image ? ` 16 | 19 | ` : 'loading...' 20 | }) 21 | 22 | async function generate(target, code, {fg, bg}) { 23 | if(target.code === code) return 24 | target.code = code 25 | await sleep(1) // get this off the bifrost 26 | const node = document.createElement('div') 27 | 28 | QrCreator.render({ 29 | text: code, 30 | radius: 0.5, // 0.0 to 0.5 31 | ecLevel: 'L', // L, M, Q, H 32 | fill: fg, // foreground color 33 | background: bg, // color or null for transparent 34 | size: 1080 // in pixels 35 | }, node); 36 | 37 | const dataURL = node.querySelector('canvas').toDataURL() 38 | 39 | $.teach({ [code]: `code`}) 40 | } 41 | 42 | $.when('click', '.portal', (event) => { 43 | const link = event.target.closest($.link) 44 | const code = link.getAttribute('src') || link.getAttribute('text') 45 | window.location.href = code 46 | }) 47 | 48 | $.style(` 49 | & { 50 | display: block; 51 | max-height: 100%; 52 | max-width: 100%; 53 | min-width: 120px; 54 | aspect-ratio: 1; 55 | position: relative; 56 | margin: auto; 57 | } 58 | & .portal { 59 | display: grid; 60 | height: 100%; 61 | width: 100%; 62 | place-content: center; 63 | border: 0; 64 | background: transparent; 65 | border-radius: 0; 66 | } 67 | & img { 68 | position: absolute; 69 | inset: 0; 70 | max-height: 100%; 71 | margin: auto; 72 | } 73 | `) 74 | 75 | -------------------------------------------------------------------------------- /src/pages/static/js/vendor/qr-creator/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 The Nimiq Foundation. 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 | -------------------------------------------------------------------------------- /src/pages/theme/base.css: -------------------------------------------------------------------------------- 1 | @import url('browser://theme/vars.css'); 2 | 3 | html, 4 | body { 5 | margin: auto; 6 | } 7 | 8 | body > pre, 9 | body > code { 10 | background: var(--browser-theme-background); 11 | color: var(--browser-theme-text-color); 12 | margin: 0px; 13 | padding: 12px; 14 | min-height: calc(100vh - 24px); 15 | } -------------------------------------------------------------------------------- /src/pages/theme/home.css: -------------------------------------------------------------------------------- 1 | body, 2 | html { 3 | height: 100%; 4 | margin: 0; 5 | padding: 0; 6 | display: flex; 7 | align-items: center; 8 | justify-content: center; 9 | background: url("peersky://static/assets/redwoods.jpg") no-repeat center 10 | center fixed; 11 | background-size: cover; 12 | font-family: monospace; 13 | position: relative; 14 | overflow-y: hidden; 15 | } 16 | 17 | body::before { 18 | content: ""; 19 | position: absolute; 20 | top: 0; 21 | left: 0; 22 | width: 100%; 23 | height: 100%; 24 | background-color: rgba(0, 0, 0, 0.2); 25 | z-index: 0; 26 | } 27 | 28 | .home-background { 29 | position: relative; 30 | height: 100vh; 31 | width: 100vw; 32 | display: flex; 33 | align-items: center; 34 | justify-content: center; 35 | z-index: 1; 36 | } 37 | 38 | .peerbar { 39 | position: absolute; 40 | top: 20px; 41 | left: 20px; 42 | display: flex; 43 | flex-direction: column; 44 | gap: 8px; 45 | padding: 8px; 46 | z-index: 3; 47 | background-color: rgba(255, 255, 255, 0.1); 48 | backdrop-filter: blur(10px) saturate(180%); 49 | border-radius: 12px; 50 | box-shadow: 0 4px 10px rgba(0, 0, 0, 0.2); 51 | border: 1px solid rgba(255, 255, 255, 0.2); 52 | } 53 | 54 | .peerbar a { 55 | display: block; 56 | } 57 | 58 | .peerbar img { 59 | width: 25px; 60 | height: 25px; 61 | filter: invert(100%) sepia(0%) saturate(0%) hue-rotate(0deg) brightness(100%) 62 | contrast(100%); 63 | padding: 5px; 64 | transition: transform 0.3s ease, filter 0.3s ease; 65 | } 66 | 67 | .peerbar img:hover { 68 | transform: scale(1.1); 69 | } 70 | -------------------------------------------------------------------------------- /src/pages/theme/index.css: -------------------------------------------------------------------------------- 1 | @import url('browser://theme/vars.css'); 2 | 3 | html, 4 | body { 5 | margin: auto; 6 | height: 100%; 7 | width: 100%; 8 | font-family: var(--browser-theme-font-family); 9 | } 10 | 11 | input, button, textarea, select, select *, option { 12 | color: inherit; 13 | font-family: inherit; 14 | font-size: inherit; 15 | background: none; 16 | padding:0.25em; 17 | border-radius: 0.25em; 18 | } 19 | 20 | #navbox { 21 | display: flex; 22 | align-items: center; 23 | background-color: var(--peersky-nav-background); 24 | gap: 8px; 25 | padding: 8px; 26 | box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); 27 | } 28 | 29 | .match-count { 30 | margin: 0 5px; 31 | font-size: 12px; 32 | color: var(--peersky-nav-button-color); 33 | min-width: 60px; 34 | text-align: center; 35 | } 36 | 37 | .nav-button { 38 | display: inline-flex; 39 | background: none; 40 | border: none; 41 | padding: 4px; 42 | cursor: pointer; 43 | color: var(--peersky-nav-button-color); 44 | transition: color 0.2s ease-in-out; 45 | } 46 | 47 | .nav-button.active { 48 | color: var(--peersky-nav-button-active); 49 | } 50 | 51 | .nav-button:disabled { 52 | color: var(--peersky-nav-button-inactive); 53 | cursor: not-allowed; 54 | } 55 | 56 | .nav-button:hover:not(:disabled) { 57 | color: var(--peersky-nav-button-hover); 58 | } 59 | 60 | .svg-container { 61 | display: flex; 62 | align-items: center; 63 | justify-content: center; 64 | } 65 | 66 | #url { 67 | background-color: var(--peersky-background-url-input); 68 | border: none; 69 | margin-left: 6px; 70 | margin-right: 6px; 71 | color: var(--browser-theme-text-color); 72 | font-size: medium; 73 | border-radius: 8px; 74 | display: block; 75 | width: 60%; 76 | padding: 0.4rem 10px; 77 | text-indent: 6px; 78 | } 79 | 80 | #url:hover { 81 | background-color: var(--peersky-background-url-input); 82 | box-shadow: 0 0 4px -1px #0a0a0a; 83 | } 84 | 85 | #url:focus { 86 | background-color: var(--peersky-background-url-input); 87 | outline: none; 88 | box-shadow: 0 0 4px -1px #0a0a0a; 89 | } 90 | 91 | find-menu { 92 | display: flex; 93 | flex-direction: row; 94 | align-items: center; 95 | margin-top: -4px; 96 | padding: 0.3em; 97 | border-radius: 6px; 98 | font-size: medium; 99 | background-color: var(--peersky-background-find-menu); 100 | position: absolute; 101 | z-index: 10; 102 | } 103 | 104 | .find-menu-input { 105 | flex: 1; 106 | padding: 0.5em; 107 | border: none; 108 | font-size: inherit; 109 | font-family: inherit; 110 | background-color: var(--peersky-background-find-menu); 111 | color: var(--browser-theme-text-color); 112 | outline: none; 113 | } 114 | 115 | .find-menu-button { 116 | padding: 0.5em; 117 | border: none; 118 | background: none; 119 | font-size: inherit; 120 | font-family: inherit; 121 | cursor: pointer; 122 | color: var(--peersky-nav-button-color); 123 | } 124 | 125 | .find-menu-button:hover { 126 | color: var(--peersky-nav-button-hover); 127 | } 128 | 129 | .hidden { 130 | display: none !important; 131 | } 132 | -------------------------------------------------------------------------------- /src/pages/theme/plan1.css: -------------------------------------------------------------------------------- 1 | .plan1-background { 2 | padding: 1rem; 3 | } 4 | 5 | .example { 6 | border: 1px solid #787878; 7 | background: 1px solid #e3e3e3; 8 | padding: 1rem; 9 | display: grid; 10 | place-items: center; 11 | } 12 | -------------------------------------------------------------------------------- /src/pages/theme/style.css: -------------------------------------------------------------------------------- 1 | @import url('browser://theme/vars.css'); 2 | 3 | * { 4 | box-sizing: border-box; 5 | } 6 | 7 | html { 8 | background: var(--browser-theme-background); 9 | color: var(--browser-theme-text-color); 10 | font-family: var(--browser-theme-font-family); 11 | font-size: inherit; 12 | } 13 | 14 | body { 15 | padding: 1em; 16 | } 17 | 18 | body > p, 19 | body > a, 20 | body > pre, 21 | body > ol, 22 | body > ul, 23 | body > table, 24 | body > img, 25 | body > form, 26 | body > iframe, 27 | body > video, 28 | body > audio, 29 | body > blockquote, 30 | body > details, 31 | body > h1, 32 | body > h2, 33 | body > h3, 34 | body > h4, 35 | body > h5, 36 | body > h6 { 37 | max-width: var(--browser-theme-max-width); 38 | margin-left: auto; 39 | margin-right: auto; 40 | display: block; 41 | } 42 | 43 | input, 44 | button, 45 | textarea, 46 | select, 47 | select *, 48 | option { 49 | color: inherit; 50 | font-family: inherit; 51 | font-size: inherit; 52 | background: none; 53 | padding: 0.5em; 54 | border-radius: 0.25em; 55 | } 56 | 57 | input { 58 | font-family: monospace; 59 | } 60 | 61 | textarea { 62 | width: 100%; 63 | resize: vertical; 64 | margin: 1em auto; 65 | font-family: monospace; 66 | } 67 | 68 | select option { 69 | background: var(--browser-theme-background); 70 | color: var(--browser-theme-text-color); 71 | } 72 | 73 | input, 74 | button, 75 | textarea, 76 | select, 77 | select *, 78 | video, 79 | dialog { 80 | border: 1px solid var(--browser-theme-primary-highlight); 81 | } 82 | 83 | fieldset { 84 | border: 1px solid var(--browser-theme-secondary-highlight); 85 | display: grid; 86 | grid-template-columns: 1fr 1fr; 87 | gap: 1em; 88 | margin-top: 1em; 89 | margin-bottom: 1em; 90 | } 91 | 92 | dialog { 93 | background: var(--browser-theme-background); 94 | color: var(--browser-theme-text-color); 95 | width: 80vw; 96 | height: 80vh; 97 | } 98 | 99 | details summary { 100 | cursor: pointer; 101 | } 102 | 103 | details summary > * { 104 | display: inline; 105 | } 106 | 107 | details summary::marker, 108 | details summary::-webkit-details-marker { 109 | color: var(--browser-theme-primary-highlight); 110 | } 111 | 112 | table { 113 | border-collapse: collapse; 114 | } 115 | 116 | body > table, 117 | body > pre { 118 | overflow-x: auto; 119 | } 120 | 121 | body > pre:only-child { 122 | color: inherit; 123 | font-size: inherit; 124 | background: none; 125 | padding: 0.5em; 126 | font-family: monospace; 127 | } 128 | 129 | th, 130 | td { 131 | border: 1px solid var(--browser-theme-primary-highlight); 132 | padding: 0.5em; 133 | text-align: left; 134 | } 135 | 136 | *::selection, 137 | option:hover { 138 | background: var(--browser-theme-primary-highlight); 139 | color: var(--browser-theme-text-color); 140 | } 141 | 142 | a { 143 | color: var(--browser-theme-secondary-highlight); 144 | text-decoration: underline; 145 | text-decoration-color: var(--browser-theme-primary-highlight); 146 | } 147 | 148 | a:hover { 149 | color: var(--browser-theme-background); 150 | background-color: var(--browser-theme-secondary-highlight); 151 | text-decoration: none; 152 | } 153 | 154 | a:visited { 155 | color: var(--browser-theme-primary-highlight); 156 | } 157 | 158 | img, 159 | video, 160 | svg, 161 | object, 162 | audio { 163 | width: 80%; 164 | display: block; 165 | margin: 1em auto; 166 | } 167 | 168 | iframe { 169 | display: block; 170 | margin: 1em auto; 171 | width: 100%; 172 | border: none; 173 | } 174 | 175 | pre { 176 | background: var(--browser-theme-primary-highlight); 177 | } 178 | 179 | code { 180 | background: var(--browser-theme-primary-highlight); 181 | font-weight: bold; 182 | padding: 0.25em; 183 | font-family: monospace; 184 | } 185 | 186 | blockquote { 187 | border-left: 1px solid var(--browser-theme-primary-highlight); 188 | margin: 1em; 189 | padding-left: 1em; 190 | } 191 | 192 | blockquote > *::before { 193 | content: "> "; 194 | color: var(--browser-theme-secondary-highlight); 195 | } 196 | 197 | pre > code { 198 | display: block; 199 | padding: 0.5em; 200 | background: var(--browser-theme-background); 201 | color: var(--browser-theme-text-color); 202 | } 203 | 204 | br { 205 | display: none; 206 | } 207 | 208 | ul > li { 209 | list-style-type: " ⟐ "; 210 | } 211 | 212 | hr { 213 | border-color: var(--browser-theme-primary-highlight); 214 | } 215 | 216 | *:focus { 217 | outline: 2px solid var(--browser-theme-secondary-highlight); 218 | } 219 | 220 | h1 { 221 | text-align: center; 222 | } 223 | 224 | /* Reset style for anchors added to headers */ 225 | h2 a, 226 | h3 a, 227 | h4 a { 228 | color: var(--browser-theme-text-color); 229 | text-decoration: none; 230 | } 231 | 232 | h1 a { 233 | color: var(--browser-theme-primary-highlight); 234 | text-decoration: none; 235 | } 236 | 237 | h1:hover::after, 238 | h2:hover::after, 239 | h3:hover::after, 240 | h4:hover::after { 241 | text-decoration: none !important; 242 | } 243 | 244 | h2::before { 245 | content: "## "; 246 | color: var(--browser-theme-secondary-highlight); 247 | } 248 | 249 | h3::before { 250 | content: "### "; 251 | color: var(--browser-theme-secondary-highlight); 252 | } 253 | 254 | h4::before { 255 | content: "#### "; 256 | color: var(--browser-theme-secondary-highlight); 257 | } 258 | 259 | *::-webkit-scrollbar { 260 | width: 1em; 261 | } 262 | 263 | *::-webkit-scrollbar-corner { 264 | background: rgba(0, 0, 0, 0); 265 | } 266 | 267 | *::-webkit-scrollbar-thumb { 268 | background-color: var(--browser-theme-primary-highlight); 269 | border: 2px solid transparent; 270 | background-clip: content-box; 271 | } 272 | 273 | *::-webkit-scrollbar-track { 274 | background-color: rgba(0, 0, 0, 0); 275 | } 276 | 277 | audio::-webkit-media-controls-mute-button, 278 | audio::-webkit-media-controls-play-button, 279 | audio::-webkit-media-controls-timeline-container, 280 | audio::-webkit-media-controls-current-time-display, 281 | audio::-webkit-media-controls-time-remaining-display, 282 | audio::-webkit-media-controls-timeline, 283 | audio::-webkit-media-controls-volume-slider-container, 284 | audio::-webkit-media-controls-volume-slider, 285 | audio::-webkit-media-controls-seek-back-button, 286 | audio::-webkit-media-controls-seek-forward-button, 287 | audio::-webkit-media-controls-fullscreen-button, 288 | audio::-webkit-media-controls-rewind-button, 289 | audio::-webkit-media-controls-return-to-realtime-button, 290 | audio::-webkit-media-controls-toggle-closed-captions-button { 291 | border: none; 292 | border-radius: none; 293 | } 294 | 295 | audio::-webkit-media-controls-timeline { 296 | background: var(--browser-theme-primary-highlight); 297 | margin: 0px 1em; 298 | border-radius: none; 299 | } 300 | 301 | audio::-webkit-media-controls-panel { 302 | background: var(--browser-theme-background); 303 | color: var(--browser-theme-text-color); 304 | font-family: var(--browser-theme-font-family); 305 | font-size: inherit; 306 | border-radius: none; 307 | } 308 | -------------------------------------------------------------------------------- /src/pages/theme/vars.css: -------------------------------------------------------------------------------- 1 | :root { 2 | /* Base16 Color Palette */ 3 | --base00: #000000; /* Black - Default dark background for UI or dark theme */ 4 | --base01: #18181b; /* Dark Gray - Lighter background for URL input, secondary UI elements, or dark theme background */ 5 | --base02: #27272a; /* Medium Gray - Navigation bar background or selection highlight */ 6 | --base03: #202125; /* Gray - Find menu background or subtle UI elements like line highlights */ 7 | --base04: #6b7280; /* Light Gray - Inactive button color or muted foreground text */ 8 | --base05: #9ca3af; /* Lighter Gray - Default button color or primary foreground text */ 9 | --base06: #e5e7eb; /* Very Light Gray - Button hover color or light foreground text */ 10 | --base07: #ffffff; /* White - Active button color, primary text in dark theme, or light theme background */ 11 | --base08: #ffccff; /* Light Violet - Light Accent Variant */ 12 | --base09: #9400d3; /* Dark Violet - Accent for Violet Theme */ 13 | --base0A: #90EE90; /* Light Green - Accent for Green Theme */ 14 | --base0B: #007f00; /* Dark Green - Accent for Green Theme */ 15 | --base0C: #00ffff; /* Cyan - Light Primary Highlight */ 16 | --base0D: #06b6d4; /* Blue - Darker Secondary Highlight */ 17 | --base0E: #ffff99; /* Light Yellow - Light Accent Variant */ 18 | --base0F: #d4a500; /* Dark Yellow - Accent for Yellow Theme */ 19 | 20 | /* Standardized Browser Theme Variables */ 21 | --browser-theme-font-family: Arial, sans-serif; 22 | --browser-theme-background: var(--base01); 23 | --browser-theme-text-color: var(--base07); 24 | --browser-theme-primary-highlight: var(--base0C); 25 | --browser-theme-secondary-highlight: var(--base0D); 26 | 27 | /* Peersky-Specific Variables */ 28 | --peersky-nav-background: var(--base02); 29 | --peersky-nav-button-color: var(--base05); 30 | --peersky-nav-button-hover: var(--base06); 31 | --peersky-nav-button-active: var(--base07); 32 | --peersky-nav-button-inactive: var(--base04); 33 | --peersky-background-url-input: var(--base01); 34 | --peersky-background-find-menu: var(--base03); 35 | } 36 | -------------------------------------------------------------------------------- /src/pages/track-box.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | 3 | class TrackedBox extends HTMLElement { 4 | constructor() { 5 | super(); 6 | this.observer = new ResizeObserver(() => this.emitResize()); 7 | this.initWebView(); 8 | } 9 | 10 | initWebView() { 11 | this.webview = document.createElement("webview"); 12 | this.webview.setAttribute("allowpopups", "true"); 13 | this.webview.style.height = "calc(100vh - 50px)"; 14 | this.webview.style.width = "100%"; 15 | 16 | // Dynamically resolve the preload script path 17 | this.webview.preload = "file://" + path.join(__dirname, "preload.js"); 18 | 19 | this.webview.addEventListener("did-navigate", (e) => { 20 | this.dispatchEvent( 21 | new CustomEvent("did-navigate", { 22 | detail: { url: this.webview.getURL() }, 23 | }) 24 | ); 25 | }); 26 | 27 | this.webview.addEventListener("page-title-updated", (e) => { 28 | this.dispatchEvent( 29 | new CustomEvent("page-title-updated", { detail: { title: e.title } }) 30 | ); 31 | }); 32 | 33 | this.appendChild(this.webview); 34 | } 35 | 36 | connectedCallback() { 37 | this.observer.observe(this); 38 | this.emitResize(); 39 | } 40 | 41 | disconnectedCallback() { 42 | this.observer.unobserve(this); 43 | } 44 | 45 | emitResize() { 46 | const { x, y, width, height } = this.getBoundingClientRect(); 47 | this.dispatchEvent( 48 | new CustomEvent("resize", { detail: { x, y, width, height } }) 49 | ); 50 | } 51 | 52 | goBack() { 53 | if (this.webview.canGoBack()) { 54 | this.webview.goBack(); 55 | } 56 | } 57 | 58 | goForward() { 59 | if (this.webview.canGoForward()) { 60 | this.webview.goForward(); 61 | } 62 | } 63 | 64 | reload() { 65 | this.webview.reload(); 66 | } 67 | 68 | stop() { 69 | this.webview.stop(); 70 | } 71 | 72 | loadURL(url) { 73 | this.webview.src = url; 74 | } 75 | 76 | getURL() { 77 | return this.webview.src; 78 | } 79 | 80 | executeJavaScript(script) { 81 | this.webview.executeJavaScript(script); 82 | } 83 | 84 | get webviewElement() { 85 | return this.webview; 86 | } 87 | } 88 | 89 | customElements.define("tracked-box", TrackedBox); 90 | -------------------------------------------------------------------------------- /src/protocols/config.js: -------------------------------------------------------------------------------- 1 | import { app } from "electron"; 2 | import path from "path"; 3 | import fs from "fs-extra"; 4 | import crypto from "hypercore-crypto"; 5 | import { libp2pOptions } from "./helia/libp2p.js"; 6 | import { getDefaultChainList } from "web3protocol/chains"; 7 | 8 | const USER_DATA = app.getPath("userData"); 9 | const DEFAULT_IPFS_DIR = path.join(USER_DATA, "ipfs"); 10 | const DEFAULT_HYPER_DIR = path.join(USER_DATA, "hyper"); 11 | const ENS_CACHE = path.join(USER_DATA, "ensCache.json"); 12 | const KEYPAIR_PATH = path.join(DEFAULT_HYPER_DIR, "swarm-keypair.json"); 13 | 14 | // Try loading an existing keypair from disk 15 | export function loadKeyPair() { 16 | if (fs.existsSync(KEYPAIR_PATH)) { 17 | const data = fs.readJsonSync(KEYPAIR_PATH); 18 | return { 19 | publicKey: Buffer.from(data.publicKey, "hex"), 20 | secretKey: Buffer.from(data.secretKey, "hex") 21 | }; 22 | } 23 | return null; 24 | } 25 | 26 | // Save a new keypair to disk 27 | export function saveKeyPair(keyPair) { 28 | // Ensure the hyper directory exists 29 | fs.ensureDirSync(DEFAULT_HYPER_DIR); 30 | 31 | fs.writeJsonSync(KEYPAIR_PATH, { 32 | publicKey: keyPair.publicKey.toString("hex"), 33 | secretKey: keyPair.secretKey.toString("hex") 34 | }); 35 | } 36 | 37 | export const ipfsOptions = { 38 | libp2p: await libp2pOptions(), 39 | repo: DEFAULT_IPFS_DIR, 40 | silent: true, 41 | preload: { 42 | enabled: false, 43 | }, 44 | config: { 45 | Addresses: { 46 | Swarm: [ 47 | "/ip4/0.0.0.0/tcp/4002", 48 | "/ip4/0.0.0.0/udp/4002/quic", 49 | "/ip6/::/tcp/4002", 50 | "/ip6/::/udp/4002/quic", 51 | ], 52 | }, 53 | Gateway: null, 54 | }, 55 | }; 56 | 57 | export const hyperOptions = { 58 | // All options here: https://github.com/datproject/sdk/#const-hypercore-hyperdrive-resolvename-keypair-derivesecret-registerextension-close--await-sdkopts 59 | storage: DEFAULT_HYPER_DIR, 60 | }; 61 | 62 | // Initialize RPC_URL using top-level await (avoiding an async IIFE) 63 | const chainList = await getDefaultChainList(); 64 | const targetChainId = 1; // Ethereum mainnet 65 | const targetChain = chainList.find((chain) => chain.id === targetChainId); 66 | export const RPC_URL = 67 | targetChain && targetChain.rpcUrls?.length > 0 68 | ? targetChain.rpcUrls[0] 69 | : (console.error(`Could not find RPC URL for chain ${targetChainId}`), null); 70 | 71 | // Initialize or load ENS cache 72 | let ensCache = new Map(); 73 | if (fs.existsSync(ENS_CACHE)) { 74 | try { 75 | const data = fs.readFileSync(ENS_CACHE, "utf-8"); 76 | const parsedData = JSON.parse(data); 77 | ensCache = new Map(parsedData); 78 | } catch (error) { 79 | console.error("Failed to load ENS cache from file:", error); 80 | } 81 | } else { 82 | console.log( 83 | "No existing ENS cache file found. Starting with an empty cache." 84 | ); 85 | } 86 | 87 | // Function to save cache to file 88 | export function saveEnsCache() { 89 | try { 90 | const data = JSON.stringify(Array.from(ensCache.entries()), null, 2); 91 | fs.writeFileSync(ENS_CACHE, data, "utf-8"); 92 | console.log("ENS cache saved to file."); 93 | } catch (error) { 94 | console.error("Failed to save ENS cache to file:", error); 95 | } 96 | } 97 | 98 | // Export the cache and save function 99 | export { ensCache }; 100 | -------------------------------------------------------------------------------- /src/protocols/helia/directoryListingTemplate.js: -------------------------------------------------------------------------------- 1 | export const directoryListingHtml = (shortCID, filesHtml) => ` 2 | 3 | 4 | 5 |
        6 | Peersky Browser Logo 7 |
        8 |

        Index of /ipfs/${shortCID}

        9 |
          10 | ${filesHtml} 11 |
        12 | 13 | 14 | `; 15 | -------------------------------------------------------------------------------- /src/protocols/helia/helia.js: -------------------------------------------------------------------------------- 1 | import { createHelia } from "helia"; 2 | 3 | export async function createNode() { 4 | return await createHelia(); 5 | } 6 | -------------------------------------------------------------------------------- /src/protocols/helia/libp2p.js: -------------------------------------------------------------------------------- 1 | import { createLibp2p } from "libp2p"; 2 | import { tcp } from "@libp2p/tcp"; 3 | import { noise } from "@chainsafe/libp2p-noise"; 4 | import { yamux } from "@chainsafe/libp2p-yamux"; 5 | import { mdns } from "@libp2p/mdns"; 6 | import { mplex } from "@libp2p/mplex"; 7 | import { webRTC, webRTCDirect } from "@libp2p/webrtc"; 8 | import { kadDHT } from "@libp2p/kad-dht"; 9 | import { webSockets } from "@libp2p/websockets"; 10 | import { bootstrap } from "@libp2p/bootstrap"; 11 | import { gossipsub } from "@chainsafe/libp2p-gossipsub"; 12 | import { identify, identifyPush } from "@libp2p/identify"; 13 | import { 14 | circuitRelayTransport, 15 | circuitRelayServer, 16 | } from "@libp2p/circuit-relay-v2"; 17 | 18 | // this list comes from https://github.com/ipfs/kubo/blob/da28fbc65a2e0f1ce59f9923823326ae2bc4f713/config/bootstrap_peers.go#L17 19 | const bootstrapConfig = { 20 | list: [ 21 | "/dnsaddr/bootstrap.libp2p.io/p2p/QmNnooDu7bfjPFoTZYxMNLWUQJyrVwtbZg5gBMjTezGAJN", 22 | "/dnsaddr/bootstrap.libp2p.io/p2p/QmbLHAnMoJPWSCR5Zhtx6BHJX9KiKNN6tpvbUcqanj75Nb", 23 | "/dnsaddr/bootstrap.libp2p.io/p2p/QmcZf59bWwK5XFi76CZX8cbJ4BhTzzA3gU1ZjYZcYW3dwt", 24 | "/ip4/104.131.131.82/tcp/4001/p2p/QmaCpDMGvV2BGHeYERUEnRQAwe3N8SzbUtfsmvsqQLuvuJ", 25 | ], 26 | }; 27 | 28 | const agentVersion = "p2plabsxyz/peersky-browser"; 29 | 30 | export async function libp2pOptions() { 31 | return await createLibp2p({ 32 | addresses: { 33 | listen: ["/ip4/0.0.0.0/tcp/0", "/ip6/::/tcp/0", "/webrtc"], 34 | }, 35 | transports: [ 36 | tcp(), 37 | webRTC(), 38 | webRTCDirect(), 39 | webSockets(), 40 | circuitRelayTransport(), 41 | ], 42 | connectionEncryption: [noise()], 43 | streamMuxers: [yamux(), mplex()], 44 | peerDiscovery: [mdns(), bootstrap(bootstrapConfig)], 45 | services: { 46 | dht: kadDHT({ 47 | clientMode: false, // Disable DHT 48 | }), 49 | pubsub: gossipsub({ 50 | emitSelf: true, // Enable pubsub 51 | }), 52 | identify: identify({ 53 | agentVersion, 54 | }), 55 | identifyPush: identifyPush({ agentVersion }), 56 | }, 57 | relay: circuitRelayServer(), 58 | }); 59 | } 60 | -------------------------------------------------------------------------------- /src/protocols/hyper-handler.js: -------------------------------------------------------------------------------- 1 | import { create as createSDK } from "hyper-sdk"; 2 | import makeHyperFetch from "hypercore-fetch"; 3 | import { Readable, PassThrough } from "stream"; 4 | import fs from "fs-extra"; 5 | import HyperDHT from "hyperdht"; 6 | import Hyperswarm from "hyperswarm"; 7 | import crypto from "hypercore-crypto"; 8 | import b4a from "b4a"; 9 | import { hyperOptions, loadKeyPair, saveKeyPair } from "./config.js"; 10 | 11 | let sdk, fetch; 12 | let swarm = null; 13 | 14 | // Mapping: roomKey -> hypercore feed (stores chat messages) 15 | const roomFeeds = {}; 16 | // Mapping: roomKey -> array of SSE clients (for real-time updates) 17 | const roomSseClients = {}; 18 | 19 | let peers = []; 20 | // Keep track of which rooms we’ve joined in the swarm (avoid double-joining) 21 | const joinedRooms = new Set(); 22 | 23 | function createDHT() { 24 | const dht = new HyperDHT({ ephemeral: false }); 25 | dht.on("error", (err) => { 26 | console.error("HyperDHT error:", err); 27 | }); 28 | return dht; 29 | } 30 | 31 | // Initialize Hyper SDK (once) 32 | async function initializeHyperSDK(options) { 33 | if (sdk && fetch) return fetch; 34 | 35 | console.log("Initializing Hyper SDK..."); 36 | 37 | // Load or generate the swarm keypair 38 | let keyPair = loadKeyPair(); 39 | if (!keyPair) { 40 | keyPair = crypto.keyPair(); 41 | saveKeyPair(keyPair); 42 | console.log("Generated new swarm keypair"); 43 | } else { 44 | console.log("Loaded existing swarm keypair"); 45 | } 46 | 47 | sdk = await createSDK(options); 48 | fetch = makeHyperFetch({ sdk, writable: true }); 49 | console.log("Hyper SDK initialized."); 50 | return fetch; 51 | } 52 | 53 | // Initialize Hyperswarm with the keypair from hyperOptions and a custom DHT. 54 | async function initializeSwarm() { 55 | if (swarm) return; 56 | 57 | const keyPair = hyperOptions.keyPair; 58 | const dht = createDHT(); 59 | 60 | swarm = new Hyperswarm({ 61 | keyPair, 62 | dht, 63 | firewall: (remotePublicKey, details) => false, 64 | }); 65 | 66 | swarm.on("error", (err) => { 67 | console.error("Hyperswarm error:", err); 68 | }); 69 | 70 | // On new peer connections: 71 | swarm.on("connection", (connection, info) => { 72 | const shortID = connection.remotePublicKey 73 | ? b4a.toString(connection.remotePublicKey, "hex").substr(0, 6) 74 | : "peer"; 75 | 76 | if (info.discoveryKey) { 77 | const discKey = b4a.toString(info.discoveryKey, "hex"); 78 | console.log(`New peer [${shortID}] connected, discKey: ${discKey}`); 79 | } else { 80 | console.log(`New peer [${shortID}] connected (no discKey).`); 81 | } 82 | 83 | connection.on("error", (err) => { 84 | console.error(`Peer [${shortID}] connection error:`, err); 85 | }); 86 | 87 | peers.push({ connection, shortID }); 88 | console.log(`Peers connected: ${peers.length}`); 89 | broadcastPeerCount(); 90 | 91 | // Replicate all known feeds on this connection. 92 | for (const feed of Object.values(roomFeeds)) { 93 | feed.replicate(connection); 94 | } 95 | 96 | connection.on("data", (rawData) => { 97 | let msg; 98 | try { 99 | msg = JSON.parse(rawData.toString()); 100 | } catch { 101 | msg = { 102 | sender: shortID, 103 | message: rawData.toString(), 104 | timestamp: Date.now(), 105 | }; 106 | } 107 | msg.sender = shortID; 108 | if (!msg.timestamp) msg.timestamp = Date.now(); 109 | console.log(`Peer [${shortID}] =>`, msg); 110 | if (msg.roomKey && roomFeeds[msg.roomKey]) { 111 | appendMessageToFeed(msg.roomKey, { 112 | sender: msg.sender, 113 | message: msg.message, 114 | timestamp: msg.timestamp, 115 | }).catch((err) => { 116 | console.error("Error appending peer msg to feed:", err); 117 | }); 118 | } 119 | }); 120 | 121 | connection.on("close", () => { 122 | peers = peers.filter((p) => p.connection !== connection); 123 | console.log(`Peer [${shortID}] disconnected. Peers: ${peers.length}`); 124 | broadcastPeerCount(); 125 | }); 126 | }); 127 | } 128 | 129 | // Main exported function to handle the `hyper://` protocol. 130 | export async function createHandler(options, session) { 131 | await initializeHyperSDK(options); 132 | await initializeSwarm(); 133 | 134 | return async function protocolHandler(req, callback) { 135 | const { url, method, headers, uploadData } = req; 136 | const urlObj = new URL(url); 137 | const protocol = urlObj.protocol.replace(":", ""); 138 | const pathname = urlObj.pathname; 139 | 140 | console.log(`Handling request: ${method} ${url}`); 141 | 142 | try { 143 | if ( 144 | protocol === "hyper" && 145 | (urlObj.hostname === "chat" || pathname.startsWith("/chat")) 146 | ) { 147 | await handleChatRequest(req, callback, session); 148 | } else { 149 | await handleHyperRequest(req, callback, session); 150 | } 151 | } catch (err) { 152 | console.error("Failed to handle Hyper request:", err); 153 | callback({ 154 | statusCode: 500, 155 | headers: { "Content-Type": "text/plain" }, 156 | data: Readable.from([`Error handling Hyper request: ${err.message}`]), 157 | }); 158 | } 159 | }; 160 | } 161 | 162 | // Handle all chat‐related endpoints (create room, join room, send/receive messages, etc). 163 | async function handleChatRequest(req, callback, session) { 164 | const { url, method, uploadData } = req; 165 | const urlObj = new URL(url); 166 | const action = urlObj.searchParams.get("action"); 167 | const roomKey = urlObj.searchParams.get("roomKey"); 168 | 169 | console.log(`Chat request: ${method} ${url}`); 170 | 171 | try { 172 | if (method === "POST" && action === "create-key") { 173 | // Create a brand-new random roomKey 174 | const newRoomKey = await generateChatRoom(); 175 | console.log("Generated new chat room key:", newRoomKey); 176 | callback({ 177 | statusCode: 200, 178 | headers: { "Content-Type": "application/json" }, 179 | data: Readable.from([ 180 | Buffer.from(JSON.stringify({ roomKey: newRoomKey })), 181 | ]), 182 | }); 183 | } else if (method === "POST" && action === "join") { 184 | // Join an existing room 185 | if (!roomKey) throw new Error("Missing roomKey in join request"); 186 | console.log("Joining chat room:", roomKey); 187 | await joinChatRoom(roomKey); 188 | callback({ 189 | statusCode: 200, 190 | headers: { "Content-Type": "application/json" }, 191 | data: Readable.from([ 192 | Buffer.from(JSON.stringify({ message: "Joined chat room" })), 193 | ]), 194 | }); 195 | } else if (method === "POST" && action === "send") { 196 | // Send a message 197 | if (!roomKey) throw new Error("Missing roomKey in send request"); 198 | const { sender, message } = await getJSONBody(uploadData, session); 199 | console.log(`Sending message [${sender}]: ${message}`); 200 | 201 | // Append to feed 202 | await appendMessageToFeed(roomKey, { 203 | sender, 204 | message, 205 | timestamp: Date.now(), 206 | }); 207 | 208 | // Broadcast to peers 209 | const data = JSON.stringify({ 210 | sender, 211 | message, 212 | timestamp: Date.now(), 213 | roomKey, 214 | }); 215 | sendMessageToPeers(data); 216 | 217 | callback({ 218 | statusCode: 200, 219 | headers: { "Content-Type": "application/json" }, 220 | data: Readable.from([ 221 | Buffer.from(JSON.stringify({ message: "Message sent" })), 222 | ]), 223 | }); 224 | } else if (method === "GET" && action === "receive") { 225 | // SSE for receiving messages in real-time 226 | if (!roomKey) throw new Error("Missing roomKey in receive request"); 227 | console.log("Setting up SSE for room:", roomKey); 228 | 229 | const feed = roomFeeds[roomKey]; 230 | if (!feed) throw new Error("Feed not initialized for this room"); 231 | 232 | const stream = new PassThrough(); 233 | session.messageStream = stream; // keep reference 234 | 235 | // Replay the entire feed so the client sees the full history 236 | for (let i = 0; i < feed.length; i++) { 237 | const msg = await feed.get(i); 238 | stream.write(`data: ${JSON.stringify(msg)}\n\n`); 239 | } 240 | 241 | // Keep the SSE connection alive 242 | const keepAlive = setInterval(() => { 243 | stream.write(":\n\n"); 244 | }, 15000); 245 | 246 | // Track SSE client 247 | if (!roomSseClients[roomKey]) { 248 | roomSseClients[roomKey] = []; 249 | } 250 | roomSseClients[roomKey].push(stream); 251 | 252 | stream.on("close", () => { 253 | clearInterval(keepAlive); 254 | roomSseClients[roomKey] = roomSseClients[roomKey].filter( 255 | (s) => s !== stream 256 | ); 257 | }); 258 | 259 | callback({ 260 | statusCode: 200, 261 | headers: { 262 | "Content-Type": "text/event-stream", 263 | "Cache-Control": "no-cache", 264 | Connection: "keep-alive", 265 | }, 266 | data: stream, 267 | }); 268 | } else { 269 | // Unknown action 270 | callback({ 271 | statusCode: 400, 272 | headers: { "Content-Type": "text/plain" }, 273 | data: Readable.from(["Invalid chat action"]), 274 | }); 275 | } 276 | } catch (err) { 277 | console.error("Error in handleChatRequest:", err); 278 | callback({ 279 | statusCode: 500, 280 | headers: { "Content-Type": "text/plain" }, 281 | data: Readable.from([`Error in chat request: ${err.message}`]), 282 | }); 283 | } 284 | } 285 | 286 | // Handle general hyper:// requests not related to “chat” API routes. 287 | async function handleHyperRequest(req, callback, session) { 288 | const { url, method = "GET", headers = {}, uploadData } = req; 289 | const fetchFn = await initializeHyperSDK(); // ensure sdk/fetch is initted 290 | 291 | let body; 292 | if (uploadData) { 293 | try { 294 | body = readBody(uploadData, session); 295 | } catch (err) { 296 | console.error("Error reading uploadData:", err); 297 | callback({ 298 | statusCode: 400, 299 | headers: { "Content-Type": "text/plain" }, 300 | data: Readable.from(["Invalid upload data"]), 301 | }); 302 | return; 303 | } 304 | } 305 | 306 | try { 307 | const resp = await fetchFn(url, { 308 | method, 309 | headers, 310 | body, 311 | duplex: "half", 312 | }); 313 | if (resp.body) { 314 | const responseStream = Readable.from(resp.body); 315 | console.log("Response received:", resp.status); 316 | callback({ 317 | statusCode: resp.status, 318 | headers: Object.fromEntries(resp.headers), 319 | data: responseStream, 320 | }); 321 | } else { 322 | console.warn("No response body."); 323 | callback({ 324 | statusCode: resp.status, 325 | headers: Object.fromEntries(resp.headers), 326 | data: Readable.from([""]), 327 | }); 328 | } 329 | } catch (err) { 330 | console.error("Failed to fetch from Hyper SDK:", err); 331 | callback({ 332 | statusCode: 500, 333 | headers: { "Content-Type": "text/plain" }, 334 | data: Readable.from([`Error fetching data: ${err.message}`]), 335 | }); 336 | } 337 | } 338 | 339 | // Helper: read the upload body (files, bytes, or blobs) into a stream. 340 | function readBody(body, session) { 341 | const stream = new PassThrough(); 342 | (async () => { 343 | try { 344 | for (const data of body || []) { 345 | if (data.bytes) { 346 | stream.write(data.bytes); 347 | } else if (data.file) { 348 | const fileStream = fs.createReadStream(data.file); 349 | fileStream.pipe(stream, { end: false }); 350 | await new Promise((resolve, reject) => { 351 | fileStream.on("end", resolve); 352 | fileStream.on("error", reject); 353 | }); 354 | } else if (data.blobUUID) { 355 | const blobData = await session.getBlobData(data.blobUUID); 356 | stream.write(blobData); 357 | } 358 | } 359 | stream.end(); 360 | } catch (err) { 361 | console.error("Error reading request body:", err); 362 | stream.emit("error", err); 363 | } 364 | })(); 365 | return stream; 366 | } 367 | 368 | // Helper: read JSON body from a request. 369 | async function getJSONBody(uploadData, session) { 370 | const stream = readBody(uploadData, session); 371 | const chunks = []; 372 | for await (const chunk of stream) { 373 | chunks.push(chunk); 374 | } 375 | const buf = Buffer.concat(chunks); 376 | console.log("Request body received (JSON):", buf.toString()); 377 | return JSON.parse(buf.toString()); 378 | } 379 | 380 | // Broadcast updated peer count to all SSE clients (in all rooms). 381 | function broadcastPeerCount() { 382 | const cnt = peers.length; 383 | console.log(`Broadcasting peer count: ${cnt}`); 384 | for (const streams of Object.values(roomSseClients)) { 385 | for (const s of streams) { 386 | s.write(`event: peersCount\ndata: ${cnt}\n\n`); 387 | } 388 | } 389 | } 390 | 391 | // Send a raw data string to all currently connected peers (swarm connections). 392 | function sendMessageToPeers(data) { 393 | console.log(`Broadcasting message to ${peers.length} peers`); 394 | for (const { connection } of peers) { 395 | // If the connection is still open, write data 396 | if (!connection.destroyed) { 397 | connection.write(data); 398 | } 399 | } 400 | } 401 | 402 | // Append a message object to the feed for a given roomKey. 403 | async function appendMessageToFeed(roomKey, { sender, message, timestamp }) { 404 | const feed = roomFeeds[roomKey]; 405 | if (!feed) { 406 | throw new Error(`Feed not initialized for room ${roomKey}`); 407 | } 408 | const obj = { 409 | sender, 410 | message, 411 | timestamp: timestamp || Date.now(), 412 | }; 413 | await feed.append(obj); 414 | } 415 | 416 | // Create a brand-new random 32-byte hex “roomKey”. 417 | async function generateChatRoom() { 418 | const buf = crypto.randomBytes(32); 419 | return b4a.toString(buf, "hex"); 420 | } 421 | 422 | // Join a chat room: create/load the feed, join the swarm topic, etc. 423 | async function joinChatRoom(roomKey) { 424 | // 1) Load the feed deterministically using the room key via the 'name' option. 425 | let feed = roomFeeds[roomKey]; 426 | if (!feed) { 427 | feed = sdk.corestore.get({ 428 | name: "chat-" + roomKey, // Use a deterministic name so every device gets the same feed. 429 | valueEncoding: "json", 430 | }); 431 | await feed.ready(); 432 | roomFeeds[roomKey] = feed; 433 | 434 | // 2) When new entries are appended to this feed, broadcast them via SSE. 435 | feed.on("append", async () => { 436 | const idx = feed.length - 1; 437 | const msg = await feed.get(idx); 438 | const sseArray = roomSseClients[roomKey] || []; 439 | for (const s of sseArray) { 440 | s.write(`data: ${JSON.stringify(msg)}\n\n`); 441 | } 442 | }); 443 | } 444 | 445 | // 3) Join the swarm (only once per room) 446 | if (!joinedRooms.has(roomKey)) { 447 | joinedRooms.add(roomKey); 448 | const topicBuf = b4a.from(roomKey, "hex"); 449 | swarm.join(topicBuf, { client: true, server: true }); 450 | await swarm.flush(); 451 | console.log(`Joined swarm for room: ${roomKey}`); 452 | } else { 453 | console.log(`Already joined swarm for room: ${roomKey}`); 454 | } 455 | } 456 | -------------------------------------------------------------------------------- /src/protocols/peersky-protocol.js: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | import { fileURLToPath } from 'url'; 3 | import mime from "mime-types"; 4 | import { Readable } from 'stream'; 5 | import ScopedFS from 'scoped-fs'; 6 | 7 | const __dirname = fileURLToPath(new URL('./', import.meta.url)); 8 | const pagesPath = path.join(__dirname, '../pages'); 9 | const fs = new ScopedFS(pagesPath); 10 | 11 | const CHECK_PATHS = [ 12 | (path) => path, 13 | (path) => path + '/index.html', 14 | (path) => path + '.html' 15 | ]; 16 | 17 | async function resolveFile(filePath) { 18 | for (const toTry of CHECK_PATHS) { 19 | const tryPath = toTry(filePath); 20 | if (await exists(tryPath)) return tryPath; 21 | } 22 | throw new Error('File not found'); 23 | } 24 | 25 | async function exists(filePath) { 26 | return new Promise((resolve, reject) => { 27 | fs.stat(filePath, (err, stat) => { 28 | if (err) { 29 | if (err.code === 'ENOENT') resolve(false); 30 | else reject(err); 31 | } else resolve(stat.isFile()); 32 | }); 33 | }); 34 | } 35 | 36 | export async function createHandler() { 37 | return async function protocolHandler({ url }, sendResponse) { 38 | const parsedUrl = new URL(url); 39 | let filePath = parsedUrl.hostname + parsedUrl.pathname; 40 | 41 | if (filePath === '/') filePath = 'home'; // default to home page 42 | 43 | try { 44 | const resolvedPath = await resolveFile(filePath); 45 | const format = path.extname(resolvedPath); 46 | if (!['', '.html', '.js', '.css', '.png', '.jpg', '.jpeg', '.gif', '.svg'].includes(format)) { 47 | sendResponse({ 48 | statusCode: 403, 49 | headers: { 50 | 'Content-Type': 'text/plain', 51 | 'Access-Control-Allow-Origin': '*', 52 | 'Allow-CSP-From': '*', 53 | 'Cache-Control': 'no-cache' 54 | }, 55 | data: Readable.from(['Unsupported file type']) 56 | }); 57 | return; 58 | } 59 | 60 | const statusCode = 200; 61 | const data = fs.createReadStream(resolvedPath); 62 | const contentType = mime.lookup(resolvedPath) || 'text/plain'; 63 | const headers = { 64 | 'Content-Type': contentType, 65 | 'Access-Control-Allow-Origin': '*', 66 | 'Allow-CSP-From': '*', 67 | 'Cache-Control': 'no-cache' 68 | }; 69 | 70 | sendResponse({ 71 | statusCode, 72 | headers, 73 | data 74 | }); 75 | } catch (e) { 76 | sendResponse({ 77 | statusCode: 404, 78 | headers: { 79 | 'Content-Type': 'text/html', 80 | 'Access-Control-Allow-Origin': '*', 81 | 'Allow-CSP-From': '*', 82 | 'Cache-Control': 'no-cache' 83 | }, 84 | data: fs.createReadStream('404.html') 85 | }); 86 | } 87 | }; 88 | } -------------------------------------------------------------------------------- /src/protocols/theme-handler.js: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | import { fileURLToPath } from 'url'; 3 | import mime from "mime-types"; 4 | import { Readable } from 'stream'; 5 | import ScopedFS from 'scoped-fs'; 6 | 7 | const __dirname = fileURLToPath(new URL('./', import.meta.url)); 8 | const themePath = path.join(__dirname, '../pages/theme'); 9 | const pagesPath = path.join(__dirname, '../pages'); 10 | const themeFS = new ScopedFS(themePath); 11 | const pagesFS = new ScopedFS(pagesPath); 12 | 13 | const CHECK_PATHS = [ 14 | (path) => path, 15 | (path) => path + 'index.html', 16 | (path) => path + '/index.html', 17 | (path) => path + '.html' 18 | ]; 19 | 20 | async function resolveFile(filePath) { 21 | for (const toTry of CHECK_PATHS) { 22 | const tryPath = toTry(filePath); 23 | if (await exists(tryPath)) return tryPath; 24 | } 25 | throw new Error('File not found'); 26 | } 27 | 28 | async function exists(filePath) { 29 | return new Promise((resolve, reject) => { 30 | themeFS.stat(filePath, (err, stat) => { 31 | if (err) { 32 | if (err.code === 'ENOENT') resolve(false); 33 | else reject(err); 34 | } else resolve(stat.isFile()); 35 | }); 36 | }); 37 | } 38 | 39 | async function get404Response() { 40 | try { 41 | await new Promise((resolve, reject) => { 42 | pagesFS.stat('404.html', (err, stat) => { 43 | if (err) reject(err); 44 | else resolve(stat.isFile()); 45 | }); 46 | }); 47 | return { 48 | statusCode: 404, 49 | headers: { 50 | 'Content-Type': 'text/html', 51 | 'Access-Control-Allow-Origin': '*', 52 | 'Allow-CSP-From': '*', 53 | 'Cache-Control': 'no-cache' 54 | }, 55 | data: pagesFS.createReadStream('404.html') 56 | }; 57 | } catch (e) { 58 | console.error('Failed to serve 404.html:', e); 59 | return { 60 | statusCode: 404, 61 | headers: { 62 | 'Content-Type': 'text/plain', 63 | 'Access-Control-Allow-Origin': '*', 64 | 'Allow-CSP-From': '*', 65 | 'Cache-Control': 'no-cache' 66 | }, 67 | data: Readable.from(['File not found']) 68 | }; 69 | } 70 | } 71 | 72 | export async function createHandler() { 73 | return async function protocolHandler({ url }, sendResponse) { 74 | const parsedUrl = new URL(url); 75 | 76 | if (parsedUrl.hostname === 'theme') { 77 | const fileName = parsedUrl.pathname.slice(1); 78 | 79 | try { 80 | const resolvedPath = await resolveFile(fileName); 81 | const statusCode = 200; 82 | const data = themeFS.createReadStream(resolvedPath); 83 | const contentType = mime.lookup(resolvedPath) || 'text/plain'; 84 | const headers = { 85 | 'Content-Type': contentType, 86 | 'Access-Control-Allow-Origin': '*', 87 | 'Allow-CSP-From': '*', 88 | 'Cache-Control': 'no-cache' 89 | }; 90 | 91 | sendResponse({ 92 | statusCode, 93 | headers, 94 | data 95 | }); 96 | } catch (e) { 97 | console.log('File not found:', fileName); 98 | sendResponse(await get404Response()); 99 | } 100 | } else { 101 | sendResponse(await get404Response()); 102 | } 103 | }; 104 | } 105 | -------------------------------------------------------------------------------- /src/protocols/web3-handler.js: -------------------------------------------------------------------------------- 1 | import { Client } from 'web3protocol'; 2 | import { getDefaultChainList } from 'web3protocol/chains'; 3 | import { Readable } from 'stream'; 4 | 5 | async function initializeWeb3Client() { 6 | // Get the default chain list 7 | let chainList = getDefaultChainList(); 8 | 9 | // Initialize the web3 client with the chain list 10 | let web3Client = new Client(chainList); 11 | 12 | return web3Client; 13 | } 14 | 15 | export async function createHandler() { 16 | const web3Client = await initializeWeb3Client(); 17 | 18 | return async function protocolHandler(request, callback) { 19 | const { url } = request; 20 | 21 | try { 22 | const fetchedWeb3Url = await web3Client.fetchUrl(url); 23 | 24 | // Collect the response data 25 | const chunks = []; 26 | const reader = fetchedWeb3Url.output.getReader(); 27 | let readResult; 28 | while (!(readResult = await reader.read()).done) { 29 | chunks.push(readResult.value); 30 | } 31 | const data = Buffer.concat(chunks); 32 | 33 | // Send response back to the browser 34 | callback({ 35 | statusCode: fetchedWeb3Url.httpCode, 36 | headers: fetchedWeb3Url.httpHeaders, 37 | data: Readable.from(data) 38 | }); 39 | } catch (error) { 40 | console.error('Error fetching with Web3 protocol:', error); 41 | 42 | const errorResponse = `Error fetching with Web3 protocol: ${error.message}\n` + 43 | `RPC URLs: ${error.rpcUrls?.join(', ')}\n` + 44 | `RPC URLs Errors: ${error.rpcUrlsErrors?.join(', ')}`; 45 | 46 | callback({ 47 | statusCode: 500, 48 | headers: { 'Content-Type': 'text/plain' }, 49 | data: Readable.from(errorResponse) 50 | }); 51 | } 52 | }; 53 | } 54 | -------------------------------------------------------------------------------- /src/renderer.js: -------------------------------------------------------------------------------- 1 | import { 2 | IPFS_PREFIX, 3 | IPNS_PREFIX, 4 | HYPER_PREFIX, 5 | WEB3_PREFIX, 6 | handleURL, 7 | } from "./utils.js"; 8 | const { ipcRenderer } = require("electron"); 9 | 10 | const DEFAULT_PAGE = "peersky://home"; 11 | const webviewContainer = document.querySelector("#webview"); 12 | const nav = document.querySelector("#navbox"); 13 | const findMenu = document.querySelector("#find"); 14 | const pageTitle = document.querySelector("title"); 15 | 16 | const searchParams = new URL(window.location.href).searchParams; 17 | const toNavigate = searchParams.has("url") 18 | ? searchParams.get("url") 19 | : DEFAULT_PAGE; 20 | 21 | document.addEventListener("DOMContentLoaded", () => { 22 | if (webviewContainer && nav) { 23 | webviewContainer.loadURL(toNavigate); 24 | 25 | focusURLInput(); 26 | 27 | // Navigation Button Event Listeners 28 | nav.addEventListener("back", () => webviewContainer.goBack()); 29 | nav.addEventListener("forward", () => webviewContainer.goForward()); 30 | nav.addEventListener("reload", () => webviewContainer.reload()); 31 | nav.addEventListener("stop", () => webviewContainer.stop()); 32 | nav.addEventListener("home", () => { 33 | webviewContainer.loadURL("peersky://home"); 34 | nav.querySelector("#url").value = "peersky://home"; 35 | }); 36 | nav.addEventListener("navigate", ({ detail }) => { 37 | const { url } = detail; 38 | navigateTo(url); 39 | }); 40 | nav.addEventListener("new-window", () => { 41 | ipcRenderer.send("new-window"); 42 | }); 43 | 44 | // Handle webview loading events to toggle refresh/stop button 45 | if (webviewContainer.webviewElement) { 46 | webviewContainer.webviewElement.addEventListener( 47 | "did-start-loading", 48 | () => { 49 | nav.setLoading(true); 50 | } 51 | ); 52 | 53 | webviewContainer.webviewElement.addEventListener( 54 | "did-stop-loading", 55 | () => { 56 | nav.setLoading(false); 57 | updateNavigationButtons(); 58 | } 59 | ); 60 | 61 | webviewContainer.webviewElement.addEventListener("did-fail-load", () => { 62 | nav.setLoading(false); 63 | updateNavigationButtons(); 64 | }); 65 | 66 | webviewContainer.webviewElement.addEventListener("did-navigate", () => { 67 | updateNavigationButtons(); 68 | }); 69 | } else { 70 | console.error("webviewElement not found in webviewContainer"); 71 | } 72 | 73 | const urlInput = nav.querySelector("#url"); 74 | if (urlInput) { 75 | urlInput.addEventListener("keypress", async (e) => { 76 | if (e.key === "Enter") { 77 | const rawURL = urlInput.value.trim(); 78 | const url = handleURL(rawURL); 79 | try { 80 | webviewContainer.loadURL(url); 81 | } catch (error) { 82 | console.error("Error loading URL:", error); 83 | } 84 | } 85 | }); 86 | } else { 87 | console.error("URL input not found within nav-box."); 88 | } 89 | 90 | // Update URL input and send navigation event 91 | webviewContainer.addEventListener("did-navigate", (e) => { 92 | if (urlInput) { 93 | urlInput.value = e.detail.url; 94 | } 95 | ipcRenderer.send("webview-did-navigate", e.detail.url); 96 | }); 97 | 98 | // Update page title 99 | webviewContainer.addEventListener("page-title-updated", (e) => { 100 | pageTitle.innerText = e.detail.title ? `${e.detail.title} - Peersky Browser` : "Peersky Browser"; 101 | }); 102 | 103 | // Find Menu Event Listeners 104 | findMenu.addEventListener("next", ({ detail }) => { 105 | webviewContainer.executeJavaScript( 106 | `window.find("${detail.value}", ${detail.findNext})` 107 | ); 108 | }); 109 | 110 | findMenu.addEventListener("previous", ({ detail }) => { 111 | webviewContainer.executeJavaScript( 112 | `window.find("${detail.value}", ${detail.findNext}, true)` 113 | ); 114 | }); 115 | 116 | findMenu.addEventListener("hide", () => { 117 | webviewContainer.focus(); 118 | }); 119 | 120 | // Initial update of navigation buttons 121 | updateNavigationButtons(); 122 | } else { 123 | console.error("webviewContainer or nav not found"); 124 | } 125 | }); 126 | 127 | function updateNavigationButtons() { 128 | if (webviewContainer && nav && webviewContainer.webviewElement) { 129 | const canGoBack = webviewContainer.webviewElement.canGoBack(); 130 | const canGoForward = webviewContainer.webviewElement.canGoForward(); 131 | nav.setNavigationButtons(canGoBack, canGoForward); 132 | } 133 | } 134 | 135 | function navigateTo(url) { 136 | webviewContainer.loadURL(url); 137 | } 138 | 139 | function focusURLInput() { 140 | const urlInput = nav.querySelector("#url"); 141 | if (urlInput) { 142 | urlInput.focus(); 143 | } 144 | } 145 | 146 | document.addEventListener("keydown", (e) => { 147 | if (e.key === "f" && (e.ctrlKey || e.metaKey)) { 148 | e.preventDefault(); 149 | findMenu.toggle(); 150 | } 151 | }); 152 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | // P2P prefixes 2 | const IPFS_PREFIX = 'ipfs://'; 3 | const IPNS_PREFIX = 'ipns://'; 4 | const HYPER_PREFIX = 'hyper://'; 5 | const WEB3_PREFIX = 'web3://'; 6 | 7 | // Utility functions 8 | function isURL(string) { 9 | try { 10 | new URL(string); 11 | return true; 12 | } catch { 13 | return false; 14 | } 15 | } 16 | 17 | function looksLikeDomain(string) { 18 | return !string.match(/\s/) && string.includes('.'); 19 | } 20 | 21 | function isBareLocalhost(string) { 22 | return string.match(/^localhost(:[0-9]+)?\/?$/); 23 | } 24 | 25 | function makeHttp(query) { 26 | return `http://${query}`; 27 | } 28 | 29 | function makeHttps(query) { 30 | return `https://${query}`; 31 | } 32 | 33 | function makeDuckDuckGo(query) { 34 | return `https://duckduckgo.com/?q=${encodeURIComponent(query)}`; 35 | } 36 | 37 | function handleURL(rawURL) { 38 | if (rawURL.endsWith('.eth')) { 39 | if (rawURL.startsWith(IPFS_PREFIX) || rawURL.startsWith(IPNS_PREFIX)) { 40 | return rawURL; 41 | } 42 | // ENS names are mutable and should be resolved via IPNS. 43 | return `${IPNS_PREFIX}${rawURL}`; 44 | } else if ( 45 | rawURL.startsWith(IPFS_PREFIX) || 46 | rawURL.startsWith(IPNS_PREFIX) || 47 | rawURL.startsWith(HYPER_PREFIX) || 48 | rawURL.startsWith(WEB3_PREFIX) 49 | ) { 50 | return rawURL; 51 | } else if (isURL(rawURL)) { 52 | return rawURL; 53 | } else if (isBareLocalhost(rawURL)) { 54 | return makeHttp(rawURL); 55 | } else if (looksLikeDomain(rawURL)) { 56 | return makeHttps(rawURL); 57 | } else { 58 | return makeDuckDuckGo(rawURL); 59 | } 60 | } 61 | 62 | export { 63 | IPFS_PREFIX, 64 | IPNS_PREFIX, 65 | HYPER_PREFIX, 66 | WEB3_PREFIX, 67 | handleURL, 68 | makeHttp, 69 | makeHttps, 70 | makeDuckDuckGo, 71 | }; 72 | -------------------------------------------------------------------------------- /src/window-manager.js: -------------------------------------------------------------------------------- 1 | import { app, BrowserWindow, ipcMain } from "electron"; 2 | import path from "path"; 3 | import fs from "fs-extra"; 4 | import ScopedFS from 'scoped-fs'; 5 | import { fileURLToPath } from "url"; 6 | import { attachContextMenus } from "./context-menu.js"; 7 | 8 | const __dirname = fileURLToPath(new URL(".", import.meta.url)); 9 | const PERSIST_FILE = path.join(app.getPath("userData"), "lastOpened.json"); 10 | const DEFAULT_SAVE_INTERVAL = 30 * 1000; 11 | const cssPath = path.join(__dirname, "pages", "theme"); 12 | const cssFS = new ScopedFS(cssPath); 13 | 14 | ipcMain.handle("peersky-read-css", async (event, name) => { 15 | try { 16 | const safeName = path.basename(name).replace(/\.css$/, '') + '.css'; 17 | const data = await new Promise((resolve, reject) => { 18 | cssFS.readFile(safeName, (err, buffer) => { 19 | if (err) { 20 | reject(err); 21 | } else { 22 | resolve(buffer.toString('utf8')); 23 | } 24 | }); 25 | }); 26 | return data; 27 | } catch (err) { 28 | console.error(`Failed to read CSS ${name}:`, err); 29 | return ""; 30 | } 31 | }); 32 | 33 | class WindowManager { 34 | constructor() { 35 | this.windows = new Set(); 36 | this.saverTimer = null; 37 | this.saverInterval = DEFAULT_SAVE_INTERVAL; 38 | this.isSaving = false; // Flag to prevent concurrent saves 39 | this.isQuitting = false; // Flag to indicate app is quitting 40 | this.registerListeners(); 41 | } 42 | 43 | registerListeners() { 44 | ipcMain.on("new-window", () => { 45 | this.open(); 46 | }); 47 | } 48 | 49 | setQuitting(flag) { 50 | this.isQuitting = flag; 51 | } 52 | 53 | open(options = {}) { 54 | const window = new PeerskyWindow(options, this); 55 | this.windows.add(window); 56 | 57 | window.window.on("closed", () => { 58 | // Remove the window from the set 59 | this.windows.delete(window); 60 | // Remove IPC listener 61 | ipcMain.removeListener( 62 | `webview-did-navigate-${window.id}`, 63 | window.navigateListener 64 | ); 65 | // Save the state after the window is closed, only if not quitting 66 | if (!this.isQuitting) { 67 | this.saveOpened(); 68 | } 69 | }); 70 | 71 | // Save state when the window is moved, resized, or navigated, only if not quitting 72 | window.window.on("move", () => { 73 | if (!this.isQuitting) this.saveOpened(); 74 | }); 75 | window.window.on("resize", () => { 76 | if (!this.isQuitting) this.saveOpened(); 77 | }); 78 | window.webContents.on("did-navigate", () => { 79 | if (!this.isQuitting) this.saveOpened(); 80 | }); 81 | 82 | return window; 83 | } 84 | 85 | get all() { 86 | return [...this.windows.values()]; 87 | } 88 | 89 | async saveOpened() { 90 | if (this.isSaving) { 91 | console.warn("saveOpened is already in progress."); 92 | return; 93 | } 94 | this.isSaving = true; 95 | console.log("Saving window states..."); 96 | 97 | const windowStates = []; 98 | for (const window of this.all) { 99 | if ( 100 | window.window.isDestroyed() || 101 | window.window.webContents.isDestroyed() 102 | ) { 103 | console.log(`Skipping destroyed window: ${window.id}`); 104 | continue; 105 | } 106 | try { 107 | const url = await window.getURL(); 108 | const position = window.window.getPosition(); 109 | const size = window.window.getSize(); 110 | windowStates.push({ url, position, size }); 111 | console.log( 112 | `Saved window ${window.id}: URL=${url}, Position=${position}, Size=${size}` 113 | ); 114 | } catch (error) { 115 | console.error( 116 | `Error saving window state for window ${window.id}:`, 117 | error 118 | ); 119 | } 120 | } 121 | 122 | try { 123 | const tempPath = PERSIST_FILE + ".tmp"; 124 | fs.outputJsonSync(tempPath, windowStates); 125 | fs.moveSync(tempPath, PERSIST_FILE, { overwrite: true }); 126 | console.log(`Window states saved to ${PERSIST_FILE}`); 127 | } catch (error) { 128 | console.error("Error writing window states to file:", error); 129 | } 130 | 131 | this.isSaving = false; 132 | } 133 | 134 | async loadSaved() { 135 | try { 136 | const exists = await fs.pathExists(PERSIST_FILE); 137 | if (!exists) { 138 | console.log("Persist file does not exist."); 139 | return []; 140 | } 141 | 142 | const data = await fs.readFile(PERSIST_FILE, "utf8"); 143 | if (!data.trim()) { 144 | // Check for empty or whitespace-only content 145 | console.log("Persist file is empty."); 146 | return []; 147 | } 148 | 149 | // Attempt to parse JSON, handle unexpected characters gracefully 150 | let windowStates; 151 | try { 152 | windowStates = JSON.parse(data); 153 | } catch (parseError) { 154 | console.error("Error parsing JSON from lastOpened.json:", parseError); 155 | // Backup the corrupted file and reset the state 156 | const backupPath = PERSIST_FILE + ".backup"; 157 | fs.moveSync(PERSIST_FILE, backupPath, { overwrite: true }); 158 | console.warn( 159 | `Corrupted lastOpened.json backed up to ${backupPath}. Starting fresh.` 160 | ); 161 | windowStates = []; 162 | } 163 | 164 | // Validate that windowStates is an array 165 | if (!Array.isArray(windowStates)) { 166 | console.error("Invalid format for window states. Expected an array."); 167 | return []; 168 | } 169 | 170 | console.log( 171 | `Loaded ${windowStates.length} window state(s) from persist file.` 172 | ); 173 | return windowStates; 174 | } catch (e) { 175 | console.error("Error loading saved windows", e); 176 | return []; 177 | } 178 | } 179 | 180 | async openSavedWindows() { 181 | const windowStates = await this.loadSaved(); 182 | 183 | if (windowStates.length === 0) { 184 | console.log("No windows to restore."); 185 | return; 186 | } 187 | 188 | for (const [index, state] of windowStates.entries()) { 189 | console.log(`Opening saved window ${index + 1}:`, state); 190 | const options = {}; 191 | if (state.position && Array.isArray(state.position)) { 192 | const [x, y] = state.position; 193 | options.x = x; 194 | options.y = y; 195 | } 196 | if (state.size && Array.isArray(state.size)) { 197 | const [width, height] = state.size; 198 | options.width = width; 199 | options.height = height; 200 | } 201 | if (state.url) { 202 | options.url = state.url; 203 | } else { 204 | options.url = "peersky://home"; 205 | } 206 | this.open(options); 207 | } 208 | 209 | console.log(`${windowStates.length} window(s) restored.`); 210 | } 211 | 212 | startSaver() { 213 | this.saverTimer = setInterval(() => { 214 | this.saveOpened(); 215 | }, this.saverInterval); 216 | console.log( 217 | `Window state saver started with interval ${this.saverInterval}ms.` 218 | ); 219 | } 220 | 221 | stopSaver() { 222 | if (this.saverTimer) { 223 | clearInterval(this.saverTimer); 224 | this.saverTimer = null; 225 | console.log("Window state saver stopped."); 226 | } 227 | } 228 | } 229 | 230 | class PeerskyWindow { 231 | constructor(options = {}, windowManager) { 232 | const { url, isMainWindow = false, ...windowOptions } = options; 233 | this.window = new BrowserWindow({ 234 | width: 800, 235 | height: 600, 236 | webPreferences: { 237 | nodeIntegration: true, 238 | contextIsolation: false, 239 | nativeWindowOpen: true, 240 | webviewTag: true, 241 | }, 242 | ...windowOptions, 243 | }); 244 | 245 | this.id = this.window.webContents.id; 246 | 247 | const loadURL = path.join(__dirname, "./pages/index.html"); 248 | const query = { query: { url: url || "peersky://home" } }; 249 | this.window.loadFile(loadURL, query); 250 | 251 | // Attach context menus 252 | attachContextMenus(this.window, windowManager); 253 | 254 | // Reference to windowManager for saving state 255 | this.windowManager = windowManager; 256 | 257 | // Define the listener function 258 | this.navigateListener = (event, url) => { 259 | this.currentURL = url; 260 | console.log(`Navigation detected in window ${this.id}: ${url}`); 261 | windowManager.saveOpened(); 262 | }; 263 | 264 | // Listen for navigation events from renderer 265 | ipcMain.on(`webview-did-navigate-${this.id}`, this.navigateListener); 266 | 267 | // Inject JavaScript into renderer to set up IPC communication 268 | this.window.webContents.on("did-finish-load", () => { 269 | // Check if the window is still alive 270 | if ( 271 | !this.window.isDestroyed() && 272 | !this.window.webContents.isDestroyed() 273 | ) { 274 | this.window.webContents 275 | .executeJavaScript( 276 | ` 277 | const { ipcRenderer } = require('electron'); 278 | const webview = document.querySelector('tracked-box').webviewElement; 279 | if (webview) { 280 | webview.addEventListener('did-navigate', (e) => { 281 | ipcRenderer.send('webview-did-navigate-${this.id}', webview.src); 282 | }); 283 | } 284 | // Send window ID to renderer for correct IPC event naming 285 | ipcRenderer.send('set-window-id', ${this.id}); 286 | ` 287 | ) 288 | .catch((error) => { 289 | console.error("Error injecting script into webContents:", error); 290 | }); 291 | } 292 | }); 293 | 294 | this.window.on("closed", () => { 295 | // Clean up IPC listener 296 | ipcMain.removeListener( 297 | `webview-did-navigate-${this.id}`, 298 | this.navigateListener 299 | ); 300 | }); 301 | } 302 | 303 | get webContents() { 304 | return this.window.webContents; 305 | } 306 | 307 | async getURL() { 308 | if (this.window.isDestroyed() || this.window.webContents.isDestroyed()) { 309 | return "peersky://home"; 310 | } 311 | try { 312 | const url = await this.window.webContents.executeJavaScript(` 313 | (function() { 314 | const webview = document.querySelector('tracked-box').webviewElement; 315 | return webview ? webview.src : 'peersky://home'; 316 | })() 317 | `); 318 | return url; 319 | } catch (error) { 320 | console.error("Error getting URL:", error); 321 | return "peersky://home"; 322 | } 323 | } 324 | } 325 | 326 | export default WindowManager; 327 | --------------------------------------------------------------------------------