OverviewFilesCommitsIssuesDocsReleasesPeers

pearbrowser-desktop commits / 3f3832e

feat: add private search home
3f3832e93a06c09ba2352cd2da88a51c89bb9320
by bigdestiny2 <33146784+bigdestiny2@users.noreply.github.com> · 2026-07-22T18:05:48.000Z

Changed files (8) +196 -14

README.md +4 -2
……@@ -48,6 +48,7 @@ The key above is **content-addressed and stable**: when we ship a new release, t
4848- Per-tab back/forward history; tabs persist across launches and survive panel switches
4949- URL bar autocomplete from your bookmarks + history Hyperbees (↑↓ to navigate, Enter to pick)
5050- "About this site" panel (ⓘ button): drive key in hex + z-base-32 with copy buttons, scheme + path, one-click bookmark toggle
51- **Private-search home:** the active first tab on launch opens a browser-owned search page powered by DuckDuckGo. PearBrowser sends no search analytics and excludes submitted queries from the optional persistent visit log; the on-page disclosure makes clear that DuckDuckGo still receives the query and network address, and that private search is not anonymity.
5152- **Ask Browser:** click **✦ Ask** to question a local QVAC/Qwen model about the active page, with private on-device streaming, cancellation, short follow-ups, source provenance, and no page access to the browser-owned AI channel
5253- **Local AI widget:** every blank new tab (`⌘T`) hosts a QVAC quick-ask card — zero page context, streamed on-device answers, model picker, and load-on-first-use progress ([docs](./docs/ASK_BROWSER.md))
5354- **Content Shield:** Brave-style ad/tracker blocking enforced inside the browser-owned proxy — blocked requests never reach a peer, relay, or the network; cosmetic element hiding + scriptlets ride the CSP-safe HTML injection path; per-drive allowlist/strict modes, toggle + live counters in Settings ([plan](./docs/BROWSER_PARITY_PLAN.md))
……@@ -63,7 +64,7 @@ The key above is **content-addressed and stable**: when we ship a new release, t
6364- **My Catalog:** create a writable personal catalog, add apps from loaded catalogs or installed apps, rename it, edit saved metadata (name, description, version, author, categories), and share the catalog key; copies opened without the writer key stay read-only
6465- Safe catalogue normalization accepts `apps[]`, `items[]`, or `entries[]`, preserves safe link-only rows (`hyper://`, `pear://`, `file://`), rejects malformed targets, and strips prototype-pollution keys before rendering
6566- Default catalog auto-loads on first Apps-tab visit (the "PearBrowser Network" Hyperbee `hyperbee://f5fb7500bccd…` — PearBrowser, peerit, HiveRelay, P2P Builders, Pear Dealroom, Paste, PearPoker, Keet, PearPass, Peercord, anonGPT, Pear POS, Pear Tickets, HiveWorm), generated from a single source manifest and mirrored into the offline seed
66- **peerit — "the front page of the P2P internet"** (a peer-to-peer Reddit, `hyper://ec6e2d6d…/`): a browsable `hypersite` whose communities, posts, threaded comments and votes live in a shared Autobase+Hyperbee log. It opens as the **active front tab on every fresh launch** (alongside the landing page) and is **pinned to the top** of the Sites discovery grid. Source: `02-apps/peerit`.
67- **peerit — "the front page of the P2P internet"** (a peer-to-peer Reddit, `hyper://ec6e2d6d…/`): a browsable `hypersite` whose communities, posts, threaded comments and votes live in a shared Autobase+Hyperbee log. It opens as a startup tab beside the private-search home and landing page, and is **pinned to the top** of the Sites discovery grid. Source: `02-apps/peerit`.
6768- Featured apps: **Keet** · **PearPass** · **anonGPT** · **Paste** · **[Peercord](https://git.churchofmalware.org/mastercodeon/Peercord)**. Peercord's current Pear release is a full desktop app (`pear.json` `type:"desktop"`), so PearBrowser launches it via the standalone `pear://` window path; it should only move to headless "Run in tab" once Peercord publishes a pear-request worker entry.
6869
6970### Publish (P2P Sites)
……@@ -79,9 +80,10 @@ The key above is **content-addressed and stable**: when we ship a new release, t
7980- Cross-launch persistence; powers the URL-bar autocomplete
8081
8182### Search
83- Private web search is available from the browser-owned home page through DuckDuckGo over HTTPS with Content Shield enabled; PearBrowser does not send search analytics or add these queries to its optional persistent visit log.
8284- Local-first personal index returns first-paint results immediately from local browser/catalogue data
8385- Optional trusted-peer federation enriches results in the background through `QueryPlanner`, digest checks, provenance, and stale-query suppression
84- Query length and result limits are bounded; no query leaves the device unless the user asks for federated search
86- Local-index query length and result limits are bounded; local queries do not leave the device unless the user asks for federated search. Web-search submissions do leave the device for DuckDuckGo, as disclosed on the home page.
8587
8688### Naming
8789- `pearname://` and typed names resolve through local petnames, owned registry records, trusted-contact federation, and curated defaults
styles.css +73 -1
……@@ -357,9 +357,75 @@ p { color: var(--pear-color-muted); margin-bottom: 12px; }
357357 max-width: 520px;
358358 text-align: center;
359359}
360.browse-welcome-inner.start-page { width: min(720px, 100%); max-width: 720px; }
360361.browse-welcome-logo { font-size: 44px; margin-bottom: 14px; }
361362.browse-welcome-inner h2 { font-size: 22px; font-weight: 700; letter-spacing: -0.4px; margin-bottom: 12px; color: var(--pear-color-text); }
362363.browse-welcome-inner p { color: var(--pear-color-muted); font-size: 14px; line-height: 1.7; margin-bottom: 20px; }
364.browse-welcome-inner .start-page-lede { margin: 0 auto 16px; max-width: 590px; }
365.private-search-card {
366 padding: 16px;
367 margin-bottom: 14px;
368 text-align: left;
369 background: var(--pear-color-surface);
370 border: 1px solid var(--pear-color-line);
371 border-radius: var(--pear-radius-card, 12px);
372 box-shadow: var(--pear-shadow-card, 0 1px 2px rgba(0, 0, 0, 0.06));
373}
374.private-search-heading {
375 display: flex;
376 align-items: center;
377 justify-content: space-between;
378 gap: 12px;
379 margin-bottom: 10px;
380 color: var(--pear-color-text);
381 font-size: 14px;
382 font-weight: 700;
383}
384.private-search-provider {
385 padding: 3px 9px;
386 color: var(--pear-color-muted);
387 border: 1px solid var(--pear-color-line);
388 border-radius: var(--pear-radius-pill, 999px);
389 font-size: 10px;
390 font-weight: 600;
391 letter-spacing: 0.03em;
392 text-transform: uppercase;
393}
394.private-search-form { display: flex; gap: 8px; }
395.private-search-form input {
396 min-width: 0;
397 flex: 1;
398 padding: 12px 14px;
399 color: var(--pear-color-text);
400 background: var(--pear-color-canvas);
401 border: 1px solid var(--pear-color-line);
402 border-radius: var(--pear-radius-control, 8px);
403 font: inherit;
404 font-size: 15px;
405}
406.private-search-form input:focus {
407 outline: none;
408 border-color: var(--pear-color-accent);
409 box-shadow: 0 0 0 3px rgba(15, 118, 110, 0.12);
410}
411.private-search-submit {
412 padding: 0 20px;
413 color: white;
414 background: var(--pear-color-accent);
415 border: 0;
416 border-radius: var(--pear-radius-control, 8px);
417 font: inherit;
418 font-weight: 700;
419 cursor: pointer;
420}
421.private-search-submit:disabled { opacity: 0.45; cursor: default; }
422.private-search-disclosure {
423 margin-top: 10px;
424 color: var(--pear-color-quiet);
425 font-size: 11px;
426 line-height: 1.5;
427}
428.start-page-p2p { margin-bottom: 14px; color: var(--pear-color-muted); font-size: 12px; line-height: 1.6; }
363429.browse-welcome-actions { display: flex; gap: 8px; justify-content: center; margin-bottom: 18px; flex-wrap: wrap; }
364430.browse-welcome-tip { color: var(--pear-color-quiet); font-size: 12px; padding-top: 16px; border-top: 1px solid var(--pear-color-surface-soft); }
365431.browse-welcome-tip strong { color: var(--pear-color-text); }
……@@ -371,6 +437,11 @@ p { color: var(--pear-color-muted); margin-bottom: 12px; }
371437 font-size: 12px;
372438}
373439
440@media (max-width: 620px) {
441 .private-search-form { flex-direction: column; }
442 .private-search-submit { padding: 11px 16px; }
443}
444
374445.webview { flex: 1; border: none; background: var(--pear-color-webview); width: 100%; }
375446
376447/* ─── Apps tab ─── */
……@@ -1376,7 +1447,8 @@ p { color: var(--pear-color-muted); margin-bottom: 12px; }
13761447 visibility: hidden;
13771448 pointer-events: none;
13781449}
1379.browse-stage .browse-welcome { flex: 1; display: flex; align-items: center; justify-content: center; }
1450.browse-stage .browse-welcome { flex: 1; display: flex; align-items: center; justify-content: center; overflow-y: auto; }
1451.browse-stage .browse-welcome-inner.start-page { margin: auto; }
13801452
13811453/* --- Ask Browser --------------------------------------------------- */
13821454.urlbar .ask-browser-toggle {
test/peerit-catalog.test.js +4 -2
……@@ -33,13 +33,15 @@ test('peerit catalogue source declares the site + drive key', () => {
3333 assert.equal(app.url, PEERIT_URL)
3434})
3535
36test('shell.js opens PearBrowser landing first with peerit beside it', () => {
36test('shell.js opens private-search home first with PearBrowser and peerit beside it', () => {
3737 const shell = readFileSync(new URL('../ui/shell.js', import.meta.url), 'utf8')
3838 // The drive key constant exists and is used to build the front-tab URL.
3939 assert.match(shell, new RegExp(`PEERIT_DRIVE_KEY = '${PEERIT_KEY}'`))
4040 assert.match(shell, /const PEERIT_URL = 'hyper:\/\/' \+ PEERIT_DRIVE_KEY \+ '\/'/)
41 // Fresh launch opens PearBrowser first (tabs[0] == the active tab), then p2pbuilders + peerit.
41 // Fresh launch opens the browser-owned home first, then the landing,
42 // p2pbuilders, and peerit.
4243 assert.match(shell, /const STARTUP_TABS = \[/)
44 assert.match(shell, /\{ url: '', title: 'PearBrowser Home' \}/)
4345 assert.match(shell, /\{ url: DEFAULT_URL, title: 'PearBrowser' \}/)
4446 assert.match(shell, /\{ url: P2PBUILDERS_URL, title: 'P2P Builders' \}/)
4547 assert.match(shell, /\{ url: PEERIT_URL, title: 'peerit' \}/)
test/private-search.test.js +33 -0
……@@ -0,0 +1,33 @@
1import test from 'node:test'
2import assert from 'node:assert/strict'
3import { readFileSync } from 'node:fs'
4import {
5 PRIVATE_SEARCH_PROVIDER,
6 normalizePrivateSearchQuery,
7 buildPrivateSearchUrl
8} from '../ui/lib/private-search.js'
9
10test('private search uses the declared DuckDuckGo HTTPS endpoint', () => {
11 assert.equal(PRIVATE_SEARCH_PROVIDER.name, 'DuckDuckGo')
12 assert.equal(PRIVATE_SEARCH_PROVIDER.origin, 'https://duckduckgo.com/')
13 const url = new URL(buildPrivateSearchUrl('tracker blocking browser'))
14 assert.equal(url.origin, 'https://duckduckgo.com')
15 assert.equal(url.searchParams.get('q'), 'tracker blocking browser')
16 assert.deepEqual([...url.searchParams.keys()], ['q'])
17})
18
19test('private search normalizes whitespace, bounds input, and rejects blanks', () => {
20 assert.equal(normalizePrivateSearchQuery(' private\n\tsearch '), 'private search')
21 assert.equal(normalizePrivateSearchQuery('x'.repeat(3000)).length, 2048)
22 assert.equal(buildPrivateSearchUrl(' '), null)
23})
24
25test('the browser-owned home is first and contains an honest provider disclosure', () => {
26 const shell = readFileSync(new URL('../ui/shell.js', import.meta.url), 'utf8')
27 assert.match(shell, /\{ url: '', title: 'PearBrowser Home' \},\n\s*\{ url: DEFAULT_URL, title: 'PearBrowser' \}/)
28 assert.match(shell, /data-testid="private-search-form"/)
29 assert.match(shell, /data-testid="private-search-input"[\s\S]*autoFocus/)
30 assert.match(shell, /rememberVisit: false/)
31 assert.match(shell, /receives your query and network address/)
32 assert.match(shell, /Private search is not anonymity/)
33})
test/tabs.test.js +14 -0
……@@ -100,6 +100,20 @@ test('restoreStartupTabs keeps PearBrowser landing first even when Dealroom was
100100 assert.equal(restored.activeId, restored.tabs[0].id)
101101})
102102
103test('restoreStartupTabs keeps a named blank browser home at the front', () => {
104 const restored = restoreStartupTabs([
105 { url: DEALROOM, title: 'Pear Dealroom', active: true }
106 ], [
107 { url: '', title: 'PearBrowser Home' },
108 { url: A, title: 'PearBrowser' }
109 ])
110 assert.equal(restored.tabs[0].url, '')
111 assert.equal(restored.tabs[0].title, 'PearBrowser Home')
112 assert.equal(restored.tabs[1].url, A)
113 assert.equal(restored.tabs[2].url, DEALROOM)
114 assert.equal(restored.activeId, restored.tabs[0].id)
115})
116
103117test('normalizeDefaultTab accepts named startup entries', () => {
104118 assert.deepEqual(normalizeDefaultTab({ url: A, title: 'PearBrowser' }), { url: A, title: 'PearBrowser' })
105119 assert.deepEqual(normalizeDefaultTab(B), { url: B, title: '' })
ui/lib/private-search.js +19 -0
……@@ -0,0 +1,19 @@
1// Pure helpers for the start-page web search. Keeping the provider contract in
2// one framework-free module makes the destination and disclosure easy to test.
3
4export const PRIVATE_SEARCH_PROVIDER = Object.freeze({
5 name: 'DuckDuckGo',
6 origin: 'https://duckduckgo.com/'
7})
8
9export function normalizePrivateSearchQuery (value) {
10 return String(value || '').normalize('NFKC').trim().replace(/\s+/gu, ' ').slice(0, 2048)
11}
12
13export function buildPrivateSearchUrl (value) {
14 const query = normalizePrivateSearchQuery(value)
15 if (!query) return null
16 const url = new URL(PRIVATE_SEARCH_PROVIDER.origin)
17 url.searchParams.set('q', query)
18 return url.toString()
19}
ui/lib/tabs.js +3 -1
……@@ -126,7 +126,9 @@ export function restoreStartupTabs (savedTabs, defaultUrls = []) {
126126
127127 for (const entry of defaultUrls) {
128128 const { url, title } = normalizeDefaultTab(entry)
129 if (url) add(makeTab(url, title ? { title } : {}))
129 // A named blank entry is the browser-owned start page. It has no URL to
130 // fetch, but still belongs at the front of every restored session.
131 if (url || title) add(makeTab(url, title ? { title } : {}))
130132 }
131133
132134 const restoredPairs = Array.isArray(savedTabs)
ui/shell.js +46 -8
……@@ -24,6 +24,7 @@ import {
2424 describeAiStatus,
2525 buildQuickAskRequest
2626} from './lib/qvac-widget.js'
27import { PRIVATE_SEARCH_PROVIDER, buildPrivateSearchUrl } from './lib/private-search.js'
2728
2829function copyText (text) {
2930 try {
……@@ -179,6 +180,7 @@ const PEERIT_URL = 'hyper://' + PEERIT_DRIVE_KEY + '/'
179180const P2PBUILDERS_DRIVE_KEY = 'ac1977a75cc84b46af0af8bb559cd4ebbe10507eb0f51d863e289d09635f6d74'
180181const P2PBUILDERS_URL = 'hyper://' + P2PBUILDERS_DRIVE_KEY + '/'
181182const STARTUP_TABS = [
183 { url: '', title: 'PearBrowser Home' },
182184 { url: DEFAULT_URL, title: 'PearBrowser' },
183185 { url: P2PBUILDERS_URL, title: 'P2P Builders' },
184186 { url: PEERIT_URL, title: 'peerit' }
……@@ -1152,6 +1154,7 @@ function Browse ({ rpc, C, navUrl, onNavigated, tabs, setTabs, activeId, setActi
11521154 const activeIdRef = useRef(activeId)
11531155 const autoLoadedRef = useRef(new Set())
11541156 const [editingUrl, setEditingUrl] = useState('')
1157 const [privateSearchQuery, setPrivateSearchQuery] = useState('')
11551158 // About-this-site modal — true when user clicked the (i) button.
11561159 const [aboutOpen, setAboutOpen] = useState(false)
11571160 const [askOpen, setAskOpen] = useState(false)
……@@ -1434,6 +1437,16 @@ function Browse ({ rpc, C, navUrl, onNavigated, tabs, setTabs, activeId, setActi
14341437 setEditingUrl(url || '')
14351438 }
14361439
1440 const submitPrivateSearch = (event) => {
1441 event?.preventDefault?.()
1442 const target = buildPrivateSearchUrl(privateSearchQuery)
1443 if (!target) return
1444 setPrivateSearchQuery('')
1445 // Search queries are never written to the optional persistent visit log.
1446 // The active tab still shows its current results URL, like every browser.
1447 go(target, activeId, { rememberVisit: false })
1448 }
1449
14371450 const closeTab = (id) => {
14381451 const closing = tabs.find((t) => t.id === id)
14391452 const closed = normalizeTabSnapshot(closing)
……@@ -1837,12 +1850,37 @@ function Browse ({ rpc, C, navUrl, onNavigated, tabs, setTabs, activeId, setActi
18371850 </div>
18381851 </div>
18391852 </div>`
1840 // A truly blank tab (⌘T) gets the welcome + quick actions.
1853 // A truly blank tab (including the first startup tab) is the
1854 // browser-owned home surface: private search + local tools.
18411855 : html`<div key=${t.id} className="browse-welcome">
1842 <div className="browse-welcome-inner">
1856 <div className="browse-welcome-inner start-page">
18431857 <div className="browse-welcome-logo">🍐</div>
1844 <h2>The peer-to-peer web starts here</h2>
1845 <p>Paste any <code>hyper://</code> URL above — hex or z-base-32 — and PearBrowser fetches it directly from its peers. No DNS, no servers, no CDN.</p>
1858 <h2>Search without a profile</h2>
1859 <p className="start-page-lede">PearBrowser sends no search analytics and never adds a query to its optional persistent visit history.</p>
1860 <section className="private-search-card" aria-labelledby="private-search-title">
1861 <div className="private-search-heading">
1862 <span id="private-search-title">Private web search</span>
1863 <span className="private-search-provider">${PRIVATE_SEARCH_PROVIDER.name}</span>
1864 </div>
1865 <form className="private-search-form" data-testid="private-search-form" onSubmit=${submitPrivateSearch}>
1866 <input
1867 type="search"
1868 value=${privateSearchQuery}
1869 data-testid="private-search-input"
1870 aria-label="Search the web privately"
1871 placeholder="Search the web"
1872 autoComplete="off"
1873 autoFocus
1874 spellCheck="false"
1875 onInput=${event => setPrivateSearchQuery(event.target.value)}
1876 />
1877 <button type="submit" className="private-search-submit" data-testid="private-search-submit" disabled=${!privateSearchQuery.trim()}>Search</button>
1878 </form>
1879 <div className="private-search-disclosure">
1880 Content Shield stays on. ${PRIVATE_SEARCH_PROVIDER.name} receives your query and network address to return results; its published policy says it does not save or share search history. Private search is not anonymity.
1881 </div>
1882 </section>
1883 <div className="start-page-p2p">Or paste a <code>hyper://</code> address above to fetch a site directly from its peers — no DNS, server, or CDN.</div>
18461884 <div className="browse-welcome-actions">
18471885 <button className="btn primary" onClick=${() => go(DEFAULT_URL)}>Open the PearBrowser site</button>
18481886 <button className="btn subtle" onClick=${() => { inputRef.current?.focus(); inputRef.current?.select?.() }}>Focus the URL bar</button>
……@@ -6482,10 +6520,10 @@ export function App ({ rpc, C, storagePath }) {
64826520 // 1. Switching to Apps/Settings/etc and back doesn't destroy them
64836521 // (Browse used to remount with a fresh tabs[] every time)
64846522 // 2. We can persist them to user-data and restore across launches
6485 // Default initial state on launch is the PearBrowser landing page first,
6486 // then p2pbuilders, then peerit. Restored session tabs stay behind those
6487 // defaults so an app homepage such as Dealroom cannot hijack the release
6488 // landing slot.
6523 // Default initial state on launch is the browser-owned private-search home
6524 // first, then the PearBrowser landing, p2pbuilders, and peerit. Restored
6525 // session tabs stay behind those defaults so an app homepage cannot hijack
6526 // the home slot.
64896527 const [tabs, setTabs] = useState(() => STARTUP_TABS.map((tab) => makeBrowserTab(tab.url, { title: tab.title })))
64906528 const [browseActiveId, setBrowseActiveId] = useState(() => 'placeholder')
64916529 const [closedTabs, setClosedTabs] = useState(() => [])