├── banner.gif ├── public ├── icon.png └── icon.icns ├── docs ├── images │ ├── peersky-llm-chat.png │ ├── peersky-settings.png │ ├── peersky-llm-editor.png │ ├── peersky-tabs-themes.gif │ ├── peersky-home-mountains.png │ ├── peersky-llm-settings.png │ └── browser-theme-protocol-example.png ├── README.md ├── P2P.md ├── Theme.md └── LLM.md ├── src ├── pages │ ├── static │ │ ├── assets │ │ │ ├── logo.png │ │ │ ├── favicon.ico │ │ │ ├── error-file.png │ │ │ ├── mountains.jpg │ │ │ ├── redwoods.jpg │ │ │ └── svg │ │ │ │ ├── bookmark-fill.svg │ │ │ │ ├── up.svg │ │ │ │ ├── down.svg │ │ │ │ ├── toggle-off.svg │ │ │ │ ├── left.svg │ │ │ │ ├── plus.svg │ │ │ │ ├── close.svg │ │ │ │ ├── build.svg │ │ │ │ ├── search.svg │ │ │ │ ├── bookmark-settings.svg │ │ │ │ ├── diamond-fill.svg │ │ │ │ ├── right.svg │ │ │ │ ├── reload-spinner.svg │ │ │ │ ├── bookmark.svg │ │ │ │ ├── arrow-bar-right.svg │ │ │ │ ├── copy.svg │ │ │ │ ├── reload.svg │ │ │ │ ├── upload.svg │ │ │ │ ├── home.svg │ │ │ │ ├── chat.svg │ │ │ │ ├── eye.svg │ │ │ │ ├── folder.svg │ │ │ │ ├── tab-right.svg │ │ │ │ ├── volume-mute.svg │ │ │ │ ├── folder-minus.svg │ │ │ │ ├── pencil-square.svg │ │ │ │ ├── people.svg │ │ │ │ ├── qr-code.svg │ │ │ │ ├── volume-up.svg │ │ │ │ ├── palette.svg │ │ │ │ ├── eye-slash.svg │ │ │ │ ├── speedometer2.svg │ │ │ │ ├── pin-angle.svg │ │ │ │ ├── robot.svg │ │ │ │ ├── globe.svg │ │ │ │ ├── wikipedia.svg │ │ │ │ ├── settings.svg │ │ │ │ └── puzzle.svg │ │ ├── elves │ │ │ ├── hello-world.js │ │ │ ├── goodbye-world.js │ │ │ └── qr-code.js │ │ └── js │ │ │ ├── qr-popup.js │ │ │ ├── home.js │ │ │ ├── vendor │ │ │ └── qr-creator │ │ │ │ └── LICENSE │ │ │ ├── error.js │ │ │ ├── bookmarks.js │ │ │ └── tabs.js │ ├── p2p │ │ ├── p2p-list.js │ │ ├── wiki │ │ │ ├── static │ │ │ │ ├── assets │ │ │ │ │ ├── favicon.ico │ │ │ │ │ └── wikipedia-on-ipfs.png │ │ │ │ └── styles.css │ │ │ ├── index.html │ │ │ └── script.js │ │ ├── chat │ │ │ ├── send.svg │ │ │ ├── index.html │ │ │ └── styles.css │ │ ├── editor │ │ │ ├── common.js │ │ │ ├── codeEditor.js │ │ │ ├── index.html │ │ │ └── styles.css │ │ ├── index.html │ │ ├── ai-chat │ │ │ └── index.html │ │ └── upload │ │ │ └── index.html │ ├── theme │ │ ├── plan1.css │ │ ├── base.css │ │ ├── home.css │ │ ├── bookmarks.css │ │ ├── error.css │ │ ├── qr.css │ │ ├── vars.css │ │ ├── tabs.css │ │ └── style.css │ ├── bookmarks.html │ ├── index.html │ ├── version.html │ ├── tabs.html │ ├── home.html │ ├── about.html │ ├── error.html │ ├── peer-bar.js │ ├── titlebar.js │ ├── clock.js │ ├── plan1.html │ └── vertical-tabs.js ├── protocols │ ├── helia │ │ ├── directoryListingTemplate.js │ │ └── helia.js │ ├── web3-handler.js │ ├── peersky-protocol.js │ ├── theme-handler.js │ └── config.js ├── search-engine.js ├── auto-updater.js ├── utils.js └── main.js ├── .github ├── ISSUE_TEMPLATE │ ├── FEATURE_REQUEST.md │ └── BUG_REPORT.md ├── CONTRIBUTING.md ├── workflows │ └── build.yml ├── PULL_REQUEST_TEMPLATE.md └── CODE_OF_CONDUCT.md ├── LICENSE ├── .gitignore ├── package.json └── README.md /banner.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/p2plabsxyz/peersky-browser/HEAD/banner.gif -------------------------------------------------------------------------------- /public/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/p2plabsxyz/peersky-browser/HEAD/public/icon.png -------------------------------------------------------------------------------- /public/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/p2plabsxyz/peersky-browser/HEAD/public/icon.icns -------------------------------------------------------------------------------- /docs/images/peersky-llm-chat.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/p2plabsxyz/peersky-browser/HEAD/docs/images/peersky-llm-chat.png -------------------------------------------------------------------------------- /docs/images/peersky-settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/p2plabsxyz/peersky-browser/HEAD/docs/images/peersky-settings.png -------------------------------------------------------------------------------- /src/pages/static/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/p2plabsxyz/peersky-browser/HEAD/src/pages/static/assets/logo.png -------------------------------------------------------------------------------- /docs/images/peersky-llm-editor.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/p2plabsxyz/peersky-browser/HEAD/docs/images/peersky-llm-editor.png -------------------------------------------------------------------------------- /docs/images/peersky-tabs-themes.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/p2plabsxyz/peersky-browser/HEAD/docs/images/peersky-tabs-themes.gif -------------------------------------------------------------------------------- /src/pages/p2p/p2p-list.js: -------------------------------------------------------------------------------- 1 | export default [ 2 | "ai-chat", 3 | "chat", 4 | "editor", 5 | "upload", 6 | "wiki" 7 | ]; 8 | -------------------------------------------------------------------------------- /src/pages/static/assets/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/p2plabsxyz/peersky-browser/HEAD/src/pages/static/assets/favicon.ico -------------------------------------------------------------------------------- /docs/images/peersky-home-mountains.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/p2plabsxyz/peersky-browser/HEAD/docs/images/peersky-home-mountains.png -------------------------------------------------------------------------------- /docs/images/peersky-llm-settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/p2plabsxyz/peersky-browser/HEAD/docs/images/peersky-llm-settings.png -------------------------------------------------------------------------------- /src/pages/static/assets/error-file.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/p2plabsxyz/peersky-browser/HEAD/src/pages/static/assets/error-file.png -------------------------------------------------------------------------------- /src/pages/static/assets/mountains.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/p2plabsxyz/peersky-browser/HEAD/src/pages/static/assets/mountains.jpg -------------------------------------------------------------------------------- /src/pages/static/assets/redwoods.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/p2plabsxyz/peersky-browser/HEAD/src/pages/static/assets/redwoods.jpg -------------------------------------------------------------------------------- /src/pages/p2p/wiki/static/assets/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/p2plabsxyz/peersky-browser/HEAD/src/pages/p2p/wiki/static/assets/favicon.ico -------------------------------------------------------------------------------- /docs/images/browser-theme-protocol-example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/p2plabsxyz/peersky-browser/HEAD/docs/images/browser-theme-protocol-example.png -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # Peersky Browser Documentation 2 | 3 | 4 | - [Settings](Settings.md) 5 | - [LLMs](LLM.md) 6 | - [Theme Protocol](Theme.md) 7 | - [P2P Apps](P2P.md) -------------------------------------------------------------------------------- /src/pages/p2p/wiki/static/assets/wikipedia-on-ipfs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/p2plabsxyz/peersky-browser/HEAD/src/pages/p2p/wiki/static/assets/wikipedia-on-ipfs.png -------------------------------------------------------------------------------- /src/pages/static/elves/hello-world.js: -------------------------------------------------------------------------------- 1 | import $elf from 'peersky://static/elves/elf.js' 2 | 3 | const $ = $elf('hello-world') 4 | 5 | $.draw((_target) => `Hello World`) 6 | 7 | $elf($) 8 | -------------------------------------------------------------------------------- /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/static/assets/svg/bookmark-fill.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/pages/static/assets/svg/up.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/pages/static/assets/svg/down.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/pages/p2p/chat/send.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/pages/static/assets/svg/toggle-off.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/pages/static/assets/svg/left.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/pages/static/assets/svg/plus.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/pages/static/assets/svg/close.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /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/static/assets/svg/build.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/pages/static/assets/svg/search.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/pages/static/assets/svg/bookmark-settings.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/pages/static/assets/svg/diamond-fill.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/pages/static/assets/svg/right.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/pages/static/assets/svg/reload-spinner.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/pages/static/assets/svg/bookmark.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/pages/static/assets/svg/arrow-bar-right.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/pages/static/assets/svg/copy.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/pages/static/assets/svg/reload.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/pages/static/assets/svg/upload.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/pages/static/assets/svg/home.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/pages/static/assets/svg/chat.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /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/static/assets/svg/eye.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/pages/static/assets/svg/folder.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/pages/static/assets/svg/tab-right.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/pages/static/assets/svg/volume-mute.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/pages/static/assets/svg/folder-minus.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/pages/static/assets/svg/pencil-square.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /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 | 12 | 13 | 14 | `; 15 | -------------------------------------------------------------------------------- /src/pages/static/assets/svg/people.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/pages/static/assets/svg/qr-code.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /src/pages/static/assets/svg/volume-up.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/pages/bookmarks.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Bookmarks 9 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /src/pages/static/assets/svg/palette.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /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 | 33 | $elf($) 34 | -------------------------------------------------------------------------------- /src/pages/static/assets/svg/eye-slash.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /docs/P2P.md: -------------------------------------------------------------------------------- 1 | # P2P Apps 2 | 3 | Peersky includes a section of static apps served from the `peersky://p2p/` namespace. These apps are fully local or served via distributed protocols like IPFS, Hypercore, etc. This allows building collaborative and offline-capable tools that do not rely on centralized servers. 4 | 5 | ## 📄 p2p-list.js 6 | 7 | The file [`p2p-list.js`](../src/pages/p2p/p2p-list.js) exports a list of registered P2P app names. 8 | 9 | To register a new P2P app: 10 | 11 | 1. Add the folder for your app inside `./src/pages/p2p/` 12 | 2. Update `p2p-list.js` with the new app name (e.g. `"chat"` or `"upload"`) 13 | 3. Make sure your app is accessible at `peersky://p2p/your-app-name/` 14 | 15 | This list is used by the main P2P apps page to display and link to available apps. 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/pages/static/assets/svg/speedometer2.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/pages/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | Peersky Browser 10 | 11 |
12 | 13 |
14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /.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/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 | -------------------------------------------------------------------------------- /src/pages/version.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | About Version 7 | 14 |

System Information

15 | 23 | -------------------------------------------------------------------------------- /src/pages/tabs.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Tabs 9 | 17 | 18 | 19 | 20 | 21 | 27 | 28 | -------------------------------------------------------------------------------- /src/pages/static/js/qr-popup.js: -------------------------------------------------------------------------------- 1 | import '../elves/qr-code.js' 2 | 3 | class QRPopup extends HTMLElement { 4 | connectedCallback() { 5 | this.render(); 6 | this.querySelector(".close-btn").onclick = () => this.dispatchEvent(new Event("close")); 7 | this.querySelector(".download-btn").onclick = () => this.dispatchEvent(new Event("download")); 8 | } 9 | 10 | render() { 11 | const url = this.getAttribute("url") || ""; 12 | this.innerHTML = ` 13 |
14 |
15 |

Scan QR Code

16 | 17 |
18 | 19 | ${url} 20 | 21 |
22 | `; 23 | } 24 | 25 | } 26 | 27 | customElements.define("qr-popup", QRPopup); 28 | -------------------------------------------------------------------------------- /src/pages/static/assets/svg/pin-angle.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/pages/static/assets/svg/robot.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/pages/home.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 10 | Home 11 | 12 | 13 | 14 | 15 | 19 | Home 20 | 21 | 22 |
23 | 24 | 25 |
26 | 27 | 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /src/pages/about.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | About 7 | 24 | 25 | 33 | 34 | -------------------------------------------------------------------------------- /src/pages/static/js/home.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Home Page Wallpaper Management 3 | * Handles dynamic wallpaper loading and updates 4 | */ 5 | 6 | // Apply wallpaper from settings 7 | async function applyWallpaper() { 8 | try { 9 | const wallpaperUrl = await window.electronAPI?.getWallpaperUrl?.() || 'peersky://static/assets/redwoods.jpg'; 10 | document.body.style.backgroundImage = `url("${wallpaperUrl}")`; 11 | console.log('Wallpaper applied:', wallpaperUrl); 12 | } catch (error) { 13 | console.error('Failed to apply wallpaper:', error); 14 | document.body.style.backgroundImage = 'url("peersky://static/assets/redwoods.jpg")'; 15 | } 16 | } 17 | 18 | // Initialize wallpaper system 19 | function initializeWallpaper() { 20 | // Listen for wallpaper changes 21 | window.electronAPI?.onWallpaperChanged?.(() => { 22 | console.log('Wallpaper changed, reapplying...'); 23 | applyWallpaper(); 24 | }); 25 | 26 | // Apply wallpaper when page loads 27 | document.addEventListener('DOMContentLoaded', applyWallpaper); 28 | window.addEventListener('load', applyWallpaper); 29 | } 30 | 31 | // Start wallpaper system 32 | initializeWallpaper(); -------------------------------------------------------------------------------- /src/pages/error.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 11 | Error - Peersky 12 | 13 | 14 | 15 |
16 | 17 |

Loading Error Details...

18 | 19 |

20 | Attempting to display error message from parameters. 21 |

22 | 23 |

24 |

25 | 26 | 30 |
31 | 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /src/search-engine.js: -------------------------------------------------------------------------------- 1 | export const BUILTIN_SEARCH_ENGINES = { 2 | duckduckgo: "https://duckduckgo.com/?q=%s", 3 | brave: "https://search.brave.com/search?q=%s", 4 | ecosia: "https://www.ecosia.org/search?q=%s", 5 | kagi: "https://kagi.com/search?q=%s", 6 | startpage: "https://www.startpage.com/sp/search?q=%s", 7 | }; 8 | 9 | export function isBuiltInSearchEngine(tpl) { 10 | if (typeof tpl !== "string") return false; 11 | 12 | // Try parsing the URL safely, adding https:// if m520issing 13 | let parsed; 14 | try { 15 | parsed = new URL(tpl); 16 | } catch { 17 | try { 18 | parsed = new URL("https://" + tpl); 19 | } catch { 20 | return false; // Not a valid URL at all 21 | } 22 | } 23 | 24 | const host = parsed.hostname.replace(/^www\./i, "").toLowerCase(); 25 | 26 | // Normalize for comparison (so "www.duckduckgo.com" → "duckduckgo.com") 27 | return Object.values(BUILTIN_SEARCH_ENGINES).some((engineUrl) => { 28 | try { 29 | const engineHost = new URL(engineUrl).hostname.replace(/^www\./i, "").toLowerCase(); 30 | return host === engineHost; 31 | } catch { 32 | return false; 33 | } 34 | }); 35 | } -------------------------------------------------------------------------------- /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/static/assets/svg/globe.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /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/ai-chat/', img: 'robot.svg', alt: 'Peersky LLM Chat' }, 14 | { href: 'peersky://p2p/editor/', img: 'build.svg', alt: 'Peersky Build' }, 15 | { href: 'peersky://p2p/upload/', img: 'upload.svg', alt: 'Peersky Upload' }, 16 | { href: 'peersky://p2p/wiki/', img: 'wikipedia.svg', alt: 'Peersky Wiki' }, 17 | { href: 'https://reader.distributed.press/', img: 'people.svg', alt: 'Social Reader' } 18 | ]; 19 | 20 | links.forEach(link => { 21 | const a = document.createElement('a'); 22 | a.href = link.href; 23 | const img = document.createElement('img'); 24 | img.src = `peersky://static/assets/svg/${link.img}`; 25 | img.alt = link.alt; 26 | a.appendChild(img); 27 | container.appendChild(a); 28 | }); 29 | 30 | this.appendChild(container); 31 | } 32 | } 33 | 34 | window.customElements.define('peer-bar', PeerBar); 35 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /src/pages/static/assets/svg/wikipedia.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/pages/static/assets/svg/settings.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/pages/static/js/error.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Error Page Handler 3 | * Dynamically renders network error details from URL parameters. 4 | */ 5 | (function () { 6 | const params = new URLSearchParams(location.search); 7 | 8 | const errorCode = params.get("code"); 9 | const errorName = params.get("name"); 10 | const errorMsg = params.get("msg"); 11 | const errorUrl = params.get("url"); 12 | 13 | // Set document title 14 | document.title = `${errorName || "Error"} - Peersky`; 15 | 16 | const titleEl = document.getElementById("errorTitle"); 17 | const codeEl = document.getElementById("errorCode"); 18 | const msgEl = document.getElementById("errorMessage"); 19 | const urlEl = document.getElementById("errorUrl"); 20 | const retryBtn = document.getElementById("retryButton"); 21 | const homeBtn = document.getElementById("homeButton"); 22 | 23 | // Update DOM safely 24 | if (titleEl) titleEl.textContent = errorName || "Connection Error"; 25 | if (msgEl) msgEl.textContent = errorMsg || "Unable to connect to the server."; 26 | if (codeEl && errorCode) codeEl.textContent = `Error Code: ${errorCode}`; 27 | if (urlEl && errorUrl) { 28 | try { 29 | urlEl.textContent = decodeURIComponent(errorUrl); 30 | } catch { 31 | urlEl.textContent = errorUrl; 32 | } 33 | } 34 | 35 | // Button actions 36 | retryBtn?.addEventListener("click", () => window.location.reload()); 37 | homeBtn?.addEventListener("click", () => (window.location.href = "peersky://home")); 38 | })(); 39 | -------------------------------------------------------------------------------- /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/chat/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Hyper Chat 9 | 10 | 11 |
12 |
13 | 14 |
- or -
15 |
16 | 23 | 24 |
25 |
26 | 27 |
28 |
29 |
RoomKey:
30 |
Peers: 0
31 |
32 |
33 |
34 | 40 | 43 |
44 |
45 |
46 | 47 | 48 | 49 | -------------------------------------------------------------------------------- /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-size: cover; 10 | background-position: center; 11 | background-repeat: no-repeat; 12 | background-attachment: fixed; 13 | font-family: monospace; 14 | position: relative; 15 | overflow-y: hidden; 16 | } 17 | 18 | body::before { 19 | content: ""; 20 | position: absolute; 21 | top: 0; 22 | left: 0; 23 | width: 100%; 24 | height: 100%; 25 | background-color: rgba(0, 0, 0, 0.2); 26 | z-index: 0; 27 | } 28 | 29 | .home-background { 30 | position: relative; 31 | height: 100vh; 32 | width: 100vw; 33 | display: flex; 34 | align-items: center; 35 | justify-content: center; 36 | z-index: 1; 37 | } 38 | 39 | .peerbar { 40 | position: absolute; 41 | top: 20px; 42 | left: 20px; 43 | display: flex; 44 | flex-direction: column; 45 | gap: 8px; 46 | padding: 8px; 47 | z-index: 3; 48 | background-color: rgba(255, 255, 255, 0.1); 49 | backdrop-filter: blur(10px) saturate(180%); 50 | border-radius: 12px; 51 | box-shadow: 0 4px 10px rgba(0, 0, 0, 0.2); 52 | border: 1px solid rgba(255, 255, 255, 0.2); 53 | } 54 | 55 | .peerbar a { 56 | display: block; 57 | } 58 | 59 | .peerbar img { 60 | width: 25px; 61 | height: 25px; 62 | filter: invert(100%) sepia(0%) saturate(0%) hue-rotate(0deg) brightness(100%) 63 | contrast(100%); 64 | padding: 5px; 65 | transition: transform 0.3s ease, filter 0.3s ease; 66 | } 67 | 68 | .peerbar img:hover { 69 | transform: scale(1.1); 70 | } 71 | -------------------------------------------------------------------------------- /.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') }} -------------------------------------------------------------------------------- /src/pages/p2p/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | P2P Apps 7 | 28 | 29 |
30 | Loading P2P apps… 31 |
32 | 33 | 55 | 56 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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/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/pages/theme/bookmarks.css: -------------------------------------------------------------------------------- 1 | @import url('browser://theme/vars.css'); 2 | @import url('browser://theme/themes.css'); 3 | 4 | :host { 5 | --bg-color: var(--browser-theme-background, #18181b); 6 | --item-hover-bg: #27272a; 7 | --text-color: var(--browser-theme-text-color, #ffffff); 8 | --border-color: #2e2e30; 9 | --icon-size: 20px; 10 | --btn-color: #9ca3af; 11 | --btn-hover-color: #e5e7eb; 12 | } 13 | 14 | .bookmarks-container { 15 | background-color: var(--settings-bg-secondary); 16 | color: var(--text-color); 17 | padding: 1rem 1.5rem; 18 | max-width: 720px; 19 | margin: 2rem auto; 20 | border-radius: 8px; 21 | box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2); 22 | font-family: var(--browser-theme-font-family, sans-serif); 23 | } 24 | 25 | h1 { 26 | font-size: 1.25rem; 27 | margin-bottom: 1rem; 28 | border-bottom: 1px solid var(--settings-border); 29 | padding-bottom: 0.5rem; 30 | } 31 | 32 | .bookmark-item { 33 | display: flex; 34 | align-items: center; 35 | justify-content: space-between; 36 | padding: 0.75rem 0.5rem; 37 | border-bottom: 1px solid var(--settings-border); 38 | transition: background 0.2s; 39 | } 40 | 41 | .bookmark-item:hover { 42 | background-color: var(--settings-bg-primary); 43 | border-radius: 6px; 44 | } 45 | 46 | .bookmark-link { 47 | display: flex; 48 | align-items: center; 49 | text-decoration: none; 50 | color: var(--text-color); 51 | flex-grow: 1; 52 | overflow: hidden; 53 | } 54 | 55 | .favicon { 56 | width: var(--icon-size); 57 | height: var(--icon-size); 58 | margin-right: 0.75rem; 59 | flex-shrink: 0; 60 | border-radius: 3px; 61 | color: wheat; 62 | } 63 | 64 | .title { 65 | white-space: nowrap; 66 | overflow: hidden; 67 | text-overflow: ellipsis; 68 | font-size: 0.95rem; 69 | } 70 | 71 | .delete-btn { 72 | background: transparent; 73 | border: none; 74 | color: var(--btn-color); 75 | cursor: pointer; 76 | font-size: 1.25rem; 77 | padding: 0 0.5rem; 78 | transition: color 0.2s; 79 | } 80 | 81 | .delete-btn:hover { 82 | color: var(--btn-hover-color); 83 | } 84 | -------------------------------------------------------------------------------- /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: none; 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 | $elf($) 76 | -------------------------------------------------------------------------------- /src/pages/theme/error.css: -------------------------------------------------------------------------------- 1 | @import url("browser://theme/vars.css"); 2 | 3 | * { 4 | margin: 0; 5 | padding: 0; 6 | box-sizing: border-box; 7 | } 8 | 9 | body { 10 | font-family: var(--browser-theme-font-family, Arial, sans-serif); 11 | background: #18181C; 12 | color: #fff; 13 | display: flex; 14 | justify-content: center; 15 | align-items: center; 16 | min-height: 100vh; 17 | padding: 20px; 18 | } 19 | 20 | .container { 21 | max-width: 480px; 22 | width: 100%; 23 | text-align: center; 24 | } 25 | 26 | .icon { 27 | font-size: 64px; 28 | margin-bottom: 16px; 29 | user-select: none; 30 | opacity: 0.7; 31 | } 32 | 33 | h1 { 34 | font-size: 24px; 35 | margin-bottom: 8px; 36 | font-weight: 500; 37 | } 38 | 39 | .error-code { 40 | font-size: 13px; 41 | opacity: 0.6; 42 | margin-bottom: 12px; 43 | } 44 | 45 | p { 46 | font-size: 15px; 47 | opacity: 0.8; 48 | margin-bottom: 20px; 49 | line-height: 1.6; 50 | } 51 | 52 | .url-box { 53 | font-size: 13px; 54 | background: rgba(255, 255, 255, 0.05); 55 | border: 1px solid rgba(255, 255, 255, 0.1); 56 | padding: 8px; 57 | border-radius: 8px; 58 | word-break: break-all; 59 | margin-bottom: 24px; 60 | font-family: monospace; 61 | } 62 | 63 | .button-group { 64 | display: flex; 65 | justify-content: center; 66 | gap: 12px; 67 | } 68 | 69 | button { 70 | background: #06b6d4; 71 | color: #000; 72 | border: none; 73 | border-radius: 4px; 74 | padding: 6px 18px; 75 | font-size: 14px; 76 | font-weight: 600; 77 | cursor: pointer; 78 | transition: all 0.2s; 79 | } 80 | 81 | button:hover { 82 | background: #3b82f6; 83 | } 84 | 85 | button:active { 86 | transform: translateY(0); 87 | } 88 | 89 | button.secondary { 90 | background: transparent; 91 | border: 1px solid #06b6d4; 92 | color: #06b6d4; 93 | } 94 | 95 | button.secondary:hover { 96 | background: rgba(0, 255, 255, 0.1); 97 | } 98 | 99 | @media (max-width: 600px) { 100 | .button-group { 101 | flex-direction: column; 102 | } 103 | button { 104 | width: 100%; 105 | } 106 | } -------------------------------------------------------------------------------- /src/pages/static/assets/svg/puzzle.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /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: Use current theme colors 20 | function getPreviewCSS() { 21 | const computedStyle = getComputedStyle(document.documentElement); 22 | const bgColor = computedStyle.getPropertyValue('--browser-theme-background').trim(); 23 | const textColor = computedStyle.getPropertyValue('--browser-theme-text-color').trim(); 24 | 25 | return ` 26 | :root { 27 | --browser-theme-background: ${bgColor}; 28 | --browser-theme-text-color: ${textColor}; 29 | } 30 | body { 31 | font-size: 1.2rem; 32 | margin: 0; 33 | padding: 0; 34 | background: var(--browser-theme-background); 35 | color: var(--browser-theme-text-color); 36 | } 37 | `; 38 | } 39 | 40 | // Function for live rendering 41 | export function update() { 42 | let htmlCode = $('#htmlCode').value; 43 | console.log('HTML Code:', htmlCode); 44 | let cssCode = $('#cssCode').value; 45 | console.log('CSS Code:', cssCode); 46 | let javascriptCode = $('#javascriptCode').value; 47 | console.log('JavaScript Code:', javascriptCode); 48 | // Assemble all elements for the iframe preview, using dynamic theme CSS 49 | let iframeContent = ` 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | ${htmlCode} 59 | 60 | 61 | 62 | `; 63 | 64 | let iframeDoc = iframe.contentWindow.document; 65 | iframeDoc.open(); 66 | iframeDoc.write(iframeContent); 67 | iframeDoc.close(); 68 | } 69 | 70 | // Show or hide the loading spinner 71 | export function showSpinner(show) { 72 | backdrop.style.display = show ? 'block' : 'none'; 73 | loadingSpinner.style.display = show ? 'block' : 'none'; 74 | } -------------------------------------------------------------------------------- /src/pages/theme/qr.css: -------------------------------------------------------------------------------- 1 | qr-popup { 2 | display: block; 3 | position: absolute; 4 | z-index: 1000; 5 | } 6 | .qr-popup { 7 | position: fixed; 8 | background-color: var(--browser-theme-background); 9 | color: var(--browser-theme-text-color); 10 | border: 1px solid var(--peersky-nav-button-inactive); 11 | border-radius: 8px; 12 | padding: 16px; 13 | display: flex; 14 | flex-direction: column; 15 | align-items: center; 16 | gap: 10px; 17 | z-index: 1000; 18 | opacity: 0; 19 | transform: scale(0.8); 20 | transition: opacity 0.3s ease, transform 0.3s ease; 21 | max-width: min(90vw, 320px); 22 | width: 100%; 23 | font-family: var(--browser-theme-font-family); 24 | } 25 | 26 | [data-theme="transparent"] .qr-popup { 27 | background-color: #18181b; 28 | } 29 | .qr-popup img { 30 | width: 100%; 31 | max-width: 200px; 32 | height: auto; 33 | border-radius: 4px; 34 | } 35 | .qr-popup.open { 36 | opacity: 1; 37 | transform: scale(1); 38 | } 39 | 40 | .qr-popup.close { 41 | opacity: 0; 42 | pointer-events: none; 43 | transform: scale(0.8); 44 | } 45 | 46 | .qr-popup-header { 47 | display: flex; 48 | justify-content: space-between; 49 | align-items: center; 50 | width: 100%; 51 | } 52 | 53 | .qr-popup-header p { 54 | margin: 0; 55 | font-size: 16px; 56 | font-weight: 500; 57 | color: var(--browser-theme-text-color); 58 | } 59 | 60 | .close-btn { 61 | color: var(--peersky-nav-button-inactive); 62 | border: none; 63 | cursor: pointer; 64 | padding: 4px; 65 | display: flex; 66 | align-items: center; 67 | justify-content: center; 68 | } 69 | 70 | .close-btn:hover { 71 | color: var(--peersky-nav-button-active); 72 | } 73 | 74 | .qr-url { 75 | font-size: 12px; 76 | color: var(--browser-theme-text-color); 77 | word-break: break-all; 78 | text-align: center; 79 | max-width: 100%; 80 | } 81 | 82 | .download-btn { 83 | background-color: var(--peersky-nav-background); 84 | color: var(--browser-theme-text-color); 85 | border: 1px solid var(--peersky-nav-button-inactive); 86 | border-radius: 4px; 87 | padding: 8px 16px; 88 | cursor: pointer; 89 | font-size: 14px; 90 | font-family: var(--browser-theme-font-family); 91 | transition: background-color 0.2s ease; 92 | } 93 | 94 | .download-btn:disabled { 95 | background-color: var(--peersky-nav-button-inactive); 96 | cursor: not-allowed; 97 | } 98 | 99 | .download-btn:hover:not(:disabled) { 100 | background-color: var(--peersky-nav-button-inactive); 101 | } 102 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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 | /* Settings Page Variables */ 37 | --settings-bg-primary: var(--base01); 38 | --settings-text-primary: var(--base07); 39 | --settings-text-secondary: var(--base05); 40 | --settings-card-bg: var(--base02); 41 | --settings-border: var(--base04); 42 | --settings-border-hover: var(--base05); 43 | --settings-border-focus: var(--base06); 44 | --settings-backdrop-blur: blur(10px); 45 | --settings-danger-color: #e53935; 46 | --settings-danger-color-hover: #c62828; 47 | 48 | /* Context Menu Icon Filters */ 49 | --context-menu-icon-filter: brightness(0) saturate(100%) invert(88%) sepia(79%) saturate(2476%) hue-rotate(86deg) brightness(118%) contrast(119%); 50 | --context-menu-icon-hover-filter: brightness(0) saturate(100%) invert(100%) sepia(0%) saturate(0%) hue-rotate(0deg) brightness(100%) contrast(100%); 51 | } 52 | -------------------------------------------------------------------------------- /src/pages/p2p/editor/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | P2P Editor 6 | 7 | 12 |
13 |
14 | 17 |
18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 |
32 |
33 |
34 | 40 | 41 | 42 |
43 |
44 | 45 |
46 |
47 | 48 | 49 |
50 |
51 | 52 | 53 | 54 | 61 | 62 | 63 | 64 |
65 | 66 |
67 |
68 | 69 | 70 | 71 | 72 | -------------------------------------------------------------------------------- /src/pages/titlebar.js: -------------------------------------------------------------------------------- 1 | const { ipcRenderer } = require('electron'); 2 | 3 | class TitleBar extends HTMLElement { 4 | constructor() { 5 | super(); 6 | this.buildTitleBar(); 7 | } 8 | 9 | buildTitleBar() { 10 | this.id = "titlebar"; 11 | this.className = "titlebar"; 12 | 13 | // Left side - App icon 14 | const appIcon = document.createElement("div"); 15 | appIcon.className = "app-icon"; 16 | 17 | // Middle - Tabs container 18 | this.tabsContainer = document.createElement("div"); 19 | this.tabsContainer.id = "tabbar-container"; 20 | this.tabsContainer.className = "tabbar-container"; 21 | 22 | if(process.platform !== "darwin") { 23 | // Right side - Window controls 24 | const windowControls = document.createElement("div"); 25 | windowControls.className = "window-controls"; 26 | 27 | // Window control buttons 28 | const minimizeBtn = document.createElement("button"); 29 | minimizeBtn.className = "window-control minimize"; 30 | minimizeBtn.innerHTML = "–"; 31 | minimizeBtn.title = "Minimize"; 32 | 33 | const maximizeBtn = document.createElement("button"); 34 | maximizeBtn.className = "window-control maximize"; 35 | maximizeBtn.innerHTML = "❑"; 36 | maximizeBtn.title = "Maximize"; 37 | 38 | const closeBtn = document.createElement("button"); 39 | closeBtn.className = "window-control close"; 40 | closeBtn.innerHTML = "✕"; 41 | closeBtn.title = "Close"; 42 | 43 | // Event listeners 44 | minimizeBtn.addEventListener("click", () => { 45 | ipcRenderer.send("window-control", "minimize"); 46 | }); 47 | 48 | maximizeBtn.addEventListener("click", () => { 49 | ipcRenderer.send("window-control", "maximize"); 50 | }); 51 | 52 | closeBtn.addEventListener("click", () => { 53 | ipcRenderer.send("window-control", "close"); 54 | }); 55 | 56 | windowControls.appendChild(minimizeBtn); 57 | windowControls.appendChild(maximizeBtn); 58 | windowControls.appendChild(closeBtn); 59 | 60 | this.appendChild(appIcon); 61 | this.appendChild(this.tabsContainer); 62 | this.appendChild(windowControls); 63 | } 64 | else { 65 | this.tabsContainer.style.marginLeft = "70px"; 66 | 67 | this.appendChild(appIcon); 68 | this.appendChild(this.tabsContainer); 69 | } 70 | } 71 | 72 | connectTabBar(tabBar) { 73 | // Add the tabBar to container 74 | if (tabBar) { 75 | this.tabsContainer.appendChild(tabBar); 76 | 77 | // On macOS, check if vertical tabs are being used 78 | if (process.platform === "darwin" && tabBar.classList.contains('vertical-tabs')) { 79 | this.classList.add('titlebar-collapsed-darwin'); 80 | } 81 | } 82 | } 83 | 84 | // Method to toggle titlebar visibility on Darwin 85 | toggleDarwinCollapse(shouldCollapse) { 86 | if (process.platform === "darwin") { 87 | if (shouldCollapse) { 88 | this.classList.add('titlebar-collapsed-darwin'); 89 | } else { 90 | this.classList.remove('titlebar-collapsed-darwin'); 91 | } 92 | } 93 | } 94 | } 95 | 96 | customElements.define('title-bar', TitleBar); -------------------------------------------------------------------------------- /src/pages/clock.js: -------------------------------------------------------------------------------- 1 | class Clock extends HTMLElement { 2 | constructor() { 3 | super(); 4 | this.updateTime(); 5 | this.isVisible = true; // Default state 6 | this.setupIPC(); 7 | } 8 | 9 | connectedCallback() { 10 | this.render(); 11 | this.startClock(); 12 | this.loadInitialSettings(); 13 | } 14 | 15 | setupIPC() { 16 | // Use electronAPI exposed by unified-preload.js 17 | if (window.electronAPI) { 18 | this.electronAPI = window.electronAPI; 19 | 20 | // Listen for show-clock-changed events 21 | try { 22 | this.electronAPI.onShowClockChanged((showClock) => { 23 | console.log('Clock: Show clock setting changed to:', showClock); 24 | this.setVisibility(showClock); 25 | }); 26 | } catch (error) { 27 | console.error('Clock: Failed to set up event listener:', error); 28 | } 29 | } else { 30 | console.warn('Clock: electronAPI not available, clock toggle will not work'); 31 | } 32 | } 33 | 34 | async loadInitialSettings() { 35 | if (!this.electronAPI) { 36 | console.warn('Clock: electronAPI not available, using default visibility'); 37 | return; 38 | } 39 | 40 | try { 41 | const showClock = await this.electronAPI.settings.get('showClock'); 42 | console.log('Clock: Initial showClock setting:', showClock); 43 | this.setVisibility(showClock); 44 | } catch (error) { 45 | console.error('Clock: Failed to load initial settings:', error); 46 | // Keep default visibility on error 47 | } 48 | } 49 | 50 | setVisibility(visible) { 51 | this.isVisible = visible; 52 | this.style.display = visible ? 'block' : 'none'; 53 | console.log('Clock: Visibility set to:', visible ? 'visible' : 'hidden'); 54 | } 55 | 56 | render() { 57 | this.style.position = "absolute"; 58 | this.style.top = "20px"; 59 | this.style.right = "20px"; 60 | this.style.color = "#FFFFFF"; 61 | this.style.fontFamily = "'Helvetica Neue', Arial, sans-serif"; 62 | this.style.fontSize = "30px"; 63 | this.style.fontWeight = "200"; 64 | this.style.padding = "8px"; 65 | this.style.borderRadius = "12px"; 66 | this.style.backgroundColor = "rgba(255, 255, 255, 0.1)"; 67 | this.style.backdropFilter = "blur(10px) saturate(180%)"; 68 | this.style.border = "1px solid rgba(255, 255, 255, 0.2)"; 69 | this.style.boxShadow = "0 4px 10px rgba(0, 0, 0, 0.2)"; 70 | this.textContent = this.formatTime(this.currentTime); 71 | } 72 | 73 | startClock() { 74 | setInterval(() => { 75 | this.updateTime(); 76 | this.render(); 77 | }, 1000); 78 | } 79 | 80 | updateTime() { 81 | this.currentTime = new Date(); 82 | } 83 | 84 | formatTime(date) { 85 | const hours = String(date.getHours()).padStart(2, "0"); 86 | const minutes = String(date.getMinutes()).padStart(2, "0"); 87 | return `${hours}:${minutes}`; 88 | } 89 | } 90 | 91 | customElements.define("simple-clock", Clock); 92 | -------------------------------------------------------------------------------- /src/pages/static/js/bookmarks.js: -------------------------------------------------------------------------------- 1 | class BookmarkBox extends HTMLElement { 2 | constructor() { 3 | super(); 4 | this.checkApiSupport(); 5 | this.attachShadow({ mode: "open" }); 6 | this.render(); 7 | this.loadBookmarks(); 8 | } 9 | checkApiSupport() { 10 | if ( 11 | !window.electronAPI || 12 | !window.electronAPI.getBookmarks || 13 | !window.electronAPI.deleteBookmark 14 | ) { 15 | console.error("Bookmark API is not supported in this environment."); 16 | this.shadowRoot.innerHTML = 17 | "

Error: Bookmark API is not available.

"; 18 | return; 19 | } 20 | } 21 | 22 | async loadBookmarks() { 23 | const bookmarks = await window.electronAPI.getBookmarks(); 24 | this.displayBookmarks(bookmarks); 25 | } 26 | 27 | displayBookmarks(bookmarks) { 28 | const container = this.shadowRoot.querySelector(".bookmarks-container"); 29 | container.innerHTML = ""; 30 | 31 | if (!bookmarks || bookmarks.length === 0) { 32 | container.innerHTML = "

No bookmarks saved yet.

"; 33 | return; 34 | } 35 | bookmarks.sort((a, b) => new Date(b.dateAdded) - new Date(a.dateAdded)); 36 | 37 | bookmarks.forEach((bookmark) => { 38 | const bookmarkElement = document.createElement("div"); 39 | bookmarkElement.className = "bookmark-item"; 40 | // Helper function to escape HTML 41 | function escapeHtml(text) { 42 | const div = document.createElement('div'); 43 | div.textContent = text; 44 | return div.innerHTML; 45 | } 46 | 47 | function escapeHtmlAttribute(text) { 48 | return text 49 | .replace(/&/g, '&') 50 | .replace(/"/g, '"') 51 | .replace(/'/g, ''') 52 | .replace(//g, '>'); 54 | } 55 | 56 | bookmarkElement.innerHTML = ` 57 | 58 | favicon 61 | ${escapeHtml(bookmark.title)} 62 | 63 | 64 | `; 65 | container.appendChild(bookmarkElement); 66 | }); 67 | 68 | this.attachDeleteListeners(); 69 | } 70 | 71 | attachDeleteListeners() { 72 | this.shadowRoot.querySelectorAll(".delete-btn").forEach((button) => { 73 | button.addEventListener("click", async (event) => { 74 | const urlToDelete = event.target.dataset.url; 75 | const success = await window.electronAPI.deleteBookmark(urlToDelete); 76 | if (success) { 77 | this.loadBookmarks(); 78 | } 79 | }); 80 | }); 81 | } 82 | 83 | render() { 84 | const link = document.createElement("link"); 85 | link.setAttribute("rel", "stylesheet"); 86 | link.setAttribute("href", "peersky://theme/bookmarks.css"); 87 | 88 | const container = document.createElement("div"); 89 | container.className = "bookmarks-container"; 90 | container.innerHTML = `

Bookmarks

`; 91 | 92 | this.shadowRoot.innerHTML = ""; // Clear previous content 93 | this.shadowRoot.appendChild(link); 94 | this.shadowRoot.appendChild(container); 95 | } 96 | } 97 | 98 | customElements.define("bookmark-box", BookmarkBox); 99 | -------------------------------------------------------------------------------- /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 | import { app } from 'electron'; 7 | import { createReadStream } from 'fs'; 8 | import { promises as fsPromises } from 'fs'; 9 | 10 | const __dirname = fileURLToPath(new URL('./', import.meta.url)); 11 | const pagesPath = path.join(__dirname, '../pages'); 12 | const fs = new ScopedFS(pagesPath); 13 | 14 | const CHECK_PATHS = [ 15 | (path) => path, 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 | fs.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 | // Handle wallpaper requests cleanly 40 | async function handleWallpaper(filename, sendResponse) { 41 | try { 42 | const wallpaperPath = path.join(app.getPath("userData"), "wallpapers", filename); 43 | await fsPromises.access(wallpaperPath); 44 | 45 | const data = createReadStream(wallpaperPath); 46 | const contentType = mime.lookup(wallpaperPath) || 'image/jpeg'; 47 | 48 | sendResponse({ 49 | statusCode: 200, 50 | headers: { 'Content-Type': contentType, 'Cache-Control': 'public, max-age=3600' }, 51 | data 52 | }); 53 | } catch { 54 | sendResponse({ 55 | statusCode: 404, 56 | headers: { 'Content-Type': 'text/plain' }, 57 | data: Readable.from(['Not found']) 58 | }); 59 | } 60 | } 61 | 62 | export async function createHandler() { 63 | return async function protocolHandler({ url }, sendResponse) { 64 | const parsedUrl = new URL(url); 65 | let filePath = parsedUrl.hostname + parsedUrl.pathname; 66 | 67 | if (filePath === '/') filePath = 'home'; 68 | if (filePath.startsWith('wallpaper/')) return handleWallpaper(filePath.slice(10), sendResponse); 69 | 70 | // Handle settings subpaths - map all /settings/* to settings.html 71 | if (filePath.startsWith('settings/')) { 72 | filePath = 'settings'; 73 | } 74 | 75 | try { 76 | const resolvedPath = await resolveFile(filePath); 77 | const format = path.extname(resolvedPath); 78 | 79 | if (!['', '.html', '.js', '.css', '.png', '.jpg', '.jpeg', '.gif', '.svg', '.ico'].includes(format)) { 80 | throw new Error('Unsupported file type'); 81 | } 82 | 83 | const statusCode = 200; 84 | const data = fs.createReadStream(resolvedPath); 85 | const contentType = mime.lookup(resolvedPath) || 'text/plain'; 86 | const headers = { 87 | 'Content-Type': contentType, 88 | 'Access-Control-Allow-Origin': '*', 89 | 'Allow-CSP-From': '*', 90 | 'Cache-Control': 'no-cache' 91 | }; 92 | 93 | sendResponse({ 94 | statusCode, 95 | headers, 96 | data 97 | }); 98 | } catch (e) { 99 | // File not found - send error code so renderer.js shows error.html 100 | sendResponse({ 101 | errorCode: -6, // net::ERR_FILE_NOT_FOUND 102 | }); 103 | } 104 | }; 105 | } -------------------------------------------------------------------------------- /src/pages/theme/tabs.css: -------------------------------------------------------------------------------- 1 | @import url('browser://theme/vars.css'); 2 | 3 | :host { 4 | --bg-color: var(--browser-theme-background); 5 | --item-hover-bg: var(--settings-bg-secondary); 6 | --text-color: var(--browser-theme-text-color); 7 | --border-color: var(--settings-border); 8 | --btn-color: var(--browser-theme-text-color); 9 | --btn-hover-color: var(--browser-theme-primary-highlight); 10 | } 11 | 12 | .tabs-container { 13 | background-color: var(--settings-card-bg); 14 | color: var(--browser-theme-text-color); 15 | padding: 1rem 1.5rem; 16 | max-width: 720px; 17 | margin: 2rem auto; 18 | border-radius: 8px; 19 | box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2); 20 | border: 1px solid var(--settings-border); 21 | font-family: var(--browser-theme-font-family, sans-serif); 22 | } 23 | 24 | h1 { 25 | font-size: 1.25rem; 26 | margin-bottom: 1rem; 27 | padding-bottom: 0.5rem; 28 | color: var(--browser-theme-text-color); 29 | } 30 | 31 | .tab-item { 32 | display: flex; 33 | align-items: center; 34 | justify-content: space-between; 35 | padding: 0.75rem 0.5rem; 36 | border-bottom: 1px solid var(--settings-border); 37 | transition: background 0.2s; 38 | cursor: pointer; 39 | } 40 | 41 | .tab-item:hover { 42 | background-color: var(--settings-bg-secondary); 43 | border-radius: 6px; 44 | } 45 | 46 | .title { 47 | overflow: hidden; 48 | text-overflow: ellipsis; 49 | white-space: nowrap; 50 | color: var(--browser-theme-text-color); 51 | flex-grow: 1; 52 | margin-right: 1rem; 53 | text-align: left; 54 | } 55 | 56 | .actions { 57 | display: flex; 58 | gap: 0.5rem; 59 | flex-shrink: 0; 60 | } 61 | 62 | .close-btn { 63 | background: transparent; 64 | border: none; 65 | color: var(--browser-theme-text-color); 66 | cursor: pointer; 67 | font-size: 1.2rem; 68 | padding: 0.25rem 0.5rem; 69 | border-radius: 4px; 70 | transition: color 0.2s, background-color 0.2s; 71 | min-width: 2rem; 72 | height: 2rem; 73 | display: flex; 74 | align-items: center; 75 | justify-content: center; 76 | opacity: 0.7; 77 | } 78 | 79 | .close-btn:hover { 80 | color: var(--browser-theme-primary-highlight); 81 | background-color: var(--settings-bg-secondary); 82 | opacity: 1; 83 | } 84 | 85 | .activate-btn { 86 | background: transparent; 87 | border: none; 88 | color: var(--browser-theme-text-color); 89 | cursor: pointer; 90 | font-size: 0.9rem; 91 | padding: 0 0.25rem; 92 | transition: color 0.2s; 93 | opacity: 0.7; 94 | } 95 | 96 | .activate-btn:hover { 97 | color: var(--browser-theme-primary-highlight); 98 | opacity: 1; 99 | } 100 | 101 | .group { 102 | margin-top: 1rem; 103 | border: 1px solid var(--settings-border); 104 | border-radius: 6px; 105 | background: var(--settings-card-bg); 106 | } 107 | 108 | .group-header { 109 | display: flex; 110 | justify-content: space-between; 111 | align-items: center; 112 | padding: 0.5rem; 113 | border-radius: 6px 6px 0 0; 114 | color: var(--settings-bg-primary); 115 | background: var(--settings-bg-secondary); 116 | border-bottom: 1px solid var(--settings-border); 117 | } 118 | 119 | .group-actions button { 120 | margin-left: 0.25rem; 121 | background: transparent; 122 | border: none; 123 | color: var(--settings-bg-primary); 124 | cursor: pointer; 125 | font-size: 0.8rem; 126 | transition: color 0.2s; 127 | opacity: 0.7; 128 | } 129 | 130 | .group-actions button:hover { 131 | color: var(--browser-theme-primary-highlight); 132 | opacity: 1; 133 | } 134 | 135 | .group-tabs { 136 | padding: 0.5rem; 137 | background: var(--settings-card-bg); 138 | } 139 | -------------------------------------------------------------------------------- /src/pages/plan1.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 14 | Plan 1 15 |
16 |

Plan 1

17 |

18 | A love letter to the grateful dead internet hypothesis 19 |

20 | 21 |

Elves

22 |

23 | Elves are discreet hypertext helpers. 24 |

25 | 26 |

Hello World

27 |

28 | The first elvish taught to most humans is hello-world. 29 |

30 | 31 |

Demo

32 |
33 | 34 |
35 | 36 |

QR Code

37 |

38 | qr-code required attributes: 39 |

40 | 41 | 44 | 45 |

46 | qr-code optional attributes 47 |

48 | 49 | 53 | 54 |

Demo

55 | 56 |
57 | 58 |
59 | 60 |
61 | 62 |

Elvish

63 |

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

66 | 67 |

Befriending Elves

68 |

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

71 | 72 |

73 | The goodbye-world elf is known by the following definition: 74 |

75 | 76 |
const $ = elf('goodbye-world', { planet: 'World' })
77 | 78 |

Learn

79 |

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

82 | 83 |
const data = $.learn()
84 | 85 |

Draw

86 |

87 | 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. 88 |

89 | 90 |
$.draw((target) => {
 91 |     const data = $.learn()
 92 |     return '<button>Hello ${data.planet}!</button>'
 93 |   }, {
 94 |   beforeUpdate: (target) => null,
 95 |   afterUpdate: (target) => null
 96 | })
97 | 98 |

Style

99 |

100 | Elves style the same way humans do, with cascading style sheets. 101 |

102 | 103 |
$.style(`
104 |   & {
105 |     color: red;
106 |   }
107 | `)
108 | 109 |

When

110 |

111 | When elves have detected a human interaction, they can process the event. 112 |

113 | 114 |
$.when('click', 'button', (event) =>  {
115 |   $.teach({ planet: pluto })
116 | })
117 | 118 |

Teach

119 |

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

122 | 123 |
$.teach(
124 |   { planet: 'pluto'},
125 |   (state, payload) => {
126 |     return {
127 |       ...state,
128 |       defunctPlanets: [...new Set([...state.defunctPlanets, payload.planet])]
129 |     }
130 | })
131 | 132 |

Demo

133 |

134 | The demo below is inspired by the documentation above, but not exact. 135 |

136 |
137 | 138 |
139 |
140 | 141 | 142 | 143 | 144 | -------------------------------------------------------------------------------- /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 | import settingsManager from '../settings-manager.js'; 7 | 8 | const __dirname = fileURLToPath(new URL('./', import.meta.url)); 9 | const themePath = path.join(__dirname, '../pages/theme'); 10 | const pagesPath = path.join(__dirname, '../pages'); 11 | const themeFS = new ScopedFS(themePath); 12 | const pagesFS = new ScopedFS(pagesPath); 13 | 14 | const CHECK_PATHS = [ 15 | (path) => path, 16 | (path) => path + 'index.html', 17 | (path) => path + '/index.html', 18 | (path) => path + '.html' 19 | ]; 20 | 21 | async function resolveFile(filePath) { 22 | for (const toTry of CHECK_PATHS) { 23 | const tryPath = toTry(filePath); 24 | if (await exists(tryPath)) return tryPath; 25 | } 26 | throw new Error('File not found'); 27 | } 28 | 29 | async function exists(filePath) { 30 | return new Promise((resolve, reject) => { 31 | themeFS.stat(filePath, (err, stat) => { 32 | if (err) { 33 | if (err.code === 'ENOENT') resolve(false); 34 | else reject(err); 35 | } else resolve(stat.isFile()); 36 | }); 37 | }); 38 | } 39 | 40 | async function get404Response() { 41 | try { 42 | await new Promise((resolve, reject) => { 43 | pagesFS.stat('404.html', (err, stat) => { 44 | if (err) reject(err); 45 | else resolve(stat.isFile()); 46 | }); 47 | }); 48 | return { 49 | statusCode: 404, 50 | headers: { 51 | 'Content-Type': 'text/html', 52 | 'Access-Control-Allow-Origin': '*', 53 | 'Allow-CSP-From': '*', 54 | 'Cache-Control': 'no-cache' 55 | }, 56 | data: pagesFS.createReadStream('404.html') 57 | }; 58 | } catch (e) { 59 | console.error('Failed to serve 404.html:', e); 60 | return { 61 | statusCode: 404, 62 | headers: { 63 | 'Content-Type': 'text/plain', 64 | 'Access-Control-Allow-Origin': '*', 65 | 'Allow-CSP-From': '*', 66 | 'Cache-Control': 'no-cache' 67 | }, 68 | data: Readable.from(['File not found']) 69 | }; 70 | } 71 | } 72 | 73 | export async function createHandler() { 74 | return async function protocolHandler({ url }, sendResponse) { 75 | const parsedUrl = new URL(url); 76 | 77 | if (parsedUrl.hostname === 'theme') { 78 | const fileName = parsedUrl.pathname.slice(1); 79 | 80 | try { 81 | let resolvedPath; 82 | 83 | // Handle dynamic theme loading for vars.css 84 | if (fileName === 'vars.css') { 85 | try { 86 | // Use the unified themes.css file for all theme switching 87 | resolvedPath = await resolveFile('themes.css'); 88 | } catch (themeError) { 89 | // Fallback to default vars.css if unified theme file not found 90 | console.warn('Unified themes.css file not found, falling back to vars.css'); 91 | resolvedPath = await resolveFile(fileName); 92 | } 93 | } else { 94 | // For all other files, use normal resolution 95 | resolvedPath = await resolveFile(fileName); 96 | } 97 | 98 | const statusCode = 200; 99 | const data = themeFS.createReadStream(resolvedPath); 100 | const contentType = mime.lookup(resolvedPath) || 'text/plain'; 101 | const headers = { 102 | 'Content-Type': contentType, 103 | 'Access-Control-Allow-Origin': '*', 104 | 'Allow-CSP-From': '*', 105 | 'Cache-Control': 'no-cache, no-store, must-revalidate', 106 | 'Pragma': 'no-cache', 107 | 'Expires': '0', 108 | 'ETag': `"theme-${Date.now()}"`, 109 | 'Last-Modified': new Date().toUTCString() 110 | }; 111 | 112 | sendResponse({ 113 | statusCode, 114 | headers, 115 | data 116 | }); 117 | } catch (e) { 118 | console.log('File not found:', fileName); 119 | sendResponse(await get404Response()); 120 | } 121 | } else { 122 | sendResponse(await get404Response()); 123 | } 124 | }; 125 | } 126 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "peersky-browser", 3 | "version": "1.0.0-beta.15", 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 | "extendInfo": { 45 | "NSMicrophoneUsageDescription": "The current page is asking to use your microphone", 46 | "NSCameraUsageDescription": "The current page is asking to use your camera", 47 | "com.apple.security.device.audio-input": true, 48 | "com.apple.security.device.camera": true 49 | }, 50 | "target": [ 51 | { 52 | "target": "default", 53 | "arch": [ 54 | "x64", 55 | "arm64" 56 | ] 57 | } 58 | ], 59 | "icon": "./public/icon.icns" 60 | }, 61 | "win": { 62 | "target": [ 63 | "nsis", 64 | "portable" 65 | ], 66 | "icon": "./public/icon.png" 67 | }, 68 | "linux": { 69 | "artifactName": "${name}-${version}-${os}-${arch}.${ext}", 70 | "executableArgs": [ 71 | "--enable-accelerated-video" 72 | ], 73 | "target": [ 74 | { 75 | "target": "deb", 76 | "arch": [ 77 | "x64", 78 | "arm64" 79 | ] 80 | }, 81 | "AppImage", 82 | "apk", 83 | "pacman" 84 | ], 85 | "category": "Network;WebBrowser" 86 | }, 87 | "protocols": [ 88 | { 89 | "name": "webpages", 90 | "schemes": [ 91 | "http", 92 | "https" 93 | ], 94 | "role": "Viewer" 95 | }, 96 | { 97 | "name": "ipfs", 98 | "schemes": [ 99 | "ipfs", 100 | "ipns", 101 | "ipld" 102 | ], 103 | "role": "Viewer" 104 | }, 105 | { 106 | "name": "hyper", 107 | "schemes": [ 108 | "hyper", 109 | "dat" 110 | ], 111 | "role": "Viewer" 112 | }, 113 | { 114 | "name": "web3", 115 | "schemes": [ 116 | "web3" 117 | ], 118 | "role": "Viewer" 119 | } 120 | ], 121 | "publish": [ 122 | { 123 | "provider": "github", 124 | "owner": "p2plabsxyz", 125 | "repo": "peersky-browser", 126 | "releaseType": "release" 127 | } 128 | ] 129 | }, 130 | "dependencies": { 131 | "@helia/ipns": "^8.2.3", 132 | "@helia/unixfs": "^5.0.3", 133 | "@libp2p/kad-dht": "^15.1.11", 134 | "b4a": "^1.6.7", 135 | "blockstore-level": "^2.0.3", 136 | "chromium-net-errors": "^13.0.0", 137 | "content-hash": "^2.5.2", 138 | "content-type": "^1.0.5", 139 | "datastore-level": "^11.0.3", 140 | "electron-find": "^1.0.7", 141 | "electron-log": "^5.3.0", 142 | "electron-updater": "^6.2.1", 143 | "ethers": "^6.13.4", 144 | "fs-extra": "^11.2.0", 145 | "helia": "^5.4.2", 146 | "hyper-sdk": "^6.1.0", 147 | "hypercore-crypto": "^3.6.1", 148 | "hypercore-fetch": "^10.0.0", 149 | "hyperdht": "^6.27.0", 150 | "hyperswarm": "^4.14.0", 151 | "jquery": "^3.7.1", 152 | "libp2p": "^2.8.8", 153 | "mime-types": "^2.1.35", 154 | "multiformats": "^13.3.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 | -------------------------------------------------------------------------------- /src/protocols/helia/helia.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import { createHelia } from "helia"; 3 | import { createLibp2p } from "libp2p"; 4 | import { libp2pDefaults } from "helia"; 5 | import { noise } from "@chainsafe/libp2p-noise"; 6 | import { yamux } from "@chainsafe/libp2p-yamux"; 7 | import { mdns } from "@libp2p/mdns"; 8 | import { tcp } from "@libp2p/tcp"; 9 | import { webRTC, webRTCDirect } from "@libp2p/webrtc"; 10 | import { webSockets } from "@libp2p/websockets"; 11 | import { circuitRelayTransport } from "@libp2p/circuit-relay-v2"; 12 | import { autoNAT } from "@libp2p/autonat"; 13 | import { autoTLS } from '@ipshipyard/libp2p-auto-tls' 14 | import { uPnPNAT } from "@libp2p/upnp-nat"; 15 | import { dcutr } from "@libp2p/dcutr"; 16 | import { kadDHT, removePrivateAddressesMapper } from "@libp2p/kad-dht"; 17 | import { ping } from "@libp2p/ping"; 18 | import { identify, identifyPush } from "@libp2p/identify"; 19 | import { bootstrap } from "@libp2p/bootstrap"; 20 | import { createDelegatedRoutingV1HttpApiClient } from "@helia/delegated-routing-v1-http-api-client"; 21 | import { delegatedHTTPRoutingDefaults } from "@helia/routers"; 22 | import { ipnsValidator } from "ipns/validator"; 23 | import { ipnsSelector } from "ipns/selector"; 24 | import { userAgent } from "libp2p/user-agent"; 25 | import { ipfsOptions, getLibp2pPrivateKey } from "../config.js"; 26 | import pkg from '../../../package.json' assert { type: 'json' }; 27 | const { version } = pkg; 28 | 29 | // https://github.com/ipfs/helia/blob/main/packages/helia/src/utils/bootstrappers.ts 30 | const bootstrapConfig = { 31 | list: [ 32 | '/dnsaddr/bootstrap.libp2p.io/p2p/QmNnooDu7bfjPFoTZYxMNLWUQJyrVwtbZg5gBMjTezGAJN', 33 | '/dnsaddr/bootstrap.libp2p.io/p2p/QmbLHAnMoJPWSCR5Zhtx6BHJX9KiKNN6tpvbUcqanj75Nb', 34 | '/dnsaddr/bootstrap.libp2p.io/p2p/QmcZf59bWwK5XFi76CZX8cbJ4BhTzzA3gU1ZjYZcYW3dwt', 35 | // va1 is not in the TXT records for _dnsaddr.bootstrap.libp2p.io yet 36 | // so use the host name directly 37 | '/dnsaddr/va1.bootstrap.libp2p.io/p2p/12D3KooWKnDdG3iXw9eTFijk3EWSunZcFi54Zka4wmtqtt6rPxc8', 38 | '/ip4/104.131.131.82/tcp/4001/p2p/QmaCpDMGvV2BGHeYERUEnRQAwe3N8SzbUtfsmvsqQLuvuJ' 39 | ] 40 | } 41 | 42 | export async function createNode() { 43 | const options = await ipfsOptions(); 44 | 45 | const privateKey = await getLibp2pPrivateKey(); 46 | const agentVersion = `peersky-browser/${version} ${userAgent()}`; 47 | 48 | const defaults = libp2pDefaults({ privateKey }); 49 | 50 | const libp2p = await createLibp2p({ 51 | ...defaults, 52 | nodeInfo: { 53 | userAgent: agentVersion 54 | }, 55 | addresses: { 56 | listen: [ 57 | '/ip4/0.0.0.0/tcp/0', 58 | '/ip4/0.0.0.0/tcp/0/ws', 59 | '/ip4/0.0.0.0/udp/0/webrtc-direct', 60 | '/ip6/::/udp/0/webrtc-direct', 61 | '/p2p-circuit' 62 | ], 63 | }, 64 | transports: [ 65 | tcp(), 66 | webSockets(), 67 | webRTC(), 68 | webRTCDirect(), 69 | circuitRelayTransport(), 70 | ], 71 | connectionEncrypters: [noise()], 72 | streamMuxers: [yamux()], 73 | peerDiscovery: [ 74 | mdns(), 75 | bootstrap(bootstrapConfig), 76 | ], 77 | services: { 78 | ...defaults.services, 79 | autoNAT: autoNAT(), 80 | autoTLS: autoTLS(), 81 | dcutr: dcutr(), 82 | delegatedRouting: () => createDelegatedRoutingV1HttpApiClient('https://delegated-ipfs.dev', delegatedHTTPRoutingDefaults()), 83 | aminoDHT: kadDHT({ 84 | protocol: '/ipfs/kad/1.0.0', 85 | peerInfoMapper: removePrivateAddressesMapper, 86 | validators: { ipns: ipnsValidator }, 87 | selectors: { ipns: ipnsSelector }, 88 | reprovide: { 89 | concurrency: 10, 90 | interval: 60 * 60 * 1000, 91 | threshold: 12 * 60 * 60 * 1000 92 | } 93 | }), 94 | identify: identify(), 95 | identifyPush: identifyPush(), 96 | ping: ping(), 97 | upnpNAT: uPnPNAT(), 98 | }, 99 | connectionManager: { 100 | maxConnections: 500, 101 | inboundConnectionThreshold: 100, 102 | maxIncomingPendingConnections: 100, 103 | }, 104 | }); 105 | 106 | /** @type {any} */ 107 | const ds = options.datastore; 108 | /** @type {any} */ 109 | const bs = options.blockstore; 110 | 111 | const node = await createHelia({ 112 | ...options, 113 | libp2p, 114 | datastore: ds, 115 | blockstore: bs, 116 | }); 117 | 118 | console.log("Peer ID:", node.libp2p.peerId.toString()); 119 | console.log("Node userAgent:", agentVersion); 120 | 121 | return node; 122 | } 123 | -------------------------------------------------------------------------------- /src/protocols/config.js: -------------------------------------------------------------------------------- 1 | import { app } from "electron"; 2 | import { LevelBlockstore } from "blockstore-level"; 3 | import { LevelDatastore } from "datastore-level"; 4 | import path from "path"; 5 | import fs from "fs-extra"; 6 | import crypto from "hypercore-crypto"; 7 | import { getDefaultChainList } from "web3protocol/chains"; 8 | import { generateKeyPair, privateKeyFromProtobuf, privateKeyToProtobuf } from "@libp2p/crypto/keys"; 9 | 10 | const USER_DATA = app.getPath("userData"); 11 | const DEFAULT_IPFS_DIR = path.join(USER_DATA, "ipfs"); 12 | const BLOCKSTORE_PATH = path.join(DEFAULT_IPFS_DIR, "blocks"); 13 | const DATASTORE_PATH = path.join(DEFAULT_IPFS_DIR, "datastore"); 14 | const DEFAULT_HYPER_DIR = path.join(USER_DATA, "hyper"); 15 | const ENS_CACHE = path.join(USER_DATA, "ensCache.json"); 16 | const KEYPAIR_PATH = path.join(DEFAULT_HYPER_DIR, "swarm-keypair.json"); 17 | const LIBP2P_KEY_PATH = path.join(DEFAULT_IPFS_DIR, "libp2p-key"); 18 | 19 | // Try loading an existing keypair from disk 20 | export function loadKeyPair() { 21 | if (fs.existsSync(KEYPAIR_PATH)) { 22 | const data = fs.readJsonSync(KEYPAIR_PATH); 23 | return { 24 | publicKey: Buffer.from(data.publicKey, "hex"), 25 | secretKey: Buffer.from(data.secretKey, "hex") 26 | }; 27 | } 28 | return null; 29 | } 30 | 31 | // Save a new keypair to disk 32 | export function saveKeyPair(keyPair) { 33 | // Ensure the hyper directory exists 34 | fs.ensureDirSync(DEFAULT_HYPER_DIR); 35 | 36 | fs.writeJsonSync(KEYPAIR_PATH, { 37 | publicKey: keyPair.publicKey.toString("hex"), 38 | secretKey: keyPair.secretKey.toString("hex") 39 | }); 40 | } 41 | 42 | // Load the libp2p private key from disk (for Helia) 43 | export async function loadLibp2pKey() { 44 | try { 45 | const raw = await fs.promises.readFile(LIBP2P_KEY_PATH); 46 | console.log(`Loaded libp2p key from ${LIBP2P_KEY_PATH}`); 47 | return privateKeyFromProtobuf(new Uint8Array(raw)); 48 | } catch (err) { 49 | if (err.code === "ENOENT") { 50 | console.log(`No libp2p key found at ${LIBP2P_KEY_PATH}, will generate a new one`); 51 | return null; 52 | } 53 | throw err; 54 | } 55 | } 56 | 57 | // Save the libp2p private key to disk (for Helia) 58 | export async function saveLibp2pKey(privateKey) { 59 | try { 60 | await fs.promises.mkdir(path.dirname(LIBP2P_KEY_PATH), { recursive: true }); 61 | const pb = privateKeyToProtobuf(privateKey); 62 | await fs.promises.writeFile(LIBP2P_KEY_PATH, Buffer.from(pb)); 63 | console.log(`Saved libp2p key to ${LIBP2P_KEY_PATH}`); 64 | } catch (err) { 65 | console.error(`Error saving libp2p key: ${err.message}`); 66 | throw err; 67 | } 68 | } 69 | 70 | // Get or generate a persistent private key for Helia 71 | export async function getLibp2pPrivateKey() { 72 | let privateKey = await loadLibp2pKey(); 73 | if (!privateKey) { 74 | privateKey = await generateKeyPair("Ed25519"); 75 | await saveLibp2pKey(privateKey); 76 | } 77 | return privateKey; 78 | } 79 | 80 | export async function ipfsOptions() { 81 | await fs.ensureDir(BLOCKSTORE_PATH); 82 | await fs.ensureDir(DATASTORE_PATH); 83 | return { 84 | repo: DEFAULT_IPFS_DIR, 85 | blockstore: new LevelBlockstore(BLOCKSTORE_PATH), 86 | datastore: new LevelDatastore(DATASTORE_PATH), 87 | }; 88 | } 89 | 90 | export const hyperOptions = { 91 | // All options here: https://github.com/datproject/sdk/#const-hypercore-hyperdrive-resolvename-keypair-derivesecret-registerextension-close--await-sdkopts 92 | storage: DEFAULT_HYPER_DIR, 93 | }; 94 | 95 | // Initialize RPC_URL using top-level await (avoiding an async IIFE) 96 | const chainList = await getDefaultChainList(); 97 | const targetChainId = 1; // Ethereum mainnet 98 | const targetChain = chainList.find((chain) => chain.id === targetChainId); 99 | export const RPC_URL = 100 | targetChain && targetChain.rpcUrls?.length > 0 101 | ? targetChain.rpcUrls[0] 102 | : (console.error(`Could not find RPC URL for chain ${targetChainId}`), null); 103 | 104 | // Initialize or load ENS cache 105 | let ensCache = new Map(); 106 | if (fs.existsSync(ENS_CACHE)) { 107 | try { 108 | const data = fs.readFileSync(ENS_CACHE, "utf-8"); 109 | const parsedData = JSON.parse(data); 110 | ensCache = new Map(parsedData); 111 | } catch (error) { 112 | console.error("Failed to load ENS cache from file:", error); 113 | } 114 | } else { 115 | console.log( 116 | "No existing ENS cache file found. Starting with an empty cache." 117 | ); 118 | } 119 | 120 | // Function to save cache to file 121 | export function saveEnsCache() { 122 | try { 123 | const data = JSON.stringify(Array.from(ensCache.entries()), null, 2); 124 | fs.writeFileSync(ENS_CACHE, data, "utf-8"); 125 | console.log("ENS cache saved to file."); 126 | } catch (error) { 127 | console.error("Failed to save ENS cache to file:", error); 128 | } 129 | } 130 | 131 | // Export the cache and save function 132 | export { ensCache }; 133 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /docs/LLM.md: -------------------------------------------------------------------------------- 1 | # Local LLMs in PeerSky 2 | 3 | Most “AI browsers” sit between you and the web, logging every prompt and response on their own servers. PeerSky does the opposite. It ships with a user-controlled LLM bridge so apps can talk to a model that runs on your own machine, not in someone else’s data center. This design is inspired by [Agregore’s AI and LLM APIs](https://agregore.mauve.moe/docs/ai). 4 | 5 | P2P apps can use this bridge to generate text, code, or metadata, then publish the results directly over P2P protocols. No account, no tracking layer, just your browser, your model, and your peers. 6 | 7 | ## Why run models locally? 8 | 9 | - **Privacy-first:** Prompts and generated data never leave your device unless you explicitly publish them. 10 | - **Offline friendly:** Once a model is pulled, it works even when you are disconnected—perfect for P2P and local-first apps. 11 | - **Resource-aware:** Smaller OSS models (e.g. 3B parameters) sip power compared to cloud APIs, cutting cost and footprint. 12 | - **Predictable costs:** You pay once in disk and CPU instead of paying per token or wiring every request through a commercial API. 13 | - **Native to P2P publishing:** Generated content can be shared over IPFS/Hyper directly from the p2p apps. 14 | 15 | ## Enabling the LLM bridge 16 | 17 | 1. Open `peersky://settings/llm`. 18 | 2. Toggle **Enable Local LLM API**. 19 | 3. Choose the **Base URL**, **API Key**, and **Model Name** you want to expose to apps. 20 | 4. Save—PeerSky will auto-download the model if it is missing and notify you. 21 | 22 | ![AI & LLM Settings](./images/peersky-llm-settings.png) 23 | 24 | ### Field reference 25 | 26 | | Setting | Purpose | 27 | |--------------|------------------------------------------------------------------------------------------------| 28 | | Base URL | Endpoint for the LLM server. Defaults to `http://127.0.0.1:11434/` for Ollama. | 29 | | API Key | `ollama` for local installs, or your cloud token (e.g. OpenAI). | 30 | | Model Name | Model to expose (default `qwen2.5-coder:3b`). PeerSky will pull it if not present. | 31 | | Logs button | Opens real-time download and generation logs. | 32 | 33 | > 💡 If PeerSky cannot reach Ollama it will show a one-time dialog with a shortcut to the Ollama installer. Change the base URL or API key to reset the warning. 34 | 35 | Install Ollama from https://ollama.com/download. 36 | 37 | ## Using cloud providers 38 | 39 | Local models are the default in PeerSky, but some developers/users still need access to fast or larger models during prototyping. For that reason, PeerSky also supports hosted providers. It simply gives app authors a way to plug in their own keys when they want more horsepower. 40 | 41 | ```json 42 | { 43 | "llm": { 44 | "enabled": true, 45 | "baseURL": "https://api.openai.com/v1/", 46 | "apiKey": "sk-...", 47 | "model": "gpt-4o-mini" 48 | } 49 | } 50 | ``` 51 | 52 | Cloud providers skip the download step but the rest of the API is identical. 53 | 54 | ## Examples of LLM usage in P2P apps 55 | ![AI Editor](./images/peersky-llm-editor.png) 56 | ![AI Chat](./images/peersky-llm-chat.png) 57 | 58 | ## Developer API 59 | 60 | PeerSky exposes the bridge through `window.llm` in P2P apps (see `src/pages/unified-preload.js`). All calls run in the renderer and forward to Electron via IPC. 61 | 62 | ### Detect support 63 | 64 | ```js 65 | if (await window.llm?.isSupported()) { 66 | // safe to use the API 67 | } else { 68 | alert('This experience requires the PeerSky LLM bridge.'); 69 | } 70 | ``` 71 | 72 | ### Chat completions (promise) 73 | 74 | ```js 75 | const messages = [ 76 | { role: 'system', content: 'You help authors polish blog posts.' }, 77 | { role: 'user', content: 'Improve this headline: Local-first Markdown workflows' } 78 | ]; 79 | 80 | const { role, content } = await window.llm.chat({ 81 | messages, 82 | temperature: 0.6, 83 | maxTokens: 512 84 | }); 85 | ``` 86 | 87 | ### Chat completions (streaming) 88 | 89 | ```js 90 | const output = document.querySelector('#draft'); 91 | const messages = [ 92 | { role: 'user', content: 'Generate a three paragraph summary of PeerSky.' } 93 | ]; 94 | 95 | for await (const chunk of window.llm.chat({ messages })) { 96 | const delta = chunk?.content || ''; 97 | output.textContent += delta; 98 | } 99 | ``` 100 | 101 | ### Text completion helper 102 | 103 | `window.llm.complete(prompt, options)` is a convenience wrapper that builds a single-message chat under the hood. 104 | 105 | ```js 106 | const tagline = await window.llm.complete('PeerSky is a browser that', { 107 | temperature: 0.7, 108 | maxTokens: 64 109 | }); 110 | ``` 111 | 112 | ## Security and trusted domains 113 | 114 | - `window.llm` is automatically exposed to PeerSky-native pages (`peersky://p2p/*`). 115 | - External origins must currently be on an allowlist (`localhost`, `agregore.mauve.moe`). This prevents arbitrary websites from silently running your local Ollama or burning cloud API credits. 116 | 117 | ## File reference 118 | 119 | | File | Purpose | 120 | |------|---------| 121 | | [src/llm.js](../src/llm.js) | Electron main-process bridge to Ollama/OpenAI with dialogs & downloads | 122 | | [src/pages/unified-preload.js](../src/pages/unified-preload.js) | Exposes `window.llm` to trusted pages via contextBridge | 123 | | [src/pages/settings.html](../src/pages/settings.html) | UI for LLM config | 124 | | [src/pages/static/js/settings.js](../src/pages/static/js/settings.js) | Renderer logic for saving LLM settings and tracking downloads | 125 | | [src/pages/p2p/](../src/pages/p2p/) | Examples of LLM API usage in the P2P apps like editor and ai chat | 126 | -------------------------------------------------------------------------------- /src/pages/vertical-tabs.js: -------------------------------------------------------------------------------- 1 | const BaseTabBar = customElements.get('tab-bar'); 2 | 3 | export default class VerticalTabs extends BaseTabBar { 4 | constructor() { 5 | super(); 6 | this.isExpanded = false; 7 | this.hoverTimeout = null; // Add timeout for hover delay 8 | this.leaveTimeout = null; // Add timeout for leave delay 9 | } 10 | 11 | buildTabBar() { 12 | super.buildTabBar(); 13 | this.classList.add('vertical-tabs'); 14 | if (this.tabContainer) { 15 | this.tabContainer.classList.add('vertical-tabs-container'); 16 | 17 | // Move add button to end so tabs appear before it 18 | this.addButton = this.tabContainer.querySelector('#add-tab'); 19 | this.ensureAddButtonPosition(); 20 | 21 | // Allow vertical scrolling with mouse wheel and stop horizontal handler 22 | this.tabContainer.addEventListener( 23 | 'wheel', 24 | (e) => { 25 | if (Math.abs(e.deltaY) > Math.abs(e.deltaX)) { 26 | e.stopImmediatePropagation(); 27 | // default vertical scrolling is preserved 28 | } 29 | }, 30 | { capture: true } 31 | ); 32 | 33 | // Update sticky state on scroll 34 | this.tabContainer.addEventListener('scroll', () => 35 | this.updateAddButtonSticky() 36 | ); 37 | } 38 | 39 | // Add hover event listeners with delays for smooth expansion 40 | this.addEventListener('mouseenter', () => { 41 | if (this.classList.contains('keep-expanded')) return; 42 | // Clear any pending leave timeout 43 | if (this.leaveTimeout) { 44 | clearTimeout(this.leaveTimeout); 45 | this.leaveTimeout = null; 46 | } 47 | 48 | // Set hover timeout for delayed expansion 49 | this.hoverTimeout = setTimeout(() => { 50 | this.isExpanded = true; 51 | this.classList.add('expanded'); 52 | }, 300); 53 | }); 54 | 55 | this.addEventListener('mouseleave', () => { 56 | if (this.classList.contains('keep-expanded')) return; 57 | // Clear any pending hover timeout 58 | if (this.hoverTimeout) { 59 | clearTimeout(this.hoverTimeout); 60 | this.hoverTimeout = null; 61 | } 62 | 63 | // Set leave timeout for delayed collapse 64 | this.leaveTimeout = setTimeout(() => { 65 | this.isExpanded = false; 66 | this.classList.remove('expanded'); 67 | }, 300); 68 | }); 69 | 70 | window.addEventListener('resize', () => this.updateAddButtonSticky()); 71 | // Ensure CSS is loaded before applying styles 72 | this.loadVerticalTabsCSS(); 73 | 74 | // Initial sticky state 75 | this.updateAddButtonSticky(); 76 | 77 | // On macOS, collapse the titlebar when vertical tabs are enabled 78 | if (process.platform === 'darwin') { 79 | this.collapseTitlebarOnDarwin(); 80 | } 81 | } 82 | 83 | collapseTitlebarOnDarwin() { 84 | const titlebar = document.querySelector('title-bar'); 85 | if (titlebar && typeof titlebar.toggleDarwinCollapse === 'function') { 86 | titlebar.toggleDarwinCollapse(true); 87 | } 88 | } 89 | 90 | loadVerticalTabsCSS() { 91 | // Check if CSS is already loaded 92 | if (document.querySelector('link[href*="vertical-tabs.css"]')) { 93 | return; 94 | } 95 | 96 | const link = document.createElement('link'); 97 | link.rel = 'stylesheet'; 98 | link.href = 'browser://theme/vertical-tabs.css'; 99 | link.onload = () => { 100 | // Force a reflow to apply styles 101 | this.style.display = 'none'; 102 | this.offsetHeight; // Trigger reflow 103 | this.style.display = ''; 104 | }; 105 | document.head.appendChild(link); 106 | } 107 | 108 | // Override tab creation to ensure proper favicon handling 109 | addTabWithId(tabId, url = "peersky://home", title = "Home") { 110 | const result = super.addTabWithId(tabId, url, title); 111 | 112 | this.ensureAddButtonPosition(); 113 | this.updateAddButtonSticky(); 114 | 115 | // Ensure favicon is properly sized for vertical tabs 116 | const tabElement = document.getElementById(tabId); 117 | if (tabElement) { 118 | const favicon = tabElement.querySelector('.tab-favicon'); 119 | if ((favicon && !favicon.style.backgroundImage) || favicon.style.backgroundImage === 'none') { 120 | favicon.style.backgroundImage = "url(peersky://static/assets/icon16.png)"; 121 | } 122 | } 123 | 124 | return result; 125 | } 126 | 127 | closeTab(tabId) { 128 | super.closeTab(tabId); 129 | this.ensureAddButtonPosition(); 130 | this.updateAddButtonSticky(); 131 | } 132 | 133 | ensureAddButtonPosition() { 134 | if (this.addButton && this.addButton.parentElement) { 135 | this.tabContainer.appendChild(this.addButton); 136 | } 137 | } 138 | 139 | updateAddButtonSticky() { 140 | if (!this.addButton) return; 141 | const shouldStick = 142 | this.tabContainer.scrollHeight > this.tabContainer.clientHeight; 143 | this.addButton.classList.toggle('sticky', shouldStick); 144 | } 145 | 146 | // Override hover behavior when keep-expanded is active 147 | updateKeepExpandedState(keepExpanded) { 148 | if (keepExpanded) { 149 | this.classList.add('keep-expanded'); 150 | // Clear any timeouts 151 | if (this.hoverTimeout) { 152 | clearTimeout(this.hoverTimeout); 153 | this.hoverTimeout = null; 154 | } 155 | if (this.leaveTimeout) { 156 | clearTimeout(this.leaveTimeout); 157 | this.leaveTimeout = null; 158 | } 159 | // Force expanded state 160 | this.isExpanded = true; 161 | this.classList.add('expanded'); 162 | } else { 163 | this.classList.remove('keep-expanded'); 164 | // Re-enable hover behavior 165 | this.isExpanded = false; 166 | this.classList.remove('expanded'); 167 | } 168 | } 169 | } 170 | 171 | customElements.define('vertical-tabs', VerticalTabs); -------------------------------------------------------------------------------- /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/editor/styles.css: -------------------------------------------------------------------------------- 1 | @import url("browser://theme/index.css"); 2 | 3 | :root { 4 | --gap: 5px; 5 | --half-gap: calc(var(--gap) / 2); 6 | } 7 | 8 | body, * { 9 | padding: 0; 10 | margin: 0; 11 | font-family: var(--browser-theme-font-family); 12 | background: var(--browser-theme-background); 13 | color: var(--browser-theme-text-color); 14 | box-sizing: border-box; 15 | } 16 | 17 | main { 18 | padding: var(--gap); 19 | height: 100vh; 20 | display: flex; 21 | flex-direction: column; 22 | background: var(--browser-theme-background); 23 | color: var(--browser-theme-text-color); 24 | } 25 | 26 | .grid-container { 27 | display: grid; 28 | grid-template-columns: 1fr 1fr; /* Two columns */ 29 | grid-template-rows: 1fr 1fr; /* Two rows */ 30 | height: 95vh; 31 | padding: var(--half-gap); 32 | row-gap: var(--gap); 33 | column-gap: var(--gap); 34 | } 35 | 36 | .grid-container > * { 37 | padding: var(--gap); 38 | width: 100%; 39 | height: 100%; 40 | overflow: auto; /* To handle content overflow */ 41 | border: 1px solid var(--browser-theme-primary-highlight); 42 | border-radius: 8px; 43 | } 44 | 45 | .grid-container > textarea { 46 | resize: none; 47 | font-size: 1.2rem; 48 | } 49 | 50 | #viewer { 51 | color: var(--browser-theme-text-color); 52 | } 53 | 54 | div textarea:focus { 55 | outline: 2px solid var(--browser-theme-text-color); 56 | color: var(--browser-theme-text-color); 57 | } 58 | 59 | #dweb-container, 60 | #uploadListBox { 61 | display: flex; 62 | flex-direction: row; 63 | justify-content: space-between; 64 | align-items: center; 65 | padding: 0 var(--half-gap); 66 | } 67 | 68 | #dweb-container > * { 69 | width: 100%; 70 | display:flex; 71 | flex-direction:row; 72 | align-items: flex-end; 73 | gap: var(--half-gap); 74 | padding: var(--half-gap); 75 | } 76 | 77 | #uploadListBox { 78 | flex-direction: column; 79 | align-items: flex-start; 80 | } 81 | 82 | li { 83 | display: flex; 84 | align-items: flex-end; 85 | } 86 | 87 | #loadingSpinner, #backdrop { 88 | display: none; 89 | } 90 | 91 | #loadingSpinner, .emoji-loader { 92 | background: transparent; 93 | } 94 | 95 | #loadingSpinner { 96 | position: absolute; 97 | top: 50%; 98 | left: 50%; 99 | transform: translate(-50%, -50%); 100 | z-index: 1000; 101 | border-radius: 50%; 102 | } 103 | 104 | @keyframes spin { 105 | from { transform: rotate(0deg); } 106 | to { transform: rotate(360deg); } 107 | } 108 | 109 | .emoji-loader { 110 | color: var(--ag-color-green); 111 | font-size: 10rem; 112 | border-radius: 50%; 113 | animation: spin 2s linear infinite; 114 | } 115 | 116 | #backdrop { 117 | position: fixed; 118 | top: 0; 119 | left: 0; 120 | width: 100%; 121 | height: 100%; 122 | background: rgba(0, 0, 0, 0.5); 123 | z-index: 999; 124 | } 125 | 126 | #uploadButton { 127 | white-space: nowrap; 128 | cursor: pointer; 129 | } 130 | 131 | #uploadButton:hover { 132 | opacity: 0.8; 133 | } 134 | 135 | #fetchContainer { 136 | justify-content: flex-end; 137 | align-items: center; 138 | } 139 | 140 | #fetchCidInput { 141 | margin-right: 2px; 142 | } 143 | 144 | #fetchButton{ 145 | cursor: pointer; 146 | } 147 | 148 | #fetchButton:hover { 149 | opacity: 0.8; 150 | } 151 | 152 | span { 153 | pointer-events: cursor; 154 | color: var(--browser-theme-text-color); 155 | } 156 | 157 | span:hover { 158 | color: var(--browser-theme-primary-highlight); 159 | } 160 | 161 | /* AI Generation Section */ 162 | #ai-toggle-container { 163 | display: flex; 164 | justify-content: center; 165 | padding: var(--half-gap); 166 | } 167 | 168 | #toggleAiButton { 169 | white-space: nowrap; 170 | padding: var(--half-gap); 171 | cursor: pointer; 172 | } 173 | 174 | #toggleAiButton:hover { 175 | opacity: 0.8; 176 | } 177 | 178 | #ai-container { 179 | display: flex; 180 | flex-direction: column; 181 | gap: var(--half-gap); 182 | padding: var(--gap); 183 | border: 1px solid var(--browser-theme-primary-highlight); 184 | border-radius: 8px; 185 | margin: var(--half-gap); 186 | background: var(--browser-theme-background); 187 | } 188 | 189 | #ai-container.hidden { 190 | display: none; 191 | } 192 | 193 | #aiPromptBox { 194 | width: 100%; 195 | padding: var(--gap); 196 | background: rgba(255, 255, 255, 0.05); 197 | color: var(--browser-theme-text-color); 198 | border: 1px solid var(--browser-theme-primary-highlight); 199 | border-radius: 8px; 200 | font-size: 1rem; 201 | resize: vertical; 202 | font-family: var(--browser-theme-font-family); 203 | } 204 | 205 | #aiPromptBox:focus { 206 | outline: 2px solid var(--browser-theme-primary-highlight); 207 | } 208 | 209 | #ai-buttons { 210 | display: flex; 211 | justify-content: flex-start; 212 | } 213 | 214 | #ai-buttons button { 215 | white-space: nowrap; 216 | padding: var(--half-gap); 217 | cursor: pointer; 218 | } 219 | 220 | #ai-buttons button:hover { 221 | opacity: 0.8; 222 | } 223 | 224 | /* AI Logs Dialog */ 225 | #aiLogDialog { 226 | white-space: nowrap; 227 | } 228 | 229 | #aiLogDialog::backdrop { 230 | background: rgba(0, 0, 0, 0.7); 231 | } 232 | 233 | #aiLogs { 234 | margin-bottom: var(--gap); 235 | } 236 | 237 | #aiLogs dt { 238 | font-weight: bold; 239 | color: var(--browser-theme-primary-highlight); 240 | margin-top: var(--gap); 241 | } 242 | 243 | #aiLogs dd { 244 | white-space: pre-wrap; 245 | margin-left: var(--gap); 246 | margin-bottom: var(--half-gap); 247 | padding: var(--half-gap); 248 | background: rgba(255, 255, 255, 0.05); 249 | border-radius: 4px; 250 | } 251 | 252 | #closeAiLog { 253 | position: sticky; 254 | bottom: 0; 255 | width: 100%; 256 | white-space: nowrap; 257 | cursor: pointer; 258 | font-size: 1rem; 259 | } 260 | 261 | #closeAiLog:hover { 262 | opacity: 0.8; 263 | } 264 | 265 | /* Media query for mobile devices */ 266 | @media screen and (max-width: 768px) { 267 | #dweb-container, 268 | #dweb-container > * { 269 | flex-direction: column; 270 | align-items: flex-start; 271 | } 272 | .grid-container { 273 | grid-template-columns: 1fr; /* One column */ 274 | grid-template-rows: repeat(4, 1fr); /* Four rows */ 275 | } 276 | 277 | #aiLogDialog { 278 | min-width: 90vw; 279 | } 280 | 281 | #ai-buttons { 282 | flex-direction: column; 283 | } 284 | } -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | import { BUILTIN_SEARCH_ENGINES } from './search-engine.js'; 2 | // P2P prefixes 3 | const IPFS_PREFIX = 'ipfs://'; 4 | const IPNS_PREFIX = 'ipns://'; 5 | const HYPER_PREFIX = 'hyper://'; 6 | const WEB3_PREFIX = 'web3://'; 7 | 8 | // Utility functions 9 | function isURL(string) { 10 | try { 11 | new URL(string); 12 | return true; 13 | } catch { 14 | return false; 15 | } 16 | } 17 | 18 | function looksLikeDomain(string) { 19 | return !string.match(/\s/) && string.includes('.'); 20 | } 21 | 22 | function isBareLocalhost(string) { 23 | return string.match(/^localhost(:[0-9]+)?\/?$/); 24 | } 25 | 26 | function makeHttp(query) { 27 | return `http://${query}`; 28 | } 29 | 30 | function makeHttps(query) { 31 | return `https://${query}`; 32 | } 33 | 34 | function makeSearch(query, engine = 'duckduckgo') { 35 | const template = BUILTIN_SEARCH_ENGINES[engine] || BUILTIN_SEARCH_ENGINES.duckduckgo; 36 | return template.replace("%s", encodeURIComponent(query)); 37 | } 38 | 39 | const PLACEHOLDER_RE = /%s|\{searchTerms\}|\$1/; 40 | const KNOWN_QUERY_KEYS = ["q","query","p","search","s","term","keywords","k","wd","text"]; 41 | 42 | function buildSearchUrl(template, term) { 43 | const encoded = encodeURIComponent(term); 44 | 45 | // (1) Replace placeholder if present 46 | if (PLACEHOLDER_RE.test(template)) { 47 | return template.replace(PLACEHOLDER_RE, encoded); 48 | } 49 | 50 | // (2) Structural fallback via URL parsing 51 | let url; 52 | try { 53 | url = new URL(template); 54 | } catch { 55 | // Fallback if invalid URL 56 | return makeSearch(term, 'duckduckgo'); 57 | } 58 | 59 | // (a) Fill first empty param, e.g. ?q= 60 | for (const [key, value] of url.searchParams.entries()) { 61 | if (value === "") { 62 | url.searchParams.set(key, term); 63 | return url.toString(); 64 | } 65 | } 66 | 67 | // (b) Overwrite known search-like key if present 68 | for (const key of KNOWN_QUERY_KEYS) { 69 | if (url.searchParams.has(key)) { 70 | url.searchParams.set(key, term); 71 | return url.toString(); 72 | } 73 | } 74 | 75 | // (c) Append ?q= if nothing matched 76 | url.searchParams.set("q", term); 77 | return url.toString(); 78 | } 79 | 80 | async function handleURL(rawURL) { 81 | if (rawURL.endsWith('.eth')) { 82 | if (rawURL.startsWith(IPFS_PREFIX) || rawURL.startsWith(IPNS_PREFIX)) { 83 | return rawURL; 84 | } 85 | // ENS names are mutable and should be resolved via IPNS. 86 | return `${IPNS_PREFIX}${rawURL}`; 87 | } else if ( 88 | rawURL.startsWith(IPFS_PREFIX) || 89 | rawURL.startsWith(IPNS_PREFIX) || 90 | rawURL.startsWith(HYPER_PREFIX) || 91 | rawURL.startsWith(WEB3_PREFIX) 92 | ) { 93 | return rawURL; 94 | } else if (isURL(rawURL)) { 95 | return rawURL; 96 | } else if (isBareLocalhost(rawURL)) { 97 | return makeHttp(rawURL); 98 | } else if (looksLikeDomain(rawURL)) { 99 | return makeHttps(rawURL); 100 | } else { 101 | // For search queries, try to get user's preferred search engine 102 | try { 103 | const { ipcRenderer } = require('electron'); 104 | const searchEngine = await ipcRenderer.invoke('settings-get', 'searchEngine'); 105 | 106 | 107 | if (searchEngine === 'custom') { 108 | const customTemplate = await ipcRenderer.invoke('settings-get', 'customSearchTemplate'); 109 | if (typeof customTemplate === 'string' && customTemplate.length) { 110 | return buildSearchUrl(customTemplate, rawURL); 111 | } 112 | console.warn('Custom search template missing or invalid, falling back to DuckDuckGo'); 113 | return makeSearch(rawURL, 'duckduckgo'); 114 | } 115 | 116 | return makeSearch(rawURL, searchEngine); 117 | } catch (error) { 118 | console.warn('Could not get search engine setting, using DuckDuckGo:', error); 119 | return makeSearch(rawURL, 'duckduckgo'); 120 | } 121 | } 122 | } 123 | 124 | // Security utilities for preventing XSS attacks 125 | 126 | /** 127 | * Escapes HTML special characters to prevent XSS 128 | * @param {string} text - The text to escape 129 | * @returns {string} - The escaped text 130 | */ 131 | function escapeHtml(text) { 132 | if (typeof text !== 'string') { 133 | return String(text); 134 | } 135 | 136 | const div = document.createElement('div'); 137 | div.textContent = text; 138 | return div.innerHTML; 139 | } 140 | 141 | /** 142 | * Escapes HTML attributes to prevent XSS in attribute values 143 | * @param {string} text - The text to escape 144 | * @returns {string} - The escaped text 145 | */ 146 | function escapeHtmlAttribute(text) { 147 | if (typeof text !== 'string') { 148 | return String(text); 149 | } 150 | 151 | return text 152 | .replace(/&/g, '&') 153 | .replace(/"/g, '"') 154 | .replace(/'/g, ''') 155 | .replace(//g, '>'); 157 | } 158 | 159 | /** 160 | * Safely sets innerHTML with escaped user content 161 | * @param {HTMLElement} element - The element to set innerHTML on 162 | * @param {string} htmlTemplate - HTML template with placeholders 163 | * @param {Object} data - Data to interpolate (will be escaped) 164 | */ 165 | function safeSetInnerHTML(element, htmlTemplate, data = {}) { 166 | let safeHtml = htmlTemplate; 167 | 168 | // Replace placeholders with escaped data 169 | for (const [key, value] of Object.entries(data)) { 170 | const placeholder = new RegExp(`\\$\\{${key}\\}`, 'g'); 171 | safeHtml = safeHtml.replace(placeholder, escapeHtml(value)); 172 | 173 | const attrPlaceholder = new RegExp(`\\$\\{${key}:attr\\}`, 'g'); 174 | safeHtml = safeHtml.replace(attrPlaceholder, escapeHtmlAttribute(value)); 175 | } 176 | 177 | element.innerHTML = safeHtml; 178 | } 179 | 180 | /** 181 | * Creates a safe template literal function for HTML 182 | * @param {Array} strings - Template literal strings 183 | * @param {...any} values - Template literal values 184 | * @returns {string} - Safe HTML string 185 | */ 186 | function html(strings, ...values) { 187 | let result = strings[0]; 188 | 189 | for (let i = 0; i < values.length; i++) { 190 | result += escapeHtml(values[i]) + strings[i + 1]; 191 | } 192 | 193 | return result; 194 | } 195 | 196 | export { 197 | IPFS_PREFIX, 198 | IPNS_PREFIX, 199 | HYPER_PREFIX, 200 | WEB3_PREFIX, 201 | isURL, 202 | looksLikeDomain, 203 | isBareLocalhost, 204 | makeHttp, 205 | makeHttps, 206 | makeSearch, 207 | buildSearchUrl, 208 | handleURL, 209 | escapeHtml, 210 | escapeHtmlAttribute, 211 | safeSetInnerHTML, 212 | html, 213 | }; 214 | -------------------------------------------------------------------------------- /src/pages/p2p/ai-chat/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | LLM Chat 7 | 64 | 65 | 66 | 73 | 74 |
75 |
76 | 77 | 78 | 79 |
80 |
81 | What is this? 82 |

83 | This is a chat application for using PeerSky's built in Local AI 84 | capabilities. 85 |

86 |

87 | This feature was inspired in part by the excellent work in the 88 | Agregore Browser, which helped shape how simple and flexible local-first integrations 94 | can be. 95 |

96 |

97 | Running models locally means your prompts stay on your device, 98 | everything works offline, and P2P apps can publish generated content 99 | directly without routing through surveillance platforms. 100 |

101 |

102 | Read more in our 103 | Docs. 109 |

110 |
111 | 186 | 187 | 188 | -------------------------------------------------------------------------------- /src/pages/static/js/tabs.js: -------------------------------------------------------------------------------- 1 | class TabsBox extends HTMLElement { 2 | constructor() { 3 | super(); 4 | this.attachShadow({ mode: 'open' }); 5 | this.render(); 6 | this.loadTabs(); 7 | 8 | // Listen for group property updates 9 | if (window.electronAPI && window.electronAPI.onGroupPropertiesUpdated) { 10 | window.electronAPI.onGroupPropertiesUpdated((groupId, properties) => { 11 | console.log('Tabs page received group update:', groupId, properties); 12 | // Refresh the tabs display 13 | setTimeout(() => this.loadTabs(), 100); 14 | }); 15 | } 16 | } 17 | 18 | async loadTabs() { 19 | try { 20 | if (!window.electronAPI || !window.electronAPI.getTabs) { 21 | throw new Error('electronAPI not available'); 22 | } 23 | const data = await window.electronAPI.getTabs(); 24 | this.displayTabs(data); 25 | } catch (e) { 26 | console.error('Failed to load tabs', e); 27 | this.displayTabs(null); 28 | } 29 | } 30 | 31 | displayTabs(tabsData) { 32 | const container = this.shadowRoot.querySelector('.tabs-container'); 33 | container.innerHTML = '

Tab Groups

'; 34 | 35 | if (!tabsData) { 36 | container.innerHTML += '

No tabs groups found.

'; 37 | return; 38 | } 39 | 40 | // The data structure is: { windowId: { activeTabId, tabCounter, tabGroups: [...], tabs: [...] } } 41 | const windows = Object.entries(tabsData); 42 | 43 | if (windows.length === 0) { 44 | container.innerHTML += '

No windows found.

'; 45 | return; 46 | } 47 | 48 | // Collect all tab groups from all windows 49 | const allGroups = new Map(); 50 | const allTabs = []; 51 | 52 | windows.forEach(([windowId, windowData]) => { 53 | if (!windowData.tabs || !Array.isArray(windowData.tabs)) { 54 | return; 55 | } 56 | 57 | // Collect tab groups from this window 58 | if (Array.isArray(windowData.tabGroups)) { 59 | windowData.tabGroups.forEach(group => { 60 | if (!allGroups.has(group.id)) { 61 | allGroups.set(group.id, { ...group, tabs: [] }); 62 | } 63 | }); 64 | } 65 | 66 | // Collect all tabs and mark them with window info 67 | windowData.tabs.forEach(tab => { 68 | const tabWithWindow = { ...tab, windowId, windowIndex: windows.findIndex(([id]) => id === windowId) + 1 }; 69 | allTabs.push(tabWithWindow); 70 | 71 | if (tab.groupId && allGroups.has(tab.groupId)) { 72 | allGroups.get(tab.groupId).tabs.push(tabWithWindow); 73 | } 74 | }); 75 | }); 76 | 77 | // Display grouped tabs (cross-window groups) 78 | allGroups.forEach(group => { 79 | if (group.tabs.length === 0) return; 80 | 81 | const groupEl = document.createElement('div'); 82 | groupEl.className = 'group'; 83 | 84 | // Helper function to escape HTML 85 | function escapeHtml(text) { 86 | const div = document.createElement('div'); 87 | div.textContent = text; 88 | return div.innerHTML; 89 | } 90 | 91 | const header = document.createElement('div'); 92 | header.className = 'group-header'; 93 | header.style.backgroundColor = group.color || '#ccc'; 94 | header.innerHTML = ` 95 |
${escapeHtml(group.name || 'Unnamed group')} (${group.tabs.length} tabs across ${new Set(group.tabs.map(t => t.windowId)).size} windows)
96 |
97 | 98 | 99 | 100 | 101 | 102 |
`; 103 | groupEl.appendChild(header); 104 | 105 | const tabsWrap = document.createElement('div'); 106 | tabsWrap.className = 'group-tabs'; 107 | if (!group.expanded) tabsWrap.style.display = 'none'; 108 | 109 | group.tabs.forEach(tab => { 110 | const item = document.createElement('div'); 111 | item.className = 'tab-item'; 112 | item.dataset.tabId = tab.id; 113 | 114 | item.innerHTML = ` 115 | ${tab.title || tab.url} 116 | Window ${tab.windowIndex} 117 |
118 | 119 |
`; 120 | tabsWrap.appendChild(item); 121 | }); 122 | 123 | groupEl.appendChild(tabsWrap); 124 | container.appendChild(groupEl); 125 | }); 126 | 127 | // Show message if no tabs found 128 | if (container.children.length === 1) { // Only the h1 title 129 | container.innerHTML += '

No tabs found.

'; 130 | } 131 | 132 | this.attachListeners(); 133 | this.attachGroupListeners(); 134 | } 135 | 136 | attachListeners() { 137 | // Handle close button clicks 138 | this.shadowRoot.querySelectorAll('.close-btn').forEach(btn => { 139 | btn.addEventListener('click', (e) => { 140 | e.stopPropagation(); 141 | const id = e.target.dataset.id; 142 | if (window.electronAPI && window.electronAPI.closeTab) { 143 | window.electronAPI.closeTab(id); 144 | } 145 | setTimeout(() => this.loadTabs(), 200); 146 | }); 147 | }); 148 | 149 | // Handle tab item clicks for switching tabs 150 | this.shadowRoot.querySelectorAll('.tab-item').forEach(item => { 151 | item.addEventListener('click', (e) => { 152 | if (e.target.classList.contains('close-btn')) return; 153 | 154 | const tabId = item.dataset.tabId; 155 | if (window.electronAPI && window.electronAPI.activateTab) { 156 | window.electronAPI.activateTab(tabId); 157 | } 158 | }); 159 | }); 160 | } 161 | 162 | attachGroupListeners() { 163 | this.shadowRoot.querySelectorAll('.group-actions button').forEach(btn => { 164 | btn.addEventListener('click', (e) => { 165 | const groupId = e.target.dataset.id; 166 | const action = e.target.dataset.action; 167 | if (window.electronAPI && window.electronAPI.groupAction) { 168 | window.electronAPI.groupAction(action, groupId); 169 | } 170 | setTimeout(() => this.loadTabs(), 200); 171 | }); 172 | }); 173 | } 174 | 175 | render() { 176 | const link = document.createElement('link'); 177 | link.rel = 'stylesheet'; 178 | link.href = 'browser://theme/tabs.css'; 179 | 180 | const container = document.createElement('div'); 181 | container.className = 'tabs-container'; 182 | container.innerHTML = '

Tabs

'; 183 | 184 | this.shadowRoot.innerHTML = ''; 185 | this.shadowRoot.appendChild(link); 186 | this.shadowRoot.appendChild(container); 187 | } 188 | } 189 | customElements.define('tabs-box', TabsBox); -------------------------------------------------------------------------------- /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/p2p/upload/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Upload 6 | 44 |
45 | 54 | 55 |
56 | 57 | 58 | 59 | 60 |
61 |
    62 |
    63 | -------------------------------------------------------------------------------- /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 | Ask DeepWiki 14 | A demo gif of the PeerSky P2P Editor showing HTML, CSS, and JavaScript panels, a live preview of a blue page with red ‘Spider-Man’ text, and AI code-generation controls 15 |
    16 | 17 | 💻 [Download](https://peersky.p2plabs.xyz/) 18 | 19 | ## Roadmap 20 | 21 | - [x] Basic browser navigation: 22 | 23 | - [x] Back 24 | - [x] Forward 25 | - [x] Reload 26 | - [x] Browser protocol (peersky://) 27 | - [x] Home page (peersky://home) 28 | - [x] Cross browser themeing ([browser://theme/](https://github.com/p2plabsxyz/peersky-browser/blob/main/docs/Theme.md)) 29 | - [x] Search engine 30 | - DuckDuckGo (default) 31 | - Brave Search 32 | - Ecosia 33 | - Kagi 34 | - Startpage 35 | - [x] Tabs 36 | - Vertical tabs toggle 37 | 38 | - [x] IPFS protocol handler: 39 | 40 | - [x] Run a local [Helia](https://helia.io/) node 41 | - [x] `ipfs://` / `ipns://` native URLs support 42 | - [x] Directory listings support 43 | - [x] Native ENS domain resolution: 44 | - [x] Resolve `.eth` domains directly to IPFS/IPNS content without centralized gateways (e.g., `ipfs://vitalik.eth`). 45 | - [x] Local caching for resolved ENS content to enhance performance and reduce RPC calls. 46 | 47 | - [x] Hypercore protocol handler: 48 | 49 | - [x] Run a local [hyper](https://holepunch.to/) node 50 | - [x] `hyper://` native URLs support 51 | 52 | - [x] Local `file://` browsing with P2P publishing: 53 | - [x] Custom `file://` support with privileged access 54 | - [x] Directory listings (Chrome-style) 55 | - [x] One-click P2P publishing to: 56 | - [x] IPFS (`ipfs://`) 57 | - [x] Hypercore (`hyper://`) 58 | 59 | - [x] Web3 protocol handler: 60 | 61 | - [x] Run a local [web3 protocol](https://docs.web3url.io/) node 62 | - [x] Access on-chain websites. 63 | - [x] Fetch data from smart contracts using auto, manual, and resource request resolve modes. 64 | - [x] Query account balances or other data directly from smart contracts. 65 | 66 | - [x] P2P Applications: 67 | 68 | - [x] `peersky://p2p/chat/` 69 | - Peer-to-peer messaging over Hyper 70 | - [x] `peersky://p2p/upload/` 71 | - Decentralized file storage 72 | - [x] `peersky://p2p/editor/` 73 | - Build and publish websites 74 | - [x] `peersky://p2p/wiki/` 75 | - Browse Wikipedia over IPFS 76 | - [x] [reader.p2plabs.xyz](https://reader.distributed.press/) 77 | - A p2p offline ActivityPub client for reading and following microblogs on the fediverse. 78 | 79 | - [x] Electron’s Auto-updater: 80 | 81 | - [x] Download and install the latest release from Github automatically 82 | 83 | - [x] Context menu: 84 | 85 | - [x] Back / Forward 86 | - [x] Reload 87 | - [x] Inspect 88 | - [x] Undo / Redo 89 | - [x] Cut / Copy / Paste 90 | - [x] Copy Link Address 91 | - [x] Open Link in New Tab 92 | 93 | - [x] Find in page: 94 | - [x] Search for text within a document or web page 95 | 96 | - [x] Window state persistence: 97 | - [x] Save and restore open windows on app launch 98 | 99 | - [x] Keyboard shortcuts: 100 | 101 | - [x] New Window: `CommandOrControl+N` 102 | - [x] Back: `CommandOrControl+[` 103 | - [x] Forward: `CommandOrControl+]` 104 | - [x] Reload: `CommandOrControl+R` 105 | - [x] Find in Page: `CommandOrControl+F` 106 | - [x] Open Dev Tools: `CommandOrControl+Shift+I` 107 | - [x] Focus URL Bar: `CommandOrControl+L` 108 | - [x] Minimize: `CommandOrControl+M` 109 | - [x] Close: `CommandOrControl+W` 110 | - [x] Toggle Full Screen: `F11` 111 | 112 | - [x] Settings (peersky://settings): 113 | 114 | - [x] Switch search engines 115 | - [x] Set custom home page wallpapers 116 | - [x] Hide/show the home page clock 117 | - [x] Change themes 118 | - [x] Clear browser cache 119 | 120 | - [x] [Local LLM](https://github.com/p2plabsxyz/peersky-browser/blob/main/docs/LLM.md) integration for P2P apps: 121 | - [x] `window.llm` APIs (chat + streaming, complete) 122 | - [x] Trusted-domain exposure (PeerSky-native + allowlist) 123 | - [x] AI Chat app (peersky://p2p/ai-chat/) 124 | - [x] Ported from [Agregore examples](https://github.com/AgregoreWeb/website/blob/main/docs/examples/llm-chat.html) with PeerSky updates 125 | - [x] P2P Editor integration (peersky://p2p/editor/) 126 | - [x] New AI generator (`ai-generator.js`) to generate code with AI 127 | 128 | - [ ] 🚧 [LLM Memory](https://github.com/p2plabsxyz/peersky-browser/issues/97) 129 | - [ ] `llm.json` to store prompts/responses across P2P apps 130 | - [ ] Reusable History component (P2P editor, AI chat, etc.) 131 | - [ ] Settings toggle to enable/disable memory 132 | - [ ] “Reset P2P Data” also clears `llm.json` 133 | 134 | - [ ] 🚧 [Web extensions](https://github.com/p2plabsxyz/peersky-browser/issues/19): 135 | - [ ] Ability to add and manage extensions 136 | - [ ] [Default extensions](https://github.com/p2plabsxyz/essential-chromium-extensions) 137 | - [ ] [Decentralized Extension Distribution](https://github.com/p2plabsxyz/peersky-browser/issues/42) 138 | 139 | - [x] Bookmarks (peersky://bookmarks): 140 | 141 | - [x] Option to add favourite pages in the nav bar (peersky://bookmarks) 142 | 143 | - [x] QR Code generator: 144 | 145 | - [x] Option to generate QR Code for every page in the URL prompt with [plan1](./docs/Plan1.md). 146 | 147 | - [ ] Archive (peersky://archive): 148 | 149 | - [ ] List and showcase published content from `peersky://p2p/` apps for enhanced discoverability. 150 | - [ ] Provide metadata (e.g., creation date, content type) to improve navigation and usability. 151 | - [ ] Ability to download all the hashes of published data in a .json file. 152 | 153 | ## Development 154 | 155 | ### Node.js and npm Setup 156 | 157 | 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`. 158 | 159 | - **npm**: Comes bundled with Node.js. Verify installation by running: 160 | ```bash 161 | node -v 162 | npm -v 163 | ``` 164 | 165 | ### Install dependencies 166 | 167 | ```bash 168 | npm install 169 | ``` 170 | 171 | ### Start the app 172 | 173 | ```bash 174 | npm start 175 | ``` 176 | 177 | ### Build 178 | After development of the browser, run the following command. This will create a production build. 179 | 180 | ```bash 181 | npm run build 182 | # For Intel and Silicon macs 183 | ``` 184 | 185 | ```bash 186 | npm run build-all 187 | # For macOS, Linux, and Windows 188 | ``` 189 | 190 | Now, the `dist` folder will appear in the root directory. 191 | 192 | ## Contribute 193 | 194 | - Thanks for your interest in contributing to PeerSky Browser. There are many ways you can contribute to the project. 195 | - To start, take a few minutes to read the "[contribution guide](https://github.com/p2plabsxyz/peersky-browser/blob/main/.github/CONTRIBUTING.md)". 196 | - 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). 197 | 198 | ## License 199 | 200 | PeerSky Browser is licensed under the [MIT License](https://github.com/p2plabsxyz/peersky-browser/blob/main/LICENSE). 201 | -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | import { app, session, protocol as globalProtocol, ipcMain, BrowserWindow,Menu,shell,dialog, webContents} 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 { createHandler as createFileHandler } from "./protocols/file-handler.js"; 8 | import { ipfsOptions, hyperOptions } from "./protocols/config.js"; 9 | import { createMenuTemplate } from "./actions.js"; 10 | import WindowManager, { createIsolatedWindow } from "./window-manager.js"; 11 | import settingsManager from "./settings-manager.js"; 12 | import { attachContextMenus, setWindowManager } from "./context-menu.js"; 13 | import { isBuiltInSearchEngine } from "./search-engine.js"; 14 | import "./llm.js"; 15 | // import { setupAutoUpdater } from "./auto-updater.js"; 16 | 17 | const P2P_PROTOCOL = { 18 | standard: true, 19 | secure: true, 20 | allowServiceWorkers: true, 21 | supportFetchAPI: true, 22 | bypassCSP: false, 23 | corsEnabled: true, 24 | stream: true, 25 | }; 26 | 27 | const BROWSER_PROTOCOL = { 28 | standard: false, 29 | secure: true, 30 | allowServiceWorkers: false, 31 | supportFetchAPI: true, 32 | bypassCSP: false, 33 | corsEnabled: true, 34 | }; 35 | 36 | const FILE_PROTOCOL = { 37 | standard: true, 38 | secure: false, 39 | allowServiceWorkers: false, 40 | supportFetchAPI: true, 41 | bypassCSP: true, 42 | corsEnabled: true, 43 | stream: true, 44 | }; 45 | 46 | let windowManager; 47 | 48 | globalProtocol.registerSchemesAsPrivileged([ 49 | { scheme: "peersky", privileges: BROWSER_PROTOCOL }, 50 | { scheme: "browser", privileges: BROWSER_PROTOCOL }, 51 | { scheme: "ipfs", privileges: P2P_PROTOCOL }, 52 | { scheme: "ipns", privileges: P2P_PROTOCOL }, 53 | { scheme: "pubsub", privileges: P2P_PROTOCOL }, 54 | { scheme: "hyper", privileges: P2P_PROTOCOL }, 55 | { scheme: "web3", privileges: P2P_PROTOCOL }, 56 | { scheme: "file", privileges: FILE_PROTOCOL }, 57 | ]); 58 | 59 | app.whenReady().then(async () => { 60 | windowManager = new WindowManager(); 61 | 62 | // Set the WindowManager instance in context-menu.js 63 | setWindowManager(windowManager); 64 | await setupProtocols(session.defaultSession); 65 | 66 | // Load saved windows or open a new one 67 | await windowManager.openSavedWindows(); 68 | if (windowManager.all.length === 0) { 69 | windowManager.open({ isMainWindow: true }); 70 | } 71 | 72 | // Register shortcuts from menu template (NOTE: all these shortcuts works on a window only if a window is in focus) 73 | const menuTemplate = createMenuTemplate(windowManager); 74 | const menu = Menu.buildFromTemplate(menuTemplate); 75 | Menu.setApplicationMenu(menu); 76 | 77 | windowManager.startSaver(); 78 | 79 | // Initialize AutoUpdater after windowManager is ready 80 | // console.log("App is prepared, setting up AutoUpdater..."); 81 | // setupAutoUpdater(); 82 | }); 83 | 84 | // Introduce a flag to prevent multiple 'before-quit' handling 85 | let isQuitting = false; 86 | 87 | 88 | async function setupProtocols(session) { 89 | const { protocol: sessionProtocol } = session; 90 | 91 | app.setAsDefaultProtocolClient("peersky"); 92 | app.setAsDefaultProtocolClient("file"); 93 | app.setAsDefaultProtocolClient("browser"); 94 | app.setAsDefaultProtocolClient("ipfs"); 95 | app.setAsDefaultProtocolClient("ipns"); 96 | app.setAsDefaultProtocolClient("hyper"); 97 | app.setAsDefaultProtocolClient("web3"); 98 | 99 | const browserProtocolHandler = await createBrowserHandler(); 100 | sessionProtocol.registerStreamProtocol("peersky", browserProtocolHandler, BROWSER_PROTOCOL); 101 | 102 | const browserThemeHandler = await createBrowserThemeHandler(); 103 | sessionProtocol.registerStreamProtocol("browser", browserThemeHandler, BROWSER_PROTOCOL); 104 | 105 | const ipfsProtocolHandler = await createIPFSHandler(ipfsOptions, session); 106 | sessionProtocol.registerStreamProtocol("ipfs", ipfsProtocolHandler, P2P_PROTOCOL); 107 | sessionProtocol.registerStreamProtocol("ipns", ipfsProtocolHandler, P2P_PROTOCOL); 108 | sessionProtocol.registerStreamProtocol("pubsub", ipfsProtocolHandler, P2P_PROTOCOL); 109 | 110 | const hyperProtocolHandler = await createHyperHandler(hyperOptions, session); 111 | sessionProtocol.registerStreamProtocol("hyper", hyperProtocolHandler, P2P_PROTOCOL); 112 | 113 | const web3ProtocolHandler = await createWeb3Handler(); 114 | sessionProtocol.registerStreamProtocol("web3", web3ProtocolHandler, P2P_PROTOCOL); 115 | 116 | const fileProtocolHandler = await createFileHandler(); 117 | sessionProtocol.handle("file", fileProtocolHandler); 118 | } 119 | 120 | app.on("window-all-closed", () => { 121 | if (process.platform !== "darwin") { 122 | app.quit(); 123 | } 124 | }); 125 | 126 | app.on("activate", () => { 127 | if (windowManager.all.length === 0) { 128 | windowManager.open({ isMainWindow: true }); 129 | } 130 | }); 131 | 132 | ipcMain.on("window-control", (event, command) => { 133 | const window = BrowserWindow.fromWebContents(event.sender); 134 | if (!window) return; 135 | 136 | switch (command) { 137 | case "minimize": 138 | window.minimize(); 139 | break; 140 | case "maximize": 141 | if (window.isMaximized()) { 142 | window.unmaximize(); 143 | } else { 144 | window.maximize(); 145 | } 146 | break; 147 | case "close": 148 | window.close(); 149 | break; 150 | } 151 | }); 152 | 153 | // IPC handler for moving tabs to new window 154 | ipcMain.on('new-window-with-tab', (event, tabData) => { 155 | // Create new window using WindowManager for proper persistence 156 | windowManager.open({ 157 | url: tabData.url, 158 | newWindow: true, 159 | isolate: true, 160 | singleTab: { 161 | url: tabData.url, 162 | title: tabData.title 163 | } 164 | }); 165 | }); 166 | 167 | ipcMain.on('new-window', (event, options = {}) => { 168 | if (options.isolate) { 169 | windowManager.open({ ...options, restoreTabs: false }); // not restoring other tabs of isolated window 170 | } else { 171 | windowManager.open(options); 172 | } 173 | }); 174 | 175 | ipcMain.handle('get-tab-memory-usage', async (event, webContentsId) => { 176 | try{ 177 | const wc = webContents.fromId(webContentsId); 178 | if (!wc) { 179 | throw new Error(`WebContents with ID ${webContentsId} not found`); 180 | } 181 | 182 | const processId = wc.getOSProcessId(); 183 | const metrics = app.getAppMetrics(); 184 | 185 | const processMetrics = metrics.find(m => m.pid === processId); 186 | 187 | if(processMetrics && processMetrics.memory) { 188 | return { 189 | workingSetSize : processMetrics.memory.workingSetSize*1024, // KB to Bytes 190 | peakWorkingSetSize : processMetrics.memory.peakWorkingSetSize*1024, 191 | privateBytes : processMetrics.memory.privateBytes*1024, 192 | } 193 | } 194 | return null; 195 | } 196 | catch (error) { 197 | console.error(`Error getting memory usage for webContents ID ${webContentsId}:`, error); 198 | return null; 199 | } 200 | }); 201 | 202 | ipcMain.on('group-action', (event, data) => { 203 | console.log('Group action received:', data); 204 | const { action, groupId } = data; 205 | 206 | // Broadcast to all windows 207 | windowManager.all.forEach(peerskyWindow => { 208 | if (peerskyWindow.window && !peerskyWindow.window.isDestroyed()) { 209 | peerskyWindow.window.webContents.send('group-action', { action, groupId }); 210 | } 211 | }); 212 | 213 | return { success: true }; 214 | }); 215 | 216 | ipcMain.on('update-group-properties', (event, groupId, properties) => { 217 | console.log('Updating group properties across all windows:', groupId, properties); 218 | 219 | // Broadcast to all windows 220 | windowManager.all.forEach(peerskyWindow => { 221 | if (peerskyWindow.window && !peerskyWindow.window.isDestroyed()) { 222 | peerskyWindow.window.webContents.send('group-properties-updated', groupId, properties); 223 | } 224 | }); 225 | }); 226 | 227 | ipcMain.handle('check-built-in-engine', (event, template) => { 228 | try { 229 | return isBuiltInSearchEngine(template); 230 | } catch (error) { 231 | console.error('Error in check-built-in-engine:', error); 232 | return false; // fallback if anything goes wrong 233 | } 234 | }); 235 | 236 | export { windowManager }; 237 | --------------------------------------------------------------------------------