| … | … | @@ -50,7 +50,10 @@ The key above is **content-addressed and stable**: when we ship a new release, t |
| 50 | 50 | - "About this site" panel (ⓘ button): drive key in hex + z-base-32 with copy buttons, scheme + path, one-click bookmark toggle |
| 51 | 51 | - **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 |
| 52 | 52 | - **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)) |
| 53 | - **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 rides the HTML injection path; toggle + live counters in Settings ([plan](./docs/BROWSER_PARITY_PLAN.md)) | |
| 53 | - **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)) | |
| 54 | - **P2P filter lists:** subscribe to a filter-list Hyperdrive by key — rules sync peer-to-peer with sha256 verification, hot-swap when the publisher updates, and keep blocking fully offline; publish your own with `scripts/build-shield-list.mjs` + `scripts/publish-and-pin.js` ([guide](./filter-lists/README.md)) | |
| 55 | - **Pear Plugins:** extensions as Hyperdrives with declared capabilities (`pear.content.styles/scripts`, `pear.net.filter`) — install by drive key, contributions inject hash-authorized, and a swarm update that requests new capabilities is auto-disabled pending re-approval; two ready-to-publish examples in `examples/plugins/` | |
| 56 | - **Plugin catalogue:** one-click discovery in Settings — curated entries install without pasting keys, `kind: "app"` entries like **anonGPT** open directly as P2P apps, and additional catalogues load from any drive with a `/plugins.json` (shippable source in `catalogues/pear-plugins/`) | |
| 54 | 57 | |
| 55 | 58 | ### Apps |
| 56 | 59 | - Paste any `pear://` link → opens in its own isolated window |
| … | … | @@ -141,6 +144,7 @@ Three independent keypairs — BIP-39 identity, HiveRelay publisher key, Coresto |
| 141 | 144 | | [Ask Browser](./docs/ASK_BROWSER.md) | Local-model side panel, Ollama/Qwen discovery, authenticated page-context capture, stream RPC, safety boundaries, and verification. | |
| 142 | 145 | | [QVAC native AI](./docs/QVAC_NATIVE_AI.md) | Native Bare/QVAC runtime, model aliases, browser-page AI API, security limits, and smoke commands. | |
| 143 | 146 | | [Browser parity plan](./docs/BROWSER_PARITY_PLAN.md) | Roadmap to Brave-grade capability: Content Shield phases, P2P filter lists, the Pear Plugins extension model, and the clearnet session bridge. | |
| 147 | | [Filter lists & plugins guide](./filter-lists/README.md) | Publishing and subscribing to P2P filter-list drives, and packaging/installing Pear Plugins with the capability-escalation guard. | | |
| 144 | 148 | | [Deep audit](./docs/DEEP_AUDIT_CATALOG_SEARCH_NAMING_NOSTR_2026-06-21.md) | Detailed catalogue/search/naming/Nostr audit, issue list, fixes, and test evidence. | |
| 145 | 149 | | [Manual release smoke](./docs/MANUAL_RELEASE_SMOKE_2026-06-23.md) | Final human-run release checklist for UI flows, Peercord trust approval, mobile device smoke, and signing/store gates. | |
| 146 | 150 | | [Native install guide](./docs/INSTALL_NATIVE_PACKAGES.md) | User-facing package selection, checksum verification, and OS-specific install steps for the current native assets. | |
| … | … | @@ -194,8 +194,10 @@ function rewriteHtmlForProxy (html, documentUrl, proxyOrigin) { |
| 194 | 194 | |
| 195 | 195 | // Inject <base> pointing at proxy document root so any leftover relative |
| 196 | 196 | // resolution still hits our host; we also set a pear-clearnet meta. |
| 197 | // Bare's URL implementation exposes protocol + host but not `.origin`. | |
| 198 | const documentOrigin = `${base.protocol}//${base.host}` | |
| 197 | 199 | const headBits = |
| 198 | `<meta name="pear-clearnet-origin" content="${escapeHtml(base.origin)}">` + | |
| 200 | `<meta name="pear-clearnet-origin" content="${escapeHtml(documentOrigin)}">` + | |
| 199 | 201 | `<base href="${escapeHtml(origin)}/clearnet/${encodeClearnetTarget(base.toString())}">` |
| 200 | 202 | |
| 201 | 203 | if (/<head[^>]*>/i.test(out)) { |
| … | … | @@ -203,6 +203,19 @@ const CMD_PLUGIN_LIST = 235 // → { plugins: [...] } |
| 203 | 203 | const CMD_PLUGIN_SET_ENABLED = 236 // { id, enabled } → kill-switch without uninstall |
| 204 | 204 | const CMD_PLUGIN_REGISTER = 237 // { id, manifest, contribution?, enabled? } → fixture/install path |
| 205 | 205 | |
| 206 | // P2P distribution (Phase 2/3 gates): filter lists and plugins arrive as | |
| 207 | // Hyperdrives. Subscriptions and installs are durable and work offline | |
| 208 | // after first sync; updates hot-swap with a capability-escalation guard. | |
| 209 | const CMD_SHIELD_SUBSCRIBE_LIST = 239 // { driveKey } → subscribe to a filter-list drive | |
| 210 | const CMD_SHIELD_UNSUBSCRIBE_LIST = 240 // { driveKey } | |
| 211 | const CMD_SHIELD_REFRESH_LISTS = 241 // { driveKey?, force? } → refresh one or all subscriptions | |
| 212 | const CMD_PLUGIN_INSTALL_DRIVE = 242 // { driveKey, granted? } → install a plugin from its drive | |
| 213 | const CMD_PLUGIN_UPDATE_DRIVE = 243 // { driveKey, acceptEscalation? } | |
| 214 | const CMD_PLUGIN_UNINSTALL = 244 // { driveKey } | |
| 215 | const CMD_PLUGIN_CATALOG = 245 // → { entries, sources } (builtin seed + subscribed catalogue drives) | |
| 216 | const CMD_PLUGIN_CATALOG_LOAD_DRIVE = 246 // { driveKey } → subscribe to a catalogue drive (/plugins.json) | |
| 217 | const CMD_PLUGIN_CATALOG_REMOVE_SOURCE = 247 // { driveKey } | |
| 218 | ||
| 206 | 219 | // Privacy ladder + clearnet session bridge status (Phases 4–5). |
| 207 | 220 | const CMD_PRIVACY_STATUS = 238 |
| 208 | 221 | |
| … | … | @@ -322,6 +335,9 @@ module.exports = { |
| 322 | 335 | CMD_SHIELD_STATUS, CMD_SHIELD_LOAD_LIST, CMD_SHIELD_REMOVE_LIST, |
| 323 | 336 | CMD_SHIELD_SET_ALLOW, CMD_SHIELD_SET_STRICT, |
| 324 | 337 | CMD_PLUGIN_LIST, CMD_PLUGIN_SET_ENABLED, CMD_PLUGIN_REGISTER, |
| 338 | CMD_SHIELD_SUBSCRIBE_LIST, CMD_SHIELD_UNSUBSCRIBE_LIST, CMD_SHIELD_REFRESH_LISTS, | |
| 339 | CMD_PLUGIN_INSTALL_DRIVE, CMD_PLUGIN_UPDATE_DRIVE, CMD_PLUGIN_UNINSTALL, | |
| 340 | CMD_PLUGIN_CATALOG, CMD_PLUGIN_CATALOG_LOAD_DRIVE, CMD_PLUGIN_CATALOG_REMOVE_SOURCE, | |
| 325 | 341 | CMD_PRIVACY_STATUS, |
| 326 | 342 | CMD_BRIDGE, |
| 327 | 343 | CMD_STOP, |
| … | … | @@ -90,7 +90,10 @@ class HttpBridge { |
| 90 | 90 | |
| 91 | 91 | _sameOrigin (a, b) { |
| 92 | 92 | try { |
| 93 | return new URL(a).origin === new URL(b).origin | |
| 93 | const left = new URL(a) | |
| 94 | const right = new URL(b) | |
| 95 | // Bare's URL implementation does not expose `.origin`. | |
| 96 | return left.protocol === right.protocol && left.host === right.host | |
| 94 | 97 | } catch { |
| 95 | 98 | return false |
| 96 | 99 | } |
| … | … | @@ -50,7 +50,11 @@ function isLoopbackOrigin (origin) { |
| 50 | 50 | |
| 51 | 51 | function normalizeOrigin (origin) { |
| 52 | 52 | if (typeof origin !== 'string' || origin.length === 0) return null |
| 53 | return new URL(origin).origin | |
| 53 | const parsed = new URL(origin) | |
| 54 | // Bare's URL implementation exposes protocol + host but not `origin`. | |
| 55 | // Building it explicitly keeps injected <base> URLs and token bindings | |
| 56 | // valid in the packaged runtime as well as in Node-based tests. | |
| 57 | return `${parsed.protocol}//${parsed.host}` | |
| 54 | 58 | } |
| 55 | 59 | |
| 56 | 60 | function originForPort (port) { |
| … | … | @@ -37,6 +37,9 @@ const { PearBridge, PEAR_SWARM_V1_SHIM, PEAR_SYNC_SHIM, PEAR_ANONGPT_SHIM } = re |
| 37 | 37 | const { HttpBridge } = require('./http-bridge.js') |
| 38 | 38 | const { AskBrowserService } = require('./ai/ask-browser-service.cjs') |
| 39 | 39 | const { ContentShield } = require('./content-shield.cjs') |
| 40 | const { ShieldListSync } = require('./shield-list-sync.cjs') | |
| 41 | const { PluginDriveLoader } = require('./plugin-drive-loader.cjs') | |
| 42 | const { PluginCatalog } = require('./plugin-catalog.cjs') | |
| 40 | 43 | const { PearPluginRegistry } = require('./pear-plugins.cjs') |
| 41 | 44 | const { SessionBridge } = require('./session-bridge.cjs') |
| 42 | 45 | const { |
| … | … | @@ -129,6 +132,9 @@ let swarmGrants = null |
| 129 | 132 | let aiService = null |
| 130 | 133 | let contentShield = null // Content Shield — request filter + cosmetic hiding (BROWSER_PARITY_PLAN.md) |
| 131 | 134 | let pearPlugins = null // Pear Plugins registry (Phase 3) |
| 135 | let shieldListSync = null // P2P filter-list subscriptions (Phase 2 gate) | |
| 136 | let pluginDriveLoader = null // Plugin installs from drives (Phase 3 gate) | |
| 137 | let pluginCatalog = null // Plugin discovery: builtin seed + subscribed catalogue drives | |
| 132 | 138 | let sessionBridge = null // Clearnet session bridge facade (Phase 4) |
| 133 | 139 | let privacySettings = { ...DEFAULT_PRIVACY } |
| 134 | 140 | /** Map<requestId, { resolve, reject, timer }> for login() ceremonies. */ |
| … | … | @@ -232,7 +238,7 @@ rpc.handle(C.CMD_NAVIGATE, async (data = {}) => { |
| 232 | 238 | ? await proxy.localUrlForDrive(key, 'hyper', pathAndSearch) |
| 233 | 239 | : `http://127.0.0.1:${proxy.port}/hyper/${key}${pathAndSearch}` |
| 234 | 240 | const apiToken = validKey && proxy?.issueApiToken |
| 235 | ? proxy.issueApiToken(key, { origin: new URL(localUrl).origin }) | |
| 241 | ? proxy.issueApiToken(key, { origin: localUrl }) | |
| 236 | 242 | : null |
| 237 | 243 | const contextToken = validKey && proxy?.pageContextToken |
| 238 | 244 | ? proxy.pageContextToken(key) |
| … | … | @@ -339,6 +345,7 @@ rpc.handle(C.CMD_SHIELD_STATUS, async (data = {}) => { |
| 339 | 345 | stats.driveAllowlisted = contentShield.isAllowlisted(driveKey) |
| 340 | 346 | stats.driveStrict = contentShield.isStrict(driveKey) |
| 341 | 347 | } |
| 348 | stats.subscriptions = shieldListSync ? shieldListSync.subscriptions() : [] | |
| 342 | 349 | return stats |
| 343 | 350 | }) |
| 344 | 351 | |
| … | … | @@ -457,6 +464,119 @@ function rememberPluginPayload (data) { |
| 457 | 464 | } |
| 458 | 465 | } |
| 459 | 466 | |
| 467 | // --- P2P distribution: filter-list drives + plugin drives --- | |
| 468 | // | |
| 469 | // Rule text and plugin payloads are durable through persistShieldState(); | |
| 470 | // these commands own the drive-sourced lifecycle (subscribe/refresh/install/ | |
| 471 | // update) so lists and plugins keep working offline after first sync and | |
| 472 | // hot-swap when their drives update. | |
| 473 | ||
| 474 | rpc.handle(C.CMD_SHIELD_SUBSCRIBE_LIST, async (data = {}) => { | |
| 475 | await whenReady() | |
| 476 | if (!shieldListSync) throw new Error('Shield list sync not available') | |
| 477 | const result = await shieldListSync.subscribe(data.driveKey) | |
| 478 | await persistShieldState() | |
| 479 | return { ...result, subscriptions: shieldListSync.subscriptions() } | |
| 480 | }) | |
| 481 | ||
| 482 | rpc.handle(C.CMD_SHIELD_UNSUBSCRIBE_LIST, async (data = {}) => { | |
| 483 | await whenReady() | |
| 484 | if (!shieldListSync) throw new Error('Shield list sync not available') | |
| 485 | const result = await shieldListSync.unsubscribe(data.driveKey) | |
| 486 | await persistShieldState() | |
| 487 | return { ...result, subscriptions: shieldListSync.subscriptions() } | |
| 488 | }) | |
| 489 | ||
| 490 | rpc.handle(C.CMD_SHIELD_REFRESH_LISTS, async (data = {}) => { | |
| 491 | await whenReady() | |
| 492 | if (!shieldListSync) throw new Error('Shield list sync not available') | |
| 493 | const outcomes = data.driveKey | |
| 494 | ? [{ driveKey: String(data.driveKey).toLowerCase(), ok: true, ...(await shieldListSync.refresh(data.driveKey, { force: !!data.force })) }] | |
| 495 | : await shieldListSync.refreshAll({ force: !!data.force }) | |
| 496 | await persistShieldState() | |
| 497 | return { outcomes, subscriptions: shieldListSync.subscriptions() } | |
| 498 | }) | |
| 499 | ||
| 500 | rpc.handle(C.CMD_PLUGIN_INSTALL_DRIVE, async (data = {}) => { | |
| 501 | await whenReady() | |
| 502 | if (!pluginDriveLoader) throw new Error('Plugin drive loader not available') | |
| 503 | const result = await pluginDriveLoader.installFromDrive(data.driveKey, { | |
| 504 | grantedCapabilities: data.granted | |
| 505 | }) | |
| 506 | await persistShieldState() | |
| 507 | return result | |
| 508 | }) | |
| 509 | ||
| 510 | rpc.handle(C.CMD_PLUGIN_UPDATE_DRIVE, async (data = {}) => { | |
| 511 | await whenReady() | |
| 512 | if (!pluginDriveLoader) throw new Error('Plugin drive loader not available') | |
| 513 | const result = await pluginDriveLoader.updateFromDrive(data.driveKey, { | |
| 514 | acceptEscalation: !!data.acceptEscalation | |
| 515 | }) | |
| 516 | await persistShieldState() | |
| 517 | return result | |
| 518 | }) | |
| 519 | ||
| 520 | rpc.handle(C.CMD_PLUGIN_UNINSTALL, async (data = {}) => { | |
| 521 | await whenReady() | |
| 522 | if (!pluginDriveLoader) throw new Error('Plugin drive loader not available') | |
| 523 | const result = await pluginDriveLoader.uninstall(data.driveKey) | |
| 524 | if (result.driveKey) delete persistShieldState._pluginPayloads[result.driveKey] | |
| 525 | await persistShieldState() | |
| 526 | return result | |
| 527 | }) | |
| 528 | ||
| 529 | // Plugin discovery. The catalogue is metadata-only: installing still runs | |
| 530 | // through the drive loader's grant + escalation path, and `kind: "app"` | |
| 531 | // entries (anonGPT) simply open as hyper:// apps. | |
| 532 | rpc.handle(C.CMD_PLUGIN_CATALOG, async () => { | |
| 533 | await whenReady() | |
| 534 | if (!pluginCatalog) return { entries: [], sources: [] } | |
| 535 | const installed = new Set(pearPlugins ? pearPlugins.list().map(p => p.id) : []) | |
| 536 | return { | |
| 537 | entries: pluginCatalog.entries().map(entry => ({ | |
| 538 | ...entry, | |
| 539 | installed: !!(entry.driveKey && installed.has(entry.driveKey)) | |
| 540 | })), | |
| 541 | sources: pluginCatalog.sources() | |
| 542 | } | |
| 543 | }) | |
| 544 | ||
| 545 | rpc.handle(C.CMD_PLUGIN_CATALOG_LOAD_DRIVE, async (data = {}) => { | |
| 546 | await whenReady() | |
| 547 | if (!pluginCatalog) throw new Error('Plugin catalogue not available') | |
| 548 | const result = await pluginCatalog.loadFromDrive(data.driveKey) | |
| 549 | await persistPluginCatalog() | |
| 550 | return { ...result, sources: pluginCatalog.sources() } | |
| 551 | }) | |
| 552 | ||
| 553 | rpc.handle(C.CMD_PLUGIN_CATALOG_REMOVE_SOURCE, async (data = {}) => { | |
| 554 | await whenReady() | |
| 555 | if (!pluginCatalog) throw new Error('Plugin catalogue not available') | |
| 556 | const removed = pluginCatalog.removeSource(data.driveKey) | |
| 557 | await persistPluginCatalog() | |
| 558 | return { removed, sources: pluginCatalog.sources() } | |
| 559 | }) | |
| 560 | ||
| 561 | function persistPluginCatalog () { | |
| 562 | if (!pluginCatalog || !userData) return Promise.resolve() | |
| 563 | return userData.setSettings({ contentShieldPluginCatalog: pluginCatalog.exportState() }).catch(() => {}) | |
| 564 | } | |
| 565 | ||
| 566 | function persistPluginInstallRecords () { | |
| 567 | if (!pluginDriveLoader || !userData) return Promise.resolve() | |
| 568 | const records = {} | |
| 569 | for (const record of pluginDriveLoader.installs()) { | |
| 570 | records[record.driveKey] = { | |
| 571 | granted: record.granted, | |
| 572 | version: record.version, | |
| 573 | installedAt: record.installedAt, | |
| 574 | escalated: record.escalated | |
| 575 | } | |
| 576 | } | |
| 577 | return userData.setSettings({ contentShieldPluginInstalls: records }).catch(() => {}) | |
| 578 | } | |
| 579 | ||
| 460 | 580 | rpc.handle(C.CMD_GET_IDENTITY, async () => { |
| 461 | 581 | return { |
| 462 | 582 | publicKey: swarm ? swarm.keyPair.publicKey.toString('hex') : null, |
| … | … | @@ -569,7 +689,7 @@ rpc.handle(C.CMD_LAUNCH_APP, async (data) => { |
| 569 | 689 | appId: data.id, |
| 570 | 690 | name: app.name, |
| 571 | 691 | driveKey, |
| 572 | apiToken: proxy?.issueApiToken ? proxy.issueApiToken(driveKey, { origin: new URL(localUrl).origin }) : null, | |
| 692 | apiToken: proxy?.issueApiToken ? proxy.issueApiToken(driveKey, { origin: localUrl }) : null, | |
| 573 | 693 | contextToken: proxy?.pageContextToken ? proxy.pageContextToken(driveKey) : null |
| 574 | 694 | } |
| 575 | 695 | }) |
| … | … | @@ -3050,9 +3170,68 @@ async function boot () { |
| 3050 | 3170 | try { pearPlugins.register(payload) } catch {} |
| 3051 | 3171 | } |
| 3052 | 3172 | } |
| 3173 | ||
| 3174 | // P2P distribution (Phase 2/3 gates). Rule text and plugin payloads were | |
| 3175 | // already rehydrated above, so both work fully offline; these two own | |
| 3176 | // the drive-sourced metadata and the hot-swap lifecycle. | |
| 3177 | const sha256Hex = (buf) => require('crypto').createHash('sha256').update(buf).digest('hex') | |
| 3178 | shieldListSync = new ShieldListSync({ | |
| 3179 | shield: contentShield, | |
| 3180 | fetchDriveFile: (keyHex, path) => proxy._hybridFetch(keyHex, path), | |
| 3181 | sha256Hex, | |
| 3182 | persistMeta: async (meta) => { | |
| 3183 | if (userData) await userData.setSettings({ contentShieldListSync: meta }) | |
| 3184 | } | |
| 3185 | }) | |
| 3186 | if (shieldSettings && shieldSettings.contentShieldListSync) { | |
| 3187 | shieldListSync.restore(shieldSettings.contentShieldListSync) | |
| 3188 | } | |
| 3189 | pluginDriveLoader = new PluginDriveLoader({ | |
| 3190 | registry: pearPlugins, | |
| 3191 | fetchDriveFile: (keyHex, path) => proxy._hybridFetch(keyHex, path), | |
| 3192 | persistInstall: async (id, payload) => { | |
| 3193 | if (payload === null) { | |
| 3194 | delete persistShieldState._pluginPayloads[id] | |
| 3195 | } else if (payload.__recordPatch) { | |
| 3196 | const existing = persistShieldState._pluginPayloads[id] | |
| 3197 | if (existing) existing.enabled = false // escalation guard disabled it | |
| 3198 | } else { | |
| 3199 | persistShieldState._pluginPayloads[id] = { | |
| 3200 | id, | |
| 3201 | manifest: payload.manifest, | |
| 3202 | contribution: payload.contribution, | |
| 3203 | enabled: payload.enabled !== false | |
| 3204 | } | |
| 3205 | } | |
| 3206 | await persistPluginInstallRecords() | |
| 3207 | } | |
| 3208 | }) | |
| 3209 | if (shieldSettings && shieldSettings.contentShieldPluginInstalls) { | |
| 3210 | pluginDriveLoader.restore(shieldSettings.contentShieldPluginInstalls) | |
| 3211 | } | |
| 3212 | pluginCatalog = new PluginCatalog({ | |
| 3213 | fetchDriveFile: (keyHex, path) => proxy._hybridFetch(keyHex, path) | |
| 3214 | }) | |
| 3215 | if (shieldSettings && shieldSettings.contentShieldPluginCatalog) { | |
| 3216 | pluginCatalog.restore(shieldSettings.contentShieldPluginCatalog) | |
| 3217 | } | |
| 3053 | 3218 | } catch {} |
| 3054 | 3219 | proxy.setContentShield(contentShield) |
| 3055 | 3220 | |
| 3221 | // Background list refresh: first sweep shortly after boot (drives need the | |
| 3222 | // swarm), then periodic hot-swap checks. Failures never disturb browsing. | |
| 3223 | if (shieldListSync) { | |
| 3224 | const firstSweep = setTimeout(() => { | |
| 3225 | shieldListSync.refreshAll() | |
| 3226 | .then((outcomes) => { | |
| 3227 | if (outcomes.some(o => o.ok && o.changed)) return persistShieldState() | |
| 3228 | }) | |
| 3229 | .catch(() => {}) | |
| 3230 | }, 30 * 1000) | |
| 3231 | if (typeof firstSweep?.unref === 'function') firstSweep.unref() | |
| 3232 | shieldListSync.startAutoRefresh(30 * 60 * 1000) | |
| 3233 | } | |
| 3234 | ||
| 3056 | 3235 | // Session bridge + privacy ladder (Phases 4–5). Clearnet navigations route |
| 3057 | 3236 | // through the browser-owned /clearnet/* proxy by default so Content Shield |
| 3058 | 3237 | // sees every request. Direct mode (real https load) is a settings opt-in. |
| … | … | @@ -3195,6 +3374,7 @@ async function ensureDevCatalogue () { |
| 3195 | 3374 | |
| 3196 | 3375 | async function shutdown () { |
| 3197 | 3376 | if (storageTimer) { clearInterval(storageTimer); storageTimer = null } |
| 3377 | try { shieldListSync?.stop() } catch {} | |
| 3198 | 3378 | try { await askBrowserService.close() } catch {} |
| 3199 | 3379 | if (aiService) { try { await aiService.close() } catch {}; aiService = null } |
| 3200 | 3380 | if (swarmBridge) { try { await swarmBridge.destroy() } catch {} swarmBridge = null } |
| … | … | @@ -0,0 +1,251 @@ |
| 1 | 'use strict' | |
| 2 | ||
| 3 | /** | |
| 4 | * Plugin catalogue — discovery for Pear Plugins and AI add-on apps. | |
| 5 | * | |
| 6 | * The catalogue answers "what can I install?" the way the app store answers | |
| 7 | * it for apps: a browser-shipped builtin seed plus any number of | |
| 8 | * P2P-distributed catalogue drives the user subscribes to (a drive with a | |
| 9 | * /plugins.json). Entries are metadata only — INSTALLING still goes through | |
| 10 | * the plugin drive loader with its capability grant + escalation guard, and | |
| 11 | * `kind: "app"` entries (like anonGPT) open as ordinary hyper:// apps whose | |
| 12 | * own manifest gates their powers. The catalogue never grants anything. | |
| 13 | * | |
| 14 | * /plugins.json format on a catalogue drive: | |
| 15 | * | |
| 16 | * { | |
| 17 | * "name": "Pear Plugins", | |
| 18 | * "plugins": [ | |
| 19 | * { | |
| 20 | * "driveKey": "<64-hex>", | |
| 21 | * "kind": "plugin" | "app", | |
| 22 | * "name": "Pear Dark Reader", | |
| 23 | * "description": "…", | |
| 24 | * "author": "…", | |
| 25 | * "capabilities": ["pear.content.styles"] | |
| 26 | * } | |
| 27 | * ] | |
| 28 | * } | |
| 29 | */ | |
| 30 | ||
| 31 | const { ANONGPT_DRIVE_KEY } = require('./constants.js') | |
| 32 | ||
| 33 | const MAX_CATALOG_BYTES = 256 * 1024 | |
| 34 | const MAX_ENTRIES_PER_SOURCE = 200 | |
| 35 | const ENTRY_KINDS = new Set(['plugin', 'app']) | |
| 36 | ||
| 37 | /** | |
| 38 | * Browser-shipped seed. anonGPT ships with its production drive key so a | |
| 39 | * user can add it on their own; the example plugins list without keys until | |
| 40 | * their drives are published (Install stays disabled — fail closed). | |
| 41 | */ | |
| 42 | const BUILTIN_PLUGIN_CATALOG = Object.freeze([ | |
| 43 | Object.freeze({ | |
| 44 | id: 'anongpt', | |
| 45 | kind: 'app', | |
| 46 | driveKey: ANONGPT_DRIVE_KEY, | |
| 47 | name: 'anonGPT', | |
| 48 | description: 'Private AI chat as a P2P app. Uses the window.pear.anongpt bridge; PearBrowser verifies the app\'s no-prompt-storage privacy manifest before the bridge is injected, and only on this drive. Open it and it works.', | |
| 49 | author: 'anonGPT', | |
| 50 | capabilities: Object.freeze(['pear.anongpt.infer']), | |
| 51 | verified: true | |
| 52 | }), | |
| 53 | Object.freeze({ | |
| 54 | id: 'pear-dark-reader', | |
| 55 | kind: 'plugin', | |
| 56 | driveKey: 'bbde8330169798dc5e0d08f8909b407cea2f8fec7e31d6241f479c714ad42082', // published 2026-07-16, storage seed-pear-dark-reader | |
| 57 | name: 'Pear Dark Reader', | |
| 58 | description: 'Dark, high-contrast theme for every hyper:// page. Styles only — no scripts, no network rules.', | |
| 59 | author: 'PearBrowser examples', | |
| 60 | capabilities: Object.freeze(['pear.content.styles']), | |
| 61 | verified: true | |
| 62 | }), | |
| 63 | Object.freeze({ | |
| 64 | id: 'peerit-enhancer', | |
| 65 | kind: 'plugin', | |
| 66 | driveKey: '1b21d8a6960bdcdfb76da94b80dae0d1a28247516de87e6839ea2f87bb609e10', // published 2026-07-16, storage seed-peerit-enhancer | |
| 67 | name: 'peerit Enhancer', | |
| 68 | description: 'Compact rows, reading-width cap, and j/k keyboard navigation for peerit. Scoped to the peerit drive only.', | |
| 69 | author: 'PearBrowser examples', | |
| 70 | capabilities: Object.freeze(['pear.content.styles', 'pear.content.scripts', 'pear.net.filter']), | |
| 71 | verified: true | |
| 72 | }) | |
| 73 | ]) | |
| 74 | ||
| 75 | class PluginCatalogError extends Error { | |
| 76 | constructor (code, message) { | |
| 77 | super(message) | |
| 78 | this.name = 'PluginCatalogError' | |
| 79 | this.code = code | |
| 80 | } | |
| 81 | } | |
| 82 | ||
| 83 | class PluginCatalog { | |
| 84 | /** | |
| 85 | * @param {object} opts | |
| 86 | * @param {function} opts.fetchDriveFile — async (driveKeyHex, path) => { content: Buffer } | null | |
| 87 | * @param {Array} [opts.builtin] — override the seed (tests) | |
| 88 | * @param {function} [opts.now] | |
| 89 | */ | |
| 90 | constructor (opts = {}) { | |
| 91 | if (typeof opts.fetchDriveFile !== 'function') { | |
| 92 | throw new TypeError('PluginCatalog requires a fetchDriveFile transport') | |
| 93 | } | |
| 94 | this._fetch = opts.fetchDriveFile | |
| 95 | this._builtin = (Array.isArray(opts.builtin) ? opts.builtin : BUILTIN_PLUGIN_CATALOG) | |
| 96 | .map(entry => validateCatalogEntry(entry)) | |
| 97 | .filter(Boolean) | |
| 98 | this._sources = new Map() // driveKeyHex -> { name, entries, loadedAt } | |
| 99 | this._now = typeof opts.now === 'function' ? opts.now : Date.now | |
| 100 | } | |
| 101 | ||
| 102 | /** Merged view: builtin first, then source entries not shadowed by them. */ | |
| 103 | entries () { | |
| 104 | const merged = [] | |
| 105 | const seen = new Set() | |
| 106 | const push = (entry, source) => { | |
| 107 | const dedupeKey = entry.driveKey || `id:${entry.id}` | |
| 108 | if (seen.has(dedupeKey)) return | |
| 109 | seen.add(dedupeKey) | |
| 110 | merged.push({ ...entry, source }) | |
| 111 | } | |
| 112 | for (const entry of this._builtin) push(entry, 'builtin') | |
| 113 | for (const [sourceKey, record] of this._sources) { | |
| 114 | for (const entry of record.entries) push(entry, sourceKey) | |
| 115 | } | |
| 116 | return merged | |
| 117 | } | |
| 118 | ||
| 119 | sources () { | |
| 120 | return [...this._sources.entries()].map(([driveKey, record]) => ({ | |
| 121 | driveKey, | |
| 122 | name: record.name, | |
| 123 | entryCount: record.entries.length, | |
| 124 | loadedAt: record.loadedAt | |
| 125 | })) | |
| 126 | } | |
| 127 | ||
| 128 | /** Subscribe to a catalogue drive: fetch, validate, register (replaces). */ | |
| 129 | async loadFromDrive (driveKey) { | |
| 130 | const key = normalizeDriveKey(driveKey) | |
| 131 | if (!key) throw new PluginCatalogError('invalid-drive-key', 'A 64-hex catalogue drive key is required') | |
| 132 | ||
| 133 | const fetched = await this._fetch(key, '/plugins.json') | |
| 134 | const content = fetched && fetched.content ? fetched.content : null | |
| 135 | if (!content || content.length === 0) { | |
| 136 | throw new PluginCatalogError('catalog-unavailable', `No /plugins.json on drive ${key.slice(0, 12)}…`) | |
| 137 | } | |
| 138 | if (content.length > MAX_CATALOG_BYTES) { | |
| 139 | throw new PluginCatalogError('catalog-too-large', `Catalogue exceeds ${MAX_CATALOG_BYTES} bytes`) | |
| 140 | } | |
| 141 | ||
| 142 | let parsed | |
| 143 | try { | |
| 144 | parsed = JSON.parse(content.toString('utf8')) | |
| 145 | } catch { | |
| 146 | throw new PluginCatalogError('catalog-invalid', 'Catalogue is not valid JSON') | |
| 147 | } | |
| 148 | const rawEntries = Array.isArray(parsed?.plugins) ? parsed.plugins : null | |
| 149 | if (!rawEntries) throw new PluginCatalogError('catalog-invalid', 'Catalogue has no plugins array') | |
| 150 | ||
| 151 | const entries = rawEntries | |
| 152 | .slice(0, MAX_ENTRIES_PER_SOURCE) | |
| 153 | .map(entry => validateCatalogEntry(entry, { requireDriveKey: true })) | |
| 154 | .filter(Boolean) | |
| 155 | if (!entries.length) throw new PluginCatalogError('catalog-empty', 'Catalogue contains no valid entries') | |
| 156 | ||
| 157 | const record = { | |
| 158 | name: cleanString(parsed.name, 120) || `catalogue ${key.slice(0, 8)}…`, | |
| 159 | entries, | |
| 160 | loadedAt: this._now() | |
| 161 | } | |
| 162 | this._sources.set(key, record) | |
| 163 | return { driveKey: key, name: record.name, entryCount: entries.length } | |
| 164 | } | |
| 165 | ||
| 166 | removeSource (driveKey) { | |
| 167 | const key = normalizeDriveKey(driveKey) | |
| 168 | return !!key && this._sources.delete(key) | |
| 169 | } | |
| 170 | ||
| 171 | /** Durable snapshot of subscribed sources (builtin never persists). */ | |
| 172 | exportState () { | |
| 173 | const sources = {} | |
| 174 | for (const [key, record] of this._sources) sources[key] = record | |
| 175 | return { sources } | |
| 176 | } | |
| 177 | ||
| 178 | restore (state) { | |
| 179 | if (!state || typeof state !== 'object' || !state.sources || typeof state.sources !== 'object') return 0 | |
| 180 | let restored = 0 | |
| 181 | for (const [key, record] of Object.entries(state.sources)) { | |
| 182 | const driveKey = normalizeDriveKey(key) | |
| 183 | if (!driveKey || !record || typeof record !== 'object') continue | |
| 184 | const entries = Array.isArray(record.entries) | |
| 185 | ? record.entries.map(entry => validateCatalogEntry(entry, { requireDriveKey: true })).filter(Boolean) | |
| 186 | : [] | |
| 187 | if (!entries.length) continue | |
| 188 | this._sources.set(driveKey, { | |
| 189 | name: cleanString(record.name, 120) || `catalogue ${driveKey.slice(0, 8)}…`, | |
| 190 | entries, | |
| 191 | loadedAt: Number.isFinite(record.loadedAt) ? record.loadedAt : 0 | |
| 192 | }) | |
| 193 | restored++ | |
| 194 | } | |
| 195 | return restored | |
| 196 | } | |
| 197 | } | |
| 198 | ||
| 199 | /** | |
| 200 | * Validate one catalogue entry into the canonical shape, or null. Catalogue | |
| 201 | * capabilities are DISPLAY metadata (what the consent UI previews); the real | |
| 202 | * grant always comes from the plugin drive's own manifest at install time. | |
| 203 | */ | |
| 204 | function validateCatalogEntry (entry, opts = {}) { | |
| 205 | if (!entry || typeof entry !== 'object') return null | |
| 206 | const name = cleanString(entry.name, 120) | |
| 207 | if (!name) return null | |
| 208 | ||
| 209 | const driveKey = normalizeDriveKey(entry.driveKey) | |
| 210 | if (opts.requireDriveKey && !driveKey) return null | |
| 211 | ||
| 212 | const kind = ENTRY_KINDS.has(entry.kind) ? entry.kind : 'plugin' | |
| 213 | const id = cleanString(entry.id, 64).toLowerCase() || driveKey || slugify(name) | |
| 214 | if (!id) return null | |
| 215 | ||
| 216 | const capabilities = Array.isArray(entry.capabilities) | |
| 217 | ? entry.capabilities.map(cap => cleanString(cap, 64)).filter(Boolean).slice(0, 8) | |
| 218 | : [] | |
| 219 | ||
| 220 | return { | |
| 221 | id, | |
| 222 | kind, | |
| 223 | driveKey, | |
| 224 | name, | |
| 225 | description: cleanString(entry.description, 500), | |
| 226 | author: cleanString(entry.author, 120), | |
| 227 | capabilities, | |
| 228 | verified: entry.verified === true, | |
| 229 | ...(typeof entry.unpublished === 'string' && entry.unpublished ? { unpublished: cleanString(entry.unpublished, 200) } : {}) | |
| 230 | } | |
| 231 | } | |
| 232 | ||
| 233 | function slugify (value) { | |
| 234 | return cleanString(value, 64).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') | |
| 235 | } | |
| 236 | ||
| 237 | function normalizeDriveKey (value) { | |
| 238 | const text = typeof value === 'string' ? value.trim().toLowerCase() : '' | |
| 239 | return /^[0-9a-f]{64}$/.test(text) ? text : '' | |
| 240 | } | |
| 241 | ||
| 242 | function cleanString (value, maxChars) { | |
| 243 | return typeof value === 'string' ? value.trim().slice(0, maxChars) : '' | |
| 244 | } | |
| 245 | ||
| 246 | module.exports = { | |
| 247 | PluginCatalog, | |
| 248 | PluginCatalogError, | |
| 249 | BUILTIN_PLUGIN_CATALOG, | |
| 250 | validateCatalogEntry | |
| 251 | } |
| … | … | @@ -0,0 +1,329 @@ |
| 1 | 'use strict' | |
| 2 | ||
| 3 | /** | |
| 4 | * Plugin drive loader — installs Pear Plugins from Hyperdrives (Phase 3 gate | |
| 5 | * of docs/BROWSER_PARITY_PLAN.md). | |
| 6 | * | |
| 7 | * A plugin drive looks like an ordinary Pear app drive: | |
| 8 | * | |
| 9 | * /manifest.json pear.plugin metadata + capabilities (see pear-plugins.cjs) | |
| 10 | * /style.css optional content styles (path declared in the manifest) | |
| 11 | * /content.js optional content script | |
| 12 | * /filters.txt optional network filter contribution | |
| 13 | * | |
| 14 | * The manifest declares which assets exist and where they match: | |
| 15 | * | |
| 16 | * { | |
| 17 | * "name": "Dark Reader", | |
| 18 | * "version": "1.0.0", | |
| 19 | * "pear": { | |
| 20 | * "plugin": { | |
| 21 | * "capabilities": ["pear.content.styles"], | |
| 22 | * "content": { | |
| 23 | * "styles": { "matches": ["*"], "path": "/style.css" }, | |
| 24 | * "scripts": { "matches": ["*"], "path": "/content.js" }, | |
| 25 | * "filters": "/filters.txt" | |
| 26 | * } | |
| 27 | * } | |
| 28 | * } | |
| 29 | * } | |
| 30 | * | |
| 31 | * The loader fetches the declared assets, builds the inline contribution the | |
| 32 | * registry expects, and registers under the drive key. Grants are captured at | |
| 33 | * install time; an update whose manifest requests capabilities beyond the | |
| 34 | * recorded grant is an ESCALATION — the plugin is disabled and flagged for | |
| 35 | * explicit re-consent instead of silently gaining power. (A Hyperdrive | |
| 36 | * update arrives over the swarm with no user action, so this guard is what | |
| 37 | * makes P2P-distributed plugins safe.) | |
| 38 | */ | |
| 39 | ||
| 40 | const MAX_ASSET_BYTES = 512 * 1024 | |
| 41 | const MAX_FILTER_BYTES = 2 * 1024 * 1024 | |
| 42 | const MAX_MANIFEST_BYTES = 256 * 1024 | |
| 43 | ||
| 44 | class PluginDriveError extends Error { | |
| 45 | constructor (code, message) { | |
| 46 | super(message) | |
| 47 | this.name = 'PluginDriveError' | |
| 48 | this.code = code | |
| 49 | } | |
| 50 | } | |
| 51 | ||
| 52 | class PluginDriveLoader { | |
| 53 | /** | |
| 54 | * @param {object} opts | |
| 55 | * @param {object} opts.registry — PearPluginRegistry | |
| 56 | * @param {function} opts.fetchDriveFile — async (driveKeyHex, path) => { content: Buffer } | null | |
| 57 | * @param {function} [opts.persistInstall] — async (id, payload|null) => void; null clears | |
| 58 | * @param {function} [opts.now] | |
| 59 | */ | |
| 60 | constructor (opts = {}) { | |
| 61 | if (!opts.registry || typeof opts.registry.register !== 'function') { | |
| 62 | throw new TypeError('PluginDriveLoader requires a PearPluginRegistry') | |
| 63 | } | |
| 64 | if (typeof opts.fetchDriveFile !== 'function') { | |
| 65 | throw new TypeError('PluginDriveLoader requires a fetchDriveFile transport') | |
| 66 | } | |
| 67 | this._registry = opts.registry | |
| 68 | this._fetch = opts.fetchDriveFile | |
| 69 | this._persistInstall = typeof opts.persistInstall === 'function' ? opts.persistInstall : async () => {} | |
| 70 | this._now = typeof opts.now === 'function' ? opts.now : Date.now | |
| 71 | this._installs = new Map() // driveKeyHex -> { granted, version, escalated: {added}|null } | |
| 72 | } | |
| 73 | ||
| 74 | /** Rehydrate install records (grants + versions) from durable settings. */ | |
| 75 | restore (records) { | |
| 76 | if (!records || typeof records !== 'object') return 0 | |
| 77 | let restored = 0 | |
| 78 | for (const [key, record] of Object.entries(records)) { | |
| 79 | const driveKey = normalizeDriveKey(key) | |
| 80 | if (!driveKey || !record || typeof record !== 'object') continue | |
| 81 | this._installs.set(driveKey, { | |
| 82 | granted: normalizeCapabilityList(record.granted), | |
| 83 | version: cleanString(record.version), | |
| 84 | installedAt: Number.isFinite(record.installedAt) ? record.installedAt : 0, | |
| 85 | escalated: record.escalated && Array.isArray(record.escalated.added) | |
| 86 | ? { added: normalizeCapabilityList(record.escalated.added) } | |
| 87 | : null | |
| 88 | }) | |
| 89 | restored++ | |
| 90 | } | |
| 91 | return restored | |
| 92 | } | |
| 93 | ||
| 94 | installs () { | |
| 95 | return [...this._installs.entries()].map(([driveKey, record]) => ({ driveKey, ...record })) | |
| 96 | } | |
| 97 | ||
| 98 | installRecord (driveKey) { | |
| 99 | const key = normalizeDriveKey(driveKey) | |
| 100 | return key ? this._installs.get(key) || null : null | |
| 101 | } | |
| 102 | ||
| 103 | /** | |
| 104 | * Install a plugin from its drive. The user's grant is the manifest's | |
| 105 | * capability set at install time (the consent surface shows exactly this | |
| 106 | * list before calling install). | |
| 107 | */ | |
| 108 | async installFromDrive (driveKey, opts = {}) { | |
| 109 | const key = normalizeDriveKey(driveKey) | |
| 110 | if (!key) throw new PluginDriveError('invalid-drive-key', 'A 64-hex plugin drive key is required') | |
| 111 | ||
| 112 | const loaded = await this._load(key) | |
| 113 | const requested = loaded.capabilities | |
| 114 | const granted = opts.grantedCapabilities !== undefined | |
| 115 | ? normalizeCapabilityList(opts.grantedCapabilities).filter(cap => requested.includes(cap)) | |
| 116 | : requested | |
| 117 | ||
| 118 | const result = this._registry.register({ | |
| 119 | id: key, | |
| 120 | manifest: withCapabilities(loaded.manifest, granted), | |
| 121 | contribution: loaded.contribution, | |
| 122 | enabled: opts.enabled !== false | |
| 123 | }) | |
| 124 | if (!result || result.ok !== true) { | |
| 125 | throw new PluginDriveError('register-failed', (result && result.reason) || 'Plugin registration failed') | |
| 126 | } | |
| 127 | ||
| 128 | const record = { | |
| 129 | granted, | |
| 130 | version: loaded.version, | |
| 131 | installedAt: this._now(), | |
| 132 | escalated: null | |
| 133 | } | |
| 134 | this._installs.set(key, record) | |
| 135 | await this._persist(key, { | |
| 136 | id: key, | |
| 137 | manifest: withCapabilities(loaded.manifest, granted), | |
| 138 | contribution: loaded.contribution, | |
| 139 | enabled: opts.enabled !== false, | |
| 140 | granted, | |
| 141 | version: loaded.version, | |
| 142 | installedAt: record.installedAt | |
| 143 | }) | |
| 144 | return { ok: true, driveKey: key, name: result.name, version: loaded.version, granted, applied: result.applied } | |
| 145 | } | |
| 146 | ||
| 147 | /** | |
| 148 | * Update an installed plugin from its drive. Fail-closed on capability | |
| 149 | * escalation: the plugin is disabled, the escalation is recorded, and the | |
| 150 | * caller gets the added capabilities to show in a re-consent prompt. | |
| 151 | * Passing `acceptEscalation: true` re-grants to the new capability set. | |
| 152 | */ | |
| 153 | async updateFromDrive (driveKey, opts = {}) { | |
| 154 | const key = normalizeDriveKey(driveKey) | |
| 155 | const record = key ? this._installs.get(key) : null | |
| 156 | if (!record) throw new PluginDriveError('not-installed', 'Plugin is not installed from a drive') | |
| 157 | ||
| 158 | const loaded = await this._load(key) | |
| 159 | const added = loaded.capabilities.filter(cap => !record.granted.includes(cap)) | |
| 160 | ||
| 161 | if (added.length > 0 && !opts.acceptEscalation) { | |
| 162 | this._registry.setEnabled(key, false) | |
| 163 | record.escalated = { added } | |
| 164 | await this._persistRecordOnly(key, record) | |
| 165 | return { | |
| 166 | ok: false, | |
| 167 | escalated: true, | |
| 168 | driveKey: key, | |
| 169 | added, | |
| 170 | version: loaded.version, | |
| 171 | message: 'Update requests new capabilities; plugin disabled pending re-consent' | |
| 172 | } | |
| 173 | } | |
| 174 | ||
| 175 | const granted = added.length > 0 ? loaded.capabilities : record.granted | |
| 176 | const result = this._registry.register({ | |
| 177 | id: key, | |
| 178 | manifest: withCapabilities(loaded.manifest, granted), | |
| 179 | contribution: loaded.contribution, | |
| 180 | enabled: true | |
| 181 | }) | |
| 182 | if (!result || result.ok !== true) { | |
| 183 | throw new PluginDriveError('register-failed', (result && result.reason) || 'Plugin registration failed') | |
| 184 | } | |
| 185 | ||
| 186 | record.granted = granted | |
| 187 | record.version = loaded.version | |
| 188 | record.escalated = null | |
| 189 | await this._persist(key, { | |
| 190 | id: key, | |
| 191 | manifest: withCapabilities(loaded.manifest, granted), | |
| 192 | contribution: loaded.contribution, | |
| 193 | enabled: true, | |
| 194 | granted, | |
| 195 | version: loaded.version, | |
| 196 | installedAt: record.installedAt || this._now() | |
| 197 | }) | |
| 198 | return { ok: true, driveKey: key, version: loaded.version, granted, escalationAccepted: added.length > 0 } | |
| 199 | } | |
| 200 | ||
| 201 | async uninstall (driveKey) { | |
| 202 | const key = normalizeDriveKey(driveKey) | |
| 203 | if (!key) return { removed: false } | |
| 204 | const known = this._installs.delete(key) | |
| 205 | const removed = this._registry.unregister(key) || known | |
| 206 | await this._persist(key, null) | |
| 207 | return { removed: !!removed, driveKey: key } | |
| 208 | } | |
| 209 | ||
| 210 | async _load (key) { | |
| 211 | const manifestFile = await this._fetch(key, '/manifest.json') | |
| 212 | const manifestBytes = manifestFile && manifestFile.content ? manifestFile.content : null | |
| 213 | if (!manifestBytes || manifestBytes.length === 0) { | |
| 214 | throw new PluginDriveError('manifest-unavailable', `No /manifest.json on drive ${key.slice(0, 12)}…`) | |
| 215 | } | |
| 216 | if (manifestBytes.length > MAX_MANIFEST_BYTES) { | |
| 217 | throw new PluginDriveError('manifest-too-large', 'Plugin manifest exceeds the size cap') | |
| 218 | } | |
| 219 | ||
| 220 | let manifest | |
| 221 | try { | |
| 222 | manifest = JSON.parse(manifestBytes.toString('utf8')) | |
| 223 | } catch { | |
| 224 | throw new PluginDriveError('manifest-invalid', 'Plugin manifest is not valid JSON') | |
| 225 | } | |
| 226 | ||
| 227 | const pear = manifest && manifest.pear && typeof manifest.pear === 'object' ? manifest.pear : {} | |
| 228 | const plugin = pear.plugin && typeof pear.plugin === 'object' ? pear.plugin : null | |
| 229 | if (!plugin) throw new PluginDriveError('not-a-plugin', 'Manifest does not declare pear.plugin') | |
| 230 | ||
| 231 | const capabilities = normalizeCapabilityList( | |
| 232 | [].concat(plugin.capabilities || [], pear.capabilities || [], manifest.capabilities || []) | |
| 233 | ) | |
| 234 | const content = plugin.content && typeof plugin.content === 'object' ? plugin.content : {} | |
| 235 | const contribution = {} | |
| 236 | ||
| 237 | if (content.styles && typeof content.styles === 'object' && typeof content.styles.path === 'string') { | |
| 238 | const css = await this._readAsset(key, content.styles.path, MAX_ASSET_BYTES) | |
| 239 | contribution.styles = { matches: normalizeMatches(content.styles.matches), css } | |
| 240 | } | |
| 241 | if (content.scripts && typeof content.scripts === 'object' && typeof content.scripts.path === 'string') { | |
| 242 | const js = await this._readAsset(key, content.scripts.path, MAX_ASSET_BYTES) | |
| 243 | contribution.scripts = { matches: normalizeMatches(content.scripts.matches), js } | |
| 244 | } | |
| 245 | if (typeof content.filters === 'string' && content.filters.startsWith('/')) { | |
| 246 | contribution.filters = await this._readAsset(key, content.filters, MAX_FILTER_BYTES) | |
| 247 | } | |
| 248 | ||
| 249 | return { | |
| 250 | manifest, | |
| 251 | capabilities, | |
| 252 | contribution, | |
| 253 | version: cleanString(manifest.version) || '0.0.0' | |
| 254 | } | |
| 255 | } | |
| 256 | ||
| 257 | async _readAsset (key, path, maxBytes) { | |
| 258 | if (typeof path !== 'string' || !path.startsWith('/') || path.includes('..')) { | |
| 259 | throw new PluginDriveError('asset-path-invalid', `Invalid plugin asset path: ${path}`) | |
| 260 | } | |
| 261 | const fetched = await this._fetch(key, path) | |
| 262 | const content = fetched && fetched.content ? fetched.content : null | |
| 263 | if (!content || content.length === 0) { | |
| 264 | throw new PluginDriveError('asset-unavailable', `Declared plugin asset ${path} is missing`) | |
| 265 | } | |
| 266 | if (content.length > maxBytes) { | |
| 267 | throw new PluginDriveError('asset-too-large', `Plugin asset ${path} exceeds ${maxBytes} bytes`) | |
| 268 | } | |
| 269 | return content.toString('utf8') | |
| 270 | } | |
| 271 | ||
| 272 | async _persist (key, payload) { | |
| 273 | try { | |
| 274 | await this._persistInstall(key, payload) | |
| 275 | } catch {} | |
| 276 | } | |
| 277 | ||
| 278 | async _persistRecordOnly (key, record) { | |
| 279 | // Escalation state rides the same durable payload; merge via callback. | |
| 280 | try { | |
| 281 | await this._persistInstall(key, { | |
| 282 | __recordPatch: true, | |
| 283 | granted: record.granted, | |
| 284 | version: record.version, | |
| 285 | installedAt: record.installedAt, | |
| 286 | escalated: record.escalated | |
| 287 | }) | |
| 288 | } catch {} | |
| 289 | } | |
| 290 | } | |
| 291 | ||
| 292 | function withCapabilities (manifest, capabilities) { | |
| 293 | // The registry derives capabilities from the manifest; scope registration | |
| 294 | // to the GRANTED set so an ungranted capability never reaches the engine. | |
| 295 | const clone = JSON.parse(JSON.stringify(manifest || {})) | |
| 296 | if (!clone.pear || typeof clone.pear !== 'object') clone.pear = {} | |
| 297 | if (!clone.pear.plugin || typeof clone.pear.plugin !== 'object') clone.pear.plugin = {} | |
| 298 | clone.pear.plugin.capabilities = [...capabilities] | |
| 299 | delete clone.pear.capabilities | |
| 300 | delete clone.capabilities | |
| 301 | return clone | |
| 302 | } | |
| 303 | ||
| 304 | function normalizeCapabilityList (value) { | |
| 305 | if (!Array.isArray(value)) return [] | |
| 306 | return [...new Set(value.map(item => typeof item === 'string' ? item.trim() : '').filter(Boolean))] | |
| 307 | } | |
| 308 | ||
| 309 | function normalizeMatches (value) { | |
| 310 | if (!Array.isArray(value)) return ['*'] | |
| 311 | const matches = value.map(item => typeof item === 'string' ? item.trim().toLowerCase() : '').filter(Boolean) | |
| 312 | return matches.length ? matches : ['*'] | |
| 313 | } | |
| 314 | ||
| 315 | function normalizeDriveKey (value) { | |
| 316 | const text = typeof value === 'string' ? value.trim().toLowerCase() : '' | |
| 317 | return /^[0-9a-f]{64}$/.test(text) ? text : '' | |
| 318 | } | |
| 319 | ||
| 320 | function cleanString (value) { | |
| 321 | return typeof value === 'string' ? value.trim().slice(0, 256) : '' | |
| 322 | } | |
| 323 | ||
| 324 | module.exports = { | |
| 325 | PluginDriveLoader, | |
| 326 | PluginDriveError, | |
| 327 | MAX_ASSET_BYTES, | |
| 328 | MAX_FILTER_BYTES | |
| 329 | } |
| … | … | @@ -0,0 +1,254 @@ |
| 1 | 'use strict' | |
| 2 | ||
| 3 | /** | |
| 4 | * Shield list sync — P2P filter-list distribution (Phase 2 gate of | |
| 5 | * docs/BROWSER_PARITY_PLAN.md). | |
| 6 | * | |
| 7 | * A filter list is published as an ordinary Hyperdrive (see | |
| 8 | * filter-lists/README.md and scripts/publish-and-pin.js): | |
| 9 | * | |
| 10 | * /filters.txt the rules (Content Shield syntax subset) | |
| 11 | * /manifest.json optional: { name, version, filters, sha256 } | |
| 12 | * | |
| 13 | * The browser subscribes by drive key. Rule text is registered on the | |
| 14 | * shield under the namespaced list `drive:<key>` — which the shield | |
| 15 | * already persists through exportListState() — so a synced list keeps | |
| 16 | * working fully offline after first sync. This module owns only the | |
| 17 | * subscription metadata (version/hash bookkeeping) and the hot-swap. | |
| 18 | * | |
| 19 | * Transport, hashing, and persistence are injected so the module runs | |
| 20 | * identically under Bare (bare-crypto, hyper-proxy hybrid fetch, | |
| 21 | * user-data settings) and under node:test fakes. | |
| 22 | */ | |
| 23 | ||
| 24 | const DRIVE_LIST_PREFIX = 'drive:' | |
| 25 | const MAX_LIST_BYTES = 2 * 1024 * 1024 // a converted EasyList fits well within this | |
| 26 | const MAX_MANIFEST_BYTES = 64 * 1024 | |
| 27 | ||
| 28 | class ShieldListSyncError extends Error { | |
| 29 | constructor (code, message) { | |
| 30 | super(message) | |
| 31 | this.name = 'ShieldListSyncError' | |
| 32 | this.code = code | |
| 33 | } | |
| 34 | } | |
| 35 | ||
| 36 | class ShieldListSync { | |
| 37 | /** | |
| 38 | * @param {object} opts | |
| 39 | * @param {object} opts.shield — ContentShield (addList/removeList) | |
| 40 | * @param {function} opts.fetchDriveFile — async (driveKeyHex, path) => { content: Buffer } | null | |
| 41 | * @param {function} [opts.sha256Hex] — (bufferOrString) => hex string; required to verify checksums | |
| 42 | * @param {function} [opts.persistMeta] — async (metaByKey) => void | |
| 43 | * @param {function} [opts.now] — clock injection for tests | |
| 44 | */ | |
| 45 | constructor (opts = {}) { | |
| 46 | if (!opts.shield || typeof opts.shield.addList !== 'function') { | |
| 47 | throw new TypeError('ShieldListSync requires a ContentShield') | |
| 48 | } | |
| 49 | if (typeof opts.fetchDriveFile !== 'function') { | |
| 50 | throw new TypeError('ShieldListSync requires a fetchDriveFile transport') | |
| 51 | } | |
| 52 | this._shield = opts.shield | |
| 53 | this._fetch = opts.fetchDriveFile | |
| 54 | this._sha256Hex = typeof opts.sha256Hex === 'function' ? opts.sha256Hex : null | |
| 55 | this._persistMeta = typeof opts.persistMeta === 'function' ? opts.persistMeta : async () => {} | |
| 56 | this._now = typeof opts.now === 'function' ? opts.now : Date.now | |
| 57 | this._meta = new Map() // driveKeyHex -> { name, version, sha256, updatedAt, rules } | |
| 58 | this._timer = null | |
| 59 | } | |
| 60 | ||
| 61 | /** Rehydrate subscription metadata from durable settings (offline boot). */ | |
| 62 | restore (metaByKey) { | |
| 63 | if (!metaByKey || typeof metaByKey !== 'object') return 0 | |
| 64 | let restored = 0 | |
| 65 | for (const [key, meta] of Object.entries(metaByKey)) { | |
| 66 | const driveKey = normalizeDriveKey(key) | |
| 67 | if (!driveKey || !meta || typeof meta !== 'object') continue | |
| 68 | this._meta.set(driveKey, { | |
| 69 | name: cleanString(meta.name) || shortListName(driveKey), | |
| 70 | version: cleanString(meta.version), | |
| 71 | sha256: cleanString(meta.sha256), | |
| 72 | updatedAt: Number.isFinite(meta.updatedAt) ? meta.updatedAt : 0, | |
| 73 | rules: Number.isFinite(meta.rules) ? meta.rules : 0 | |
| 74 | }) | |
| 75 | restored++ | |
| 76 | } | |
| 77 | return restored | |
| 78 | } | |
| 79 | ||
| 80 | subscriptions () { | |
| 81 | return [...this._meta.entries()].map(([driveKey, meta]) => ({ | |
| 82 | driveKey, | |
| 83 | listName: DRIVE_LIST_PREFIX + driveKey, | |
| 84 | ...meta | |
| 85 | })) | |
| 86 | } | |
| 87 | ||
| 88 | isSubscribed (driveKey) { | |
| 89 | const key = normalizeDriveKey(driveKey) | |
| 90 | return !!key && this._meta.has(key) | |
| 91 | } | |
| 92 | ||
| 93 | /** | |
| 94 | * Subscribe to a filter-list drive: first fetch, verify, register, persist. | |
| 95 | * Throws ShieldListSyncError on transport/validation failure (fail-closed: | |
| 96 | * nothing is registered on error). | |
| 97 | */ | |
| 98 | async subscribe (driveKey) { | |
| 99 | const key = normalizeDriveKey(driveKey) | |
| 100 | if (!key) throw new ShieldListSyncError('invalid-drive-key', 'A 64-hex filter-list drive key is required') | |
| 101 | const result = await this._pull(key, { force: true }) | |
| 102 | return { driveKey: key, ...this._meta.get(key), ...result } | |
| 103 | } | |
| 104 | ||
| 105 | async unsubscribe (driveKey) { | |
| 106 | const key = normalizeDriveKey(driveKey) | |
| 107 | if (!key || !this._meta.has(key)) return { removed: false } | |
| 108 | this._meta.delete(key) | |
| 109 | this._shield.removeList(DRIVE_LIST_PREFIX + key) | |
| 110 | await this._persist() | |
| 111 | return { removed: true, driveKey: key } | |
| 112 | } | |
| 113 | ||
| 114 | /** | |
| 115 | * Re-fetch one subscription. Skips the rules download when the published | |
| 116 | * manifest version and checksum match what is already applied (hot-swap | |
| 117 | * only on change). `force` re-applies regardless. | |
| 118 | */ | |
| 119 | async refresh (driveKey, opts = {}) { | |
| 120 | const key = normalizeDriveKey(driveKey) | |
| 121 | if (!key || !this._meta.has(key)) { | |
| 122 | throw new ShieldListSyncError('not-subscribed', 'Not subscribed to that filter-list drive') | |
| 123 | } | |
| 124 | return this._pull(key, { force: !!opts.force }) | |
| 125 | } | |
| 126 | ||
| 127 | /** Refresh every subscription; per-drive failures never abort the sweep. */ | |
| 128 | async refreshAll (opts = {}) { | |
| 129 | const outcomes = [] | |
| 130 | for (const key of [...this._meta.keys()]) { | |
| 131 | try { | |
| 132 | const result = await this._pull(key, { force: !!opts.force }) | |
| 133 | outcomes.push({ driveKey: key, ok: true, ...result }) | |
| 134 | } catch (err) { | |
| 135 | outcomes.push({ driveKey: key, ok: false, code: err.code || 'refresh-failed', message: err.message }) | |
| 136 | } | |
| 137 | } | |
| 138 | return outcomes | |
| 139 | } | |
| 140 | ||
| 141 | /** Periodic background refresh. Cleared by stop(); never holds the loop open. */ | |
| 142 | startAutoRefresh (intervalMs) { | |
| 143 | const interval = Number.isFinite(intervalMs) && intervalMs > 0 ? intervalMs : 0 | |
| 144 | if (!interval) return false | |
| 145 | this.stop() | |
| 146 | this._timer = setInterval(() => { | |
| 147 | this.refreshAll().catch(() => {}) | |
| 148 | }, interval) | |
| 149 | if (typeof this._timer?.unref === 'function') this._timer.unref() | |
| 150 | return true | |
| 151 | } | |
| 152 | ||
| 153 | stop () { | |
| 154 | if (!this._timer) return | |
| 155 | clearInterval(this._timer) | |
| 156 | this._timer = null | |
| 157 | } | |
| 158 | ||
| 159 | async _pull (key, { force }) { | |
| 160 | const manifest = await this._readManifest(key) | |
| 161 | const existing = this._meta.get(key) | |
| 162 | ||
| 163 | if (!force && existing && manifest && manifest.version && manifest.version === existing.version && | |
| 164 | (!manifest.sha256 || manifest.sha256 === existing.sha256)) { | |
| 165 | return { changed: false, version: existing.version } | |
| 166 | } | |
| 167 | ||
| 168 | const filtersPath = manifest && manifest.filters ? manifest.filters : '/filters.txt' | |
| 169 | const fetched = await this._fetch(key, filtersPath) | |
| 170 | const content = fetched && fetched.content ? fetched.content : null | |
| 171 | if (!content || content.length === 0) { | |
| 172 | throw new ShieldListSyncError('list-unavailable', `Filter list ${filtersPath} not found on drive ${key.slice(0, 12)}…`) | |
| 173 | } | |
| 174 | if (content.length > MAX_LIST_BYTES) { | |
| 175 | throw new ShieldListSyncError('list-too-large', `Filter list exceeds ${MAX_LIST_BYTES} bytes`) | |
| 176 | } | |
| 177 | ||
| 178 | const text = content.toString('utf8') | |
| 179 | const contentHash = this._sha256Hex ? this._sha256Hex(content) : '' | |
| 180 | if (manifest && manifest.sha256 && this._sha256Hex && contentHash !== manifest.sha256) { | |
| 181 | throw new ShieldListSyncError('checksum-mismatch', 'Filter list bytes do not match the published sha256') | |
| 182 | } | |
| 183 | ||
| 184 | if (!force && existing && contentHash && existing.sha256 === contentHash) { | |
| 185 | // Same bytes under a version-less manifest — nothing to swap. | |
| 186 | this._meta.set(key, { ...existing, updatedAt: this._now() }) | |
| 187 | await this._persist() | |
| 188 | return { changed: false, version: existing.version } | |
| 189 | } | |
| 190 | ||
| 191 | const applied = this._shield.addList(DRIVE_LIST_PREFIX + key, text) | |
| 192 | const meta = { | |
| 193 | name: (manifest && cleanString(manifest.name)) || shortListName(key), | |
| 194 | version: (manifest && cleanString(manifest.version)) || '', | |
| 195 | sha256: (manifest && cleanString(manifest.sha256)) || contentHash, | |
| 196 | updatedAt: this._now(), | |
| 197 | rules: (applied && applied.blockRules + applied.exceptionRules + applied.cosmeticRules) || 0 | |
| 198 | } | |
| 199 | this._meta.set(key, meta) | |
| 200 | await this._persist() | |
| 201 | return { changed: true, version: meta.version, rules: meta.rules } | |
| 202 | } | |
| 203 | ||
| 204 | async _readManifest (key) { | |
| 205 | let fetched | |
| 206 | try { | |
| 207 | fetched = await this._fetch(key, '/manifest.json') | |
| 208 | } catch { | |
| 209 | return null | |
| 210 | } | |
| 211 | const content = fetched && fetched.content ? fetched.content : null | |
| 212 | if (!content || content.length === 0 || content.length > MAX_MANIFEST_BYTES) return null | |
| 213 | try { | |
| 214 | const parsed = JSON.parse(content.toString('utf8')) | |
| 215 | if (!parsed || typeof parsed !== 'object') return null | |
| 216 | return { | |
| 217 | name: cleanString(parsed.name), | |
| 218 | version: cleanString(parsed.version), | |
| 219 | sha256: cleanString(parsed.sha256).toLowerCase(), | |
| 220 | filters: typeof parsed.filters === 'string' && parsed.filters.startsWith('/') ? parsed.filters : null | |
| 221 | } | |
| 222 | } catch { | |
| 223 | return null | |
| 224 | } | |
| 225 | } | |
| 226 | ||
| 227 | async _persist () { | |
| 228 | const meta = {} | |
| 229 | for (const [key, value] of this._meta) meta[key] = value | |
| 230 | try { | |
| 231 | await this._persistMeta(meta) | |
| 232 | } catch {} | |
| 233 | } | |
| 234 | } | |
| 235 | ||
| 236 | function normalizeDriveKey (value) { | |
| 237 | const text = typeof value === 'string' ? value.trim().toLowerCase() : '' | |
| 238 | return /^[0-9a-f]{64}$/.test(text) ? text : '' | |
| 239 | } | |
| 240 | ||
| 241 | function cleanString (value) { | |
| 242 | return typeof value === 'string' ? value.trim().slice(0, 256) : '' | |
| 243 | } | |
| 244 | ||
| 245 | function shortListName (key) { | |
| 246 | return `list ${key.slice(0, 8)}…` | |
| 247 | } | |
| 248 | ||
| 249 | module.exports = { | |
| 250 | ShieldListSync, | |
| 251 | ShieldListSyncError, | |
| 252 | DRIVE_LIST_PREFIX, | |
| 253 | MAX_LIST_BYTES | |
| 254 | } |
| … | … | @@ -0,0 +1,36 @@ |
| 1 | { | |
| 2 | "name": "Pear Plugins", | |
| 3 | "description": "The browser-curated plugin catalogue. Publish this directory as a Hyperdrive (scripts/publish-and-pin.js catalogues/pear-plugins --name pear-plugins-catalog) and subscribers hot-load it from Settings. Entries with an empty driveKey are placeholders: publish the plugin drive first, paste its key here, bump nothing else, and re-publish this catalogue.", | |
| 4 | "plugins": [ | |
| 5 | { | |
| 6 | "id": "anongpt", | |
| 7 | "kind": "app", | |
| 8 | "driveKey": "e3cf8b6fae6260608cbfcdf6b82d985c65f5ad1b9c85e777e296e7c521213abc", | |
| 9 | "name": "anonGPT", | |
| 10 | "description": "Private AI chat as a P2P app. Uses the window.pear.anongpt bridge; PearBrowser verifies the app's no-prompt-storage privacy manifest before the bridge is injected, and only on this drive.", | |
| 11 | "author": "anonGPT", | |
| 12 | "capabilities": ["pear.anongpt.infer"], | |
| 13 | "verified": true | |
| 14 | }, | |
| 15 | { | |
| 16 | "id": "pear-dark-reader", | |
| 17 | "kind": "plugin", | |
| 18 | "driveKey": "bbde8330169798dc5e0d08f8909b407cea2f8fec7e31d6241f479c714ad42082", | |
| 19 | "name": "Pear Dark Reader", | |
| 20 | "description": "Dark, high-contrast theme for every hyper:// page. Styles only — no scripts, no network rules. Source: examples/plugins/dark-reader.", | |
| 21 | "author": "PearBrowser examples", | |
| 22 | "capabilities": ["pear.content.styles"], | |
| 23 | "verified": true | |
| 24 | }, | |
| 25 | { | |
| 26 | "id": "peerit-enhancer", | |
| 27 | "kind": "plugin", | |
| 28 | "driveKey": "1b21d8a6960bdcdfb76da94b80dae0d1a28247516de87e6839ea2f87bb609e10", | |
| 29 | "name": "peerit Enhancer", | |
| 30 | "description": "Compact rows, reading-width cap, and j/k keyboard navigation for peerit. Scoped to the peerit drive only. Source: examples/plugins/peerit-enhancer.", | |
| 31 | "author": "PearBrowser examples", | |
| 32 | "capabilities": ["pear.content.styles", "pear.content.scripts", "pear.net.filter"], | |
| 33 | "verified": true | |
| 34 | } | |
| 35 | ] | |
| 36 | } |
| … | … | @@ -1,7 +1,7 @@ |
| 1 | 1 | # PearBrowser → mainstream-browser parity plan |
| 2 | 2 | |
| 3 | Status: Phases 1–3 shipped; Phase 4 clearnet proxy + session-bridge facade shipped; Phase 5 privacy ladder shipped (HTTPS-only, tracking strip, farbling, cookie isolation in proxy mode). Native pear-electron session.webRequest still optional for direct-mode shields. | |
| 4 | Date: 2026-07-13 | |
| 3 | Status: Phases 1–3 shipped with gates closed; Phase 4 clearnet proxy + session-bridge facade shipped; Phase 5 privacy ladder shipped (HTTPS-only, tracking strip, farbling, cookie isolation in proxy mode). P2P distribution live 2026-07-16: filter-list drive subscriptions (`shield-list-sync.cjs`) and plugin installs from drives (`plugin-drive-loader.cjs` with the capability-escalation guard), plus the plugin catalogue (`plugin-catalog.cjs`, anonGPT in the builtin seed). All four distribution drives published, pinned, and fresh-peer verified — keys in `filter-lists/README.md`. Remaining: the native pear-electron session.webRequest bridge for direct-mode shields. | |
| 4 | Date: 2026-07-16 | |
| 5 | 5 | |
| 6 | 6 | The goal: make PearBrowser as capable a daily browser as Brave — ad blocking, |
| 7 | 7 | tracker protection, and a plugin/extension system — without giving up what |
| … | … | @@ -235,8 +235,8 @@ layer to host real web content: |
| 235 | 235 | | Phase | Scope | Gate to advance | |
| 236 | 236 | |---|---|---| |
| 237 | 237 | | 1 (done) | Shield engine + proxy wiring + settings + tests | full desktop suite green | |
| 238 | | 2 | P2P lists, shield panel, strict mode, allowlists | list drive pinned + hot-swap proven offline | | |
| 239 | | 3 | Plugin manifest vocabulary, content styles/scripts, panels, Permission Center | two real plugins (e.g. a dark-reader and a peerit enhancer) shipped from drives | | |
| 238 | | 2 (**gate closed 2026-07-16**) | P2P lists (`shield-list-sync.cjs`, subscribe/refresh/offline restore, Settings UI), shield panel, strict mode, allowlists | pear-default list drive published + pinned + fresh-peer verified (`842fb9e6…`, see `filter-lists/README.md`); hot-swap + offline proven in `test/shield-list-sync.test.js` | | |
| 239 | | 3 (**gate closed 2026-07-16**) | Plugin manifest vocabulary, drive installs (`plugin-drive-loader.cjs`), escalation guard, kill switch, Settings UI, plugin catalogue (`plugin-catalog.cjs` — builtin seed with anonGPT + drive-loadable `/plugins.json` sources, one-click install/open UI) | two real plugins shipped from drives: dark-reader `bbde8330…` + peerit-enhancer `1b21d8a6…`, catalogue drive `01b74736…`, all pinned + fresh-peer verified | | |
| 240 | 240 | | 4 | pear-electron session bridge, clearnet tabs, native shield | upstream decision or maintained patch + release smoke on all targets | |
| 241 | 241 | | 5 | privacy ladder (HTTPS-only, fingerprinting, cookies) | per-feature web-compat smoke | |
| 242 | 242 |
| … | … | @@ -0,0 +1,14 @@ |
| 1 | { | |
| 2 | "name": "Pear Dark Reader", | |
| 3 | "version": "1.0.0", | |
| 4 | "description": "Applies a dark, high-contrast theme to every hyper:// page. Styles only — requests nothing else.", | |
| 5 | "author": "PearBrowser examples", | |
| 6 | "pear": { | |
| 7 | "plugin": { | |
| 8 | "capabilities": ["pear.content.styles"], | |
| 9 | "content": { | |
| 10 | "styles": { "matches": ["*"], "path": "/style.css" } | |
| 11 | } | |
| 12 | } | |
| 13 | } | |
| 14 | } |
| … | … | @@ -0,0 +1,26 @@ |
| 1 | /* Pear Dark Reader — example Pear Plugin (pear.content.styles). | |
| 2 | * | |
| 3 | * A deliberately simple inversion-based dark theme: dark canvas, light text, | |
| 4 | * media flipped back to normal. Real dark-reader engines analyze computed | |
| 5 | * styles; this example shows the plugin surface, not CSS science. */ | |
| 6 | ||
| 7 | :root { | |
| 8 | color-scheme: dark; | |
| 9 | } | |
| 10 | ||
| 11 | html { | |
| 12 | background: #101418 !important; | |
| 13 | filter: invert(1) hue-rotate(180deg); | |
| 14 | } | |
| 15 | ||
| 16 | /* Flip media and embeds back so photos and video look natural. */ | |
| 17 | img, picture, video, canvas, svg, iframe, embed, object, | |
| 18 | [style*="background-image"] { | |
| 19 | filter: invert(1) hue-rotate(180deg); | |
| 20 | } | |
| 21 | ||
| 22 | /* Keep selection readable through the double inversion. */ | |
| 23 | ::selection { | |
| 24 | background: #2b5f8a; | |
| 25 | color: #ffffff; | |
| 26 | } |
| … | … | @@ -0,0 +1,36 @@ |
| 1 | /* peerit Enhancer content script — example Pear Plugin surface. | |
| 2 | * | |
| 3 | * Runs only on the peerit drive (matches in manifest.json). Adds j/k | |
| 4 | * keyboard navigation between posts. Content scripts are injected | |
| 5 | * hash-authorized through the browser's CSP pipeline, so these exact | |
| 6 | * bytes are what the user approved at install time. */ | |
| 7 | (() => { | |
| 8 | 'use strict' | |
| 9 | if (window.__peeritEnhancer) return | |
| 10 | window.__peeritEnhancer = true | |
| 11 | ||
| 12 | const postSelector = '.post, .post-row, article' | |
| 13 | let cursor = -1 | |
| 14 | ||
| 15 | function posts () { | |
| 16 | return [...document.querySelectorAll(postSelector)] | |
| 17 | } | |
| 18 | ||
| 19 | function focusPost (index) { | |
| 20 | const all = posts() | |
| 21 | if (!all.length) return | |
| 22 | cursor = Math.max(0, Math.min(all.length - 1, index)) | |
| 23 | const el = all[cursor] | |
| 24 | el.scrollIntoView({ block: 'center', behavior: 'smooth' }) | |
| 25 | for (const other of all) other.style.outline = '' | |
| 26 | el.style.outline = '2px solid rgba(94, 176, 239, 0.65)' | |
| 27 | el.style.outlineOffset = '2px' | |
| 28 | } | |
| 29 | ||
| 30 | window.addEventListener('keydown', (event) => { | |
| 31 | if (event.target && /^(input|textarea|select)$/i.test(event.target.tagName)) return | |
| 32 | if (event.metaKey || event.ctrlKey || event.altKey) return | |
| 33 | if (event.key === 'j') focusPost(cursor + 1) | |
| 34 | else if (event.key === 'k') focusPost(cursor - 1) | |
| 35 | }, { passive: true }) | |
| 36 | })() |
| … | … | @@ -0,0 +1,5 @@ |
| 1 | ! peerit Enhancer filter contribution (pear.net.filter). | |
| 2 | ! Namespaced under plugin:<drive-key> by the shield; the kill switch | |
| 3 | ! or uninstall removes these rules instantly. | |
| 4 | ec6e2d6d9d22b9d6b40e11a9ca3042be3197e4bdca9e9a7f079be6ee830761b4##.promoted-post | |
| 5 | ec6e2d6d9d22b9d6b40e11a9ca3042be3197e4bdca9e9a7f079be6ee830761b4##.self-promo-banner |
| … | … | @@ -0,0 +1,26 @@ |
| 1 | { | |
| 2 | "name": "peerit Enhancer", | |
| 3 | "version": "1.0.0", | |
| 4 | "description": "Quality-of-life tweaks for peerit (the front page of the P2P internet): compact post rows, a reading-width cap, keyboard hint chrome, and a small filter contribution. Scoped to the peerit drive only.", | |
| 5 | "author": "PearBrowser examples", | |
| 6 | "pear": { | |
| 7 | "plugin": { | |
| 8 | "capabilities": [ | |
| 9 | "pear.content.styles", | |
| 10 | "pear.content.scripts", | |
| 11 | "pear.net.filter" | |
| 12 | ], | |
| 13 | "content": { | |
| 14 | "styles": { | |
| 15 | "matches": ["ec6e2d6d9d22b9d6b40e11a9ca3042be3197e4bdca9e9a7f079be6ee830761b4"], | |
| 16 | "path": "/style.css" | |
| 17 | }, | |
| 18 | "scripts": { | |
| 19 | "matches": ["ec6e2d6d9d22b9d6b40e11a9ca3042be3197e4bdca9e9a7f079be6ee830761b4"], | |
| 20 | "path": "/content.js" | |
| 21 | }, | |
| 22 | "filters": "/filters.txt" | |
| 23 | } | |
| 24 | } | |
| 25 | } | |
| 26 | } |
| … | … | @@ -0,0 +1,30 @@ |
| 1 | /* peerit Enhancer — example Pear Plugin, scoped to the peerit drive. | |
| 2 | * Compact rows and a reading-width cap; nothing here runs anywhere else. */ | |
| 3 | ||
| 4 | main, .feed, .posts { | |
| 5 | max-width: 780px; | |
| 6 | margin-left: auto !important; | |
| 7 | margin-right: auto !important; | |
| 8 | } | |
| 9 | ||
| 10 | .post, .post-row, article { | |
| 11 | padding-top: 6px !important; | |
| 12 | padding-bottom: 6px !important; | |
| 13 | line-height: 1.45; | |
| 14 | } | |
| 15 | ||
| 16 | /* Badge so it's obvious the enhancer is active during the example demo. */ | |
| 17 | body::after { | |
| 18 | content: "peerit enhancer active"; | |
| 19 | position: fixed; | |
| 20 | right: 10px; | |
| 21 | bottom: 10px; | |
| 22 | font: 11px system-ui, sans-serif; | |
| 23 | color: #9aa7b0; | |
| 24 | background: rgba(20, 26, 32, 0.85); | |
| 25 | border: 1px solid rgba(154, 167, 176, 0.3); | |
| 26 | border-radius: 999px; | |
| 27 | padding: 3px 10px; | |
| 28 | pointer-events: none; | |
| 29 | z-index: 2147483647; | |
| 30 | } |
| … | … | @@ -0,0 +1,112 @@ |
| 1 | # P2P filter lists and Pear Plugins — publishing and subscribing | |
| 2 | ||
| 3 | ## Live drives (published, pinned, fresh-peer verified 2026-07-16) | |
| 4 | ||
| 5 | | Artifact | Drive key | Publisher storage (`03-sites/pearbrowser-publishers/`) | | |
| 6 | |---|---|---| | |
| 7 | | pear-default filter list | `842fb9e64c1c2092ec426151fd4f9ffb23a2efcae26ff3dd61d5d564ed58d99f` | `seed-pear-default-filters` | | |
| 8 | | Pear Dark Reader plugin | `bbde8330169798dc5e0d08f8909b407cea2f8fec7e31d6241f479c714ad42082` | `seed-pear-dark-reader` | | |
| 9 | | peerit Enhancer plugin | `1b21d8a6960bdcdfb76da94b80dae0d1a28247516de87e6839ea2f87bb609e10` | `seed-peerit-enhancer` | | |
| 10 | | Pear Plugins catalogue | `01b7473601a6a6a58ec240b1c4ef0cdcf1aef0f6f8bf7ff16636faecb640ad13` | `seed-pear-plugins-catalog` | | |
| 11 | ||
| 12 | Subscribe to the list: Settings → Content Shield → *Filter lists from the | |
| 13 | swarm* → paste `842fb9e6…`. Load the catalogue: *Plugin catalog* → paste | |
| 14 | `01b74736…`. (The builtin catalogue seed already carries the plugin keys, so | |
| 15 | Install works out of the box; the catalogue drive is for distribution beyond | |
| 16 | this build.) Each was published with `publish-and-pin.js`, durably | |
| 17 | replicated with `reseed-drive.js --hold 90`, and confirmed with | |
| 18 | `verify-pin.js` from a fresh peer. | |
| 19 | ||
| 20 | Content Shield rules and Pear Plugins are distributed the same way as every | |
| 21 | other PearBrowser artifact: as Hyperdrives, pinned on HiveRelay, synced | |
| 22 | peer-to-peer. No CDN, no vendor list-fetch fingerprint, fully offline after | |
| 23 | first sync. | |
| 24 | ||
| 25 | ## Filter lists | |
| 26 | ||
| 27 | A filter-list drive contains: | |
| 28 | ||
| 29 | ```text | |
| 30 | /filters.txt the rules (Content Shield syntax subset) | |
| 31 | /manifest.json { name, version, filters, sha256, rules, builtAt } | |
| 32 | ``` | |
| 33 | ||
| 34 | Build (regenerates the manifest checksum) and publish: | |
| 35 | ||
| 36 | ```sh | |
| 37 | node scripts/build-shield-list.mjs filter-lists/pear-default --name pear-default | |
| 38 | node scripts/publish-and-pin.js filter-lists/pear-default --name pear-default-filters | |
| 39 | ``` | |
| 40 | ||
| 41 | Ship an update: edit `filters.txt`, rebuild with `--bump`, re-publish with the | |
| 42 | original `--key` and `--storage`. Subscribed browsers verify the sha256 and | |
| 43 | hot-swap on their next refresh sweep (every 30 minutes, or immediately via | |
| 44 | **Refresh** in Settings → Content Shield). | |
| 45 | ||
| 46 | Subscribe in the browser: Settings → Content Shield → *Filter lists from the | |
| 47 | swarm* → paste the drive key. Rule text is persisted locally, so the list | |
| 48 | keeps blocking with no network at all; the subscription metadata | |
| 49 | (version/checksum) lives in user-data settings. | |
| 50 | ||
| 51 | ## Pear Plugins | |
| 52 | ||
| 53 | A plugin drive is an ordinary drive with plugin metadata in `/manifest.json` | |
| 54 | (see `examples/plugins/*`): | |
| 55 | ||
| 56 | ```json | |
| 57 | { | |
| 58 | "name": "Pear Dark Reader", | |
| 59 | "version": "1.0.0", | |
| 60 | "pear": { | |
| 61 | "plugin": { | |
| 62 | "capabilities": ["pear.content.styles"], | |
| 63 | "content": { | |
| 64 | "styles": { "matches": ["*"], "path": "/style.css" }, | |
| 65 | "scripts": { "matches": ["<drive-key>"], "path": "/content.js" }, | |
| 66 | "filters": "/filters.txt" | |
| 67 | } | |
| 68 | } | |
| 69 | } | |
| 70 | } | |
| 71 | ``` | |
| 72 | ||
| 73 | `matches` entries are document hosts — for hyper:// pages that is the drive | |
| 74 | key, so a plugin scoped to one app names that app's drive key; `"*"` matches | |
| 75 | everywhere. Publish exactly like a list: | |
| 76 | ||
| 77 | ```sh | |
| 78 | node scripts/publish-and-pin.js examples/plugins/dark-reader --name pear-dark-reader | |
| 79 | node scripts/publish-and-pin.js examples/plugins/peerit-enhancer --name peerit-enhancer | |
| 80 | ``` | |
| 81 | ||
| 82 | Install in the browser: Settings → Content Shield → *Pear Plugins* → paste | |
| 83 | the plugin drive key. The consent surface shows the capability list; the | |
| 84 | grant is recorded at install time. **A drive update that requests new | |
| 85 | capabilities is disabled automatically** and flagged for explicit | |
| 86 | re-approval — swarm updates can never silently escalate a plugin's power. | |
| 87 | Styles and scripts are injected hash-authorized through the CSP pipeline, so | |
| 88 | the running bytes are provably the installed bytes. | |
| 89 | ||
| 90 | ## The plugin catalogue | |
| 91 | ||
| 92 | Users shouldn't need drive keys. Settings → Content Shield → *Plugin | |
| 93 | catalog* lists curated entries with one-click actions: **Install** for | |
| 94 | plugin drives (runs the normal grant flow) and **Open** for `kind: "app"` | |
| 95 | entries — AI add-on apps like **anonGPT**, which ships in the builtin seed | |
| 96 | with its production drive key so anyone can add it on their own. The | |
| 97 | catalogue is metadata only: it grants nothing, and app entries stay gated by | |
| 98 | their own manifests (anonGPT's bridge still requires its privacy-claims | |
| 99 | manifest check, on its drive only). | |
| 100 | ||
| 101 | Catalogues are themselves P2P: any drive with a `/plugins.json` (see | |
| 102 | `catalogues/pear-plugins/plugins.json`) can be loaded as an extra source | |
| 103 | from the same Settings block, and subscribed sources persist offline. | |
| 104 | Publish the curated catalogue: | |
| 105 | ||
| 106 | ```sh | |
| 107 | node scripts/publish-and-pin.js catalogues/pear-plugins --name pear-plugins-catalog | |
| 108 | ``` | |
| 109 | ||
| 110 | After publishing the two example plugin drives, paste their keys into the | |
| 111 | `driveKey` fields of `plugins.json` and re-publish the catalogue — every | |
| 112 | subscriber's Install buttons light up on their next load. |
| … | … | @@ -0,0 +1,81 @@ |
| 1 | ! PearBrowser default shield list (pear-default) | |
| 2 | ! Published as a Hyperdrive; see filter-lists/README.md. | |
| 3 | ! Syntax: Content Shield subset of Adblock Plus + hosts lines | |
| 4 | ! (||host^, substrings, @@ exceptions, ##selector, host##selector, | |
| 5 | ! ##+js(scriptlet, args), 0.0.0.0 host). | |
| 6 | ||
| 7 | ! --- Core ad networks --- | |
| 8 | ||doubleclick.net^ | |
| 9 | ||googlesyndication.com^ | |
| 10 | ||googleadservices.com^ | |
| 11 | ||adservice.google.com^ | |
| 12 | ||amazon-adsystem.com^ | |
| 13 | ||adsystem.com^ | |
| 14 | ||adnxs.com^ | |
| 15 | ||rubiconproject.com^ | |
| 16 | ||pubmatic.com^ | |
| 17 | ||openx.net^ | |
| 18 | ||criteo.com^ | |
| 19 | ||criteo.net^ | |
| 20 | ||taboola.com^ | |
| 21 | ||outbrain.com^ | |
| 22 | ||moatads.com^ | |
| 23 | ||adroll.com^ | |
| 24 | ||adform.net^ | |
| 25 | ||smartadserver.com^ | |
| 26 | ||yieldmo.com^ | |
| 27 | ||sharethrough.com^ | |
| 28 | ||spotxchange.com^ | |
| 29 | ||teads.tv^ | |
| 30 | ||media.net^ | |
| 31 | ||bidswitch.net^ | |
| 32 | ||casalemedia.com^ | |
| 33 | ||33across.com^ | |
| 34 | ||gumgum.com^ | |
| 35 | ||sonobi.com^ | |
| 36 | ||triplelift.com^ | |
| 37 | ||
| 38 | ! --- Analytics and session recording --- | |
| 39 | ||googletagmanager.com^ | |
| 40 | ||google-analytics.com^ | |
| 41 | ||hotjar.com^ | |
| 42 | ||mixpanel.com^ | |
| 43 | ||segment.io^ | |
| 44 | ||segment.com/v1^ | |
| 45 | ||fullstory.com^ | |
| 46 | ||mouseflow.com^ | |
| 47 | ||clarity.ms^ | |
| 48 | ||chartbeat.com^ | |
| 49 | ||scorecardresearch.com^ | |
| 50 | ||quantserve.com^ | |
| 51 | ||amplitude.com/2/httpapi^ | |
| 52 | ||newrelic.com/nr-data^ | |
| 53 | ||mc.yandex.ru^ | |
| 54 | ||yandex.ru/metrika^ | |
| 55 | ||matomo.cloud^ | |
| 56 | ||plausible.io/api/event^ | |
| 57 | ||
| 58 | ! --- Social trackers --- | |
| 59 | ||connect.facebook.net^ | |
| 60 | ||facebook.com/tr^ | |
| 61 | ||ads-twitter.com^ | |
| 62 | ||static.ads-twitter.com^ | |
| 63 | ||analytics.tiktok.com^ | |
| 64 | ||snap.licdn.com^ | |
| 65 | ||px.ads.linkedin.com^ | |
| 66 | ||
| 67 | ! --- Generic path rules --- | |
| 68 | /adsbygoogle. | |
| 69 | /pagead/js/ | |
| 70 | /ads/banner/ | |
| 71 | /pixel/track | |
| 72 | ||
| 73 | ! --- Cosmetic --- | |
| 74 | ##.adsbygoogle | |
| 75 | ##.ad-banner | |
| 76 | ##.sponsored-content | |
| 77 | ##[id^="google_ads_iframe"] | |
| 78 | ##iframe[src*="doubleclick.net"] | |
| 79 | ||
| 80 | ! --- Exceptions (first-party widgets that break without these) --- | |
| 81 | @@||maps.googleapis.com^ |
| … | … | @@ -0,0 +1,8 @@ |
| 1 | { | |
| 2 | "name": "pear-default", | |
| 3 | "version": "1", | |
| 4 | "filters": "/filters.txt", | |
| 5 | "sha256": "40894dd043fd10c10775fd190957d96468cb6b442d36059730e924e2ade6b07c", | |
| 6 | "rules": 64, | |
| 7 | "builtAt": "2026-07-16T08:57:14.950Z" | |
| 8 | } |
| … | … | @@ -0,0 +1,69 @@ |
| 1 | /** | |
| 2 | * Build a filter-list drive directory: (re)generate manifest.json with the | |
| 3 | * sha256 of filters.txt so subscribers can verify the bytes they sync. | |
| 4 | * | |
| 5 | * Usage: | |
| 6 | * node scripts/build-shield-list.mjs filter-lists/pear-default [--name pear-default] [--bump] | |
| 7 | * | |
| 8 | * Then publish + pin the directory as a Hyperdrive: | |
| 9 | * node scripts/publish-and-pin.js filter-lists/pear-default --name pear-default-filters | |
| 10 | * | |
| 11 | * Re-publishing an update: edit filters.txt, re-run this script with --bump | |
| 12 | * (increments the integer version), then publish-and-pin with the original | |
| 13 | * --key and --storage. Subscribed browsers hot-swap on their next refresh. | |
| 14 | */ | |
| 15 | ||
| 16 | import { createHash } from 'node:crypto' | |
| 17 | import { readFileSync, writeFileSync, existsSync } from 'node:fs' | |
| 18 | import { join, basename, resolve } from 'node:path' | |
| 19 | ||
| 20 | const args = process.argv.slice(2) | |
| 21 | const dir = args.find(a => !a.startsWith('--')) | |
| 22 | const bump = args.includes('--bump') | |
| 23 | const nameFlag = args.indexOf('--name') | |
| 24 | const name = nameFlag !== -1 ? args[nameFlag + 1] : null | |
| 25 | ||
| 26 | if (!dir) { | |
| 27 | console.error('usage: node scripts/build-shield-list.mjs <list-dir> [--name <list-name>] [--bump]') | |
| 28 | process.exit(2) | |
| 29 | } | |
| 30 | ||
| 31 | const listDir = resolve(dir) | |
| 32 | const filtersPath = join(listDir, 'filters.txt') | |
| 33 | const manifestPath = join(listDir, 'manifest.json') | |
| 34 | ||
| 35 | if (!existsSync(filtersPath)) { | |
| 36 | console.error(`no filters.txt in ${listDir}`) | |
| 37 | process.exit(2) | |
| 38 | } | |
| 39 | ||
| 40 | const filters = readFileSync(filtersPath) | |
| 41 | const sha256 = createHash('sha256').update(filters).digest('hex') | |
| 42 | ||
| 43 | let previous = {} | |
| 44 | if (existsSync(manifestPath)) { | |
| 45 | try { previous = JSON.parse(readFileSync(manifestPath, 'utf8')) } catch {} | |
| 46 | } | |
| 47 | ||
| 48 | const previousVersion = Number.parseInt(previous.version, 10) | |
| 49 | const version = bump && Number.isFinite(previousVersion) | |
| 50 | ? String(previousVersion + 1) | |
| 51 | : (previous.version && !bump ? String(previous.version) : '1') | |
| 52 | ||
| 53 | const manifest = { | |
| 54 | name: name || previous.name || basename(listDir), | |
| 55 | version, | |
| 56 | filters: '/filters.txt', | |
| 57 | sha256, | |
| 58 | rules: filters.toString('utf8').split('\n').filter(line => { | |
| 59 | const l = line.trim() | |
| 60 | return l && !l.startsWith('!') && !l.startsWith('[') | |
| 61 | }).length, | |
| 62 | builtAt: new Date().toISOString() | |
| 63 | } | |
| 64 | ||
| 65 | writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n') | |
| 66 | console.log(`${manifest.name} v${manifest.version} — ${manifest.rules} rules, sha256 ${sha256.slice(0, 16)}…`) | |
| 67 | console.log(`wrote ${manifestPath}`) | |
| 68 | console.log('publish with:') | |
| 69 | console.log(` node scripts/publish-and-pin.js ${dir} --name ${manifest.name}-filters`) |
| … | … | @@ -44,7 +44,7 @@ test('rewriteHtmlForProxy rewrites href/src to proxy paths', () => { |
| 44 | 44 | </body></html>` |
| 45 | 45 | const out = rewriteHtmlForProxy(html, 'https://example.com/page', 'http://127.0.0.1:9') |
| 46 | 46 | assert.match(out, /\/clearnet\//) |
| 47 | assert.match(out, /pear-clearnet-origin/) | |
| 47 | assert.match(out, /<meta name="pear-clearnet-origin" content="https:\/\/example\.com">/) | |
| 48 | 48 | assert.doesNotMatch(out, /href="\/next"/) |
| 49 | 49 | // Absolute CDN URL is base64url-encoded into the proxy path |
| 50 | 50 | assert.match(out, /clearnet\/[A-Za-z0-9_-]+/) |
| … | … | @@ -35,6 +35,8 @@ test('shell ships clearnet chrome, privacy card, and navigate kind handling', () |
| 35 | 35 | assert.match(shell, /fingerprintFarbling/) |
| 36 | 36 | assert.match(shell, /clearnet-iframe-proxy/) |
| 37 | 37 | assert.match(shell, /kind === 'clearnet'/) |
| 38 | assert.match(shell, /<p>Fetching <code>\$\{t\.url\}<\/code> \$\{t\.kind/) | |
| 39 | assert.doesNotMatch(shell, /\? html`Fetching <code>/) | |
| 38 | 40 | assert.match(shell, /CMD_PRIVACY_STATUS/) |
| 39 | 41 | assert.match(shell, /example\.com/) |
| 40 | 42 | }) |
| … | … | @@ -148,6 +148,29 @@ test('HyperProxy assigns distinct feature-flagged loopback origins per drive', a |
| 148 | 148 | }) |
| 149 | 149 | }) |
| 150 | 150 | |
| 151 | test('HyperProxy injects a valid base URL when URL.origin is unavailable in Bare', async () => { | |
| 152 | const NativeURL = globalThis.URL | |
| 153 | class BareLikeURL extends NativeURL { | |
| 154 | get origin () { return undefined } | |
| 155 | } | |
| 156 | ||
| 157 | globalThis.URL = BareLikeURL | |
| 158 | try { | |
| 159 | const proxy = new HyperProxy(async () => null, () => {}) | |
| 160 | proxy._port = 43210 | |
| 161 | const html = (await proxy._injectHtmlHead( | |
| 162 | '<html><head></head><body></body></html>', | |
| 163 | driveA, | |
| 164 | `/hyper/${driveA}/index.html` | |
| 165 | )).toString('utf8') | |
| 166 | ||
| 167 | assert.ok(html.includes(`<base href="http://127.0.0.1:43210/hyper/${driveA}/">`)) | |
| 168 | assert.doesNotMatch(html, /<base href="undefined\//) | |
| 169 | } finally { | |
| 170 | globalThis.URL = NativeURL | |
| 171 | } | |
| 172 | }) | |
| 173 | ||
| 151 | 174 | test('HyperProxy per-drive listeners serve only their bound drive key', async (t) => { |
| 152 | 175 | const proxy = new HyperProxy(async () => null, () => {}, null, { |
| 153 | 176 | perDriveOrigins: true |
| … | … | @@ -0,0 +1,142 @@ |
| 1 | import test from 'node:test' | |
| 2 | import assert from 'node:assert/strict' | |
| 3 | import { readFileSync } from 'node:fs' | |
| 4 | import { fileURLToPath } from 'node:url' | |
| 5 | import { dirname, join } from 'node:path' | |
| 6 | import { createRequire } from 'node:module' | |
| 7 | ||
| 8 | const require = createRequire(import.meta.url) | |
| 9 | const { PluginCatalog, PluginCatalogError, BUILTIN_PLUGIN_CATALOG, validateCatalogEntry } = require('../backend/plugin-catalog.cjs') | |
| 10 | const { ANONGPT_DRIVE_KEY } = require('../backend/constants.js') | |
| 11 | ||
| 12 | const root = join(dirname(fileURLToPath(import.meta.url)), '..') | |
| 13 | const CAT_KEY = 'f0'.repeat(32) | |
| 14 | ||
| 15 | function makeCatalog (files = {}, opts = {}) { | |
| 16 | return new PluginCatalog({ | |
| 17 | fetchDriveFile: async (driveKey, path) => { | |
| 18 | const value = files[path] | |
| 19 | return value == null ? null : { content: Buffer.from(value) } | |
| 20 | }, | |
| 21 | now: () => 4242, | |
| 22 | ...opts | |
| 23 | }) | |
| 24 | } | |
| 25 | ||
| 26 | test('builtin seed lists anonGPT with its production drive key, installable by the user', () => { | |
| 27 | const catalog = makeCatalog() | |
| 28 | const entries = catalog.entries() | |
| 29 | ||
| 30 | const anongpt = entries.find(entry => entry.id === 'anongpt') | |
| 31 | assert.ok(anongpt, 'anonGPT is in the builtin catalogue') | |
| 32 | assert.equal(anongpt.kind, 'app') | |
| 33 | assert.equal(anongpt.driveKey, ANONGPT_DRIVE_KEY) | |
| 34 | assert.equal(anongpt.verified, true) | |
| 35 | assert.equal(anongpt.source, 'builtin') | |
| 36 | ||
| 37 | // Example plugins carry their published drive keys (2026-07-16). | |
| 38 | const darkReader = entries.find(entry => entry.id === 'pear-dark-reader') | |
| 39 | assert.ok(darkReader) | |
| 40 | assert.equal(darkReader.driveKey, 'bbde8330169798dc5e0d08f8909b407cea2f8fec7e31d6241f479c714ad42082') | |
| 41 | assert.equal(darkReader.kind, 'plugin') | |
| 42 | const enhancer = entries.find(entry => entry.id === 'peerit-enhancer') | |
| 43 | assert.equal(enhancer.driveKey, '1b21d8a6960bdcdfb76da94b80dae0d1a28247516de87e6839ea2f87bb609e10') | |
| 44 | assert.equal(BUILTIN_PLUGIN_CATALOG.length, 3) | |
| 45 | }) | |
| 46 | ||
| 47 | test('a catalogue drive loads, validates entries, and merges after builtin', async () => { | |
| 48 | const pluginKey = 'ab'.repeat(32) | |
| 49 | const catalog = makeCatalog({ | |
| 50 | '/plugins.json': JSON.stringify({ | |
| 51 | name: 'Community Plugins', | |
| 52 | plugins: [ | |
| 53 | { driveKey: pluginKey, name: 'Community Styler', kind: 'plugin', capabilities: ['pear.content.styles'], description: 'x' }, | |
| 54 | { driveKey: 'not-a-key', name: 'Broken entry' }, | |
| 55 | { name: 'No key at all' }, | |
| 56 | { driveKey: ANONGPT_DRIVE_KEY, name: 'anonGPT shadow attempt', kind: 'app' }, | |
| 57 | 42 | |
| 58 | ] | |
| 59 | }) | |
| 60 | }) | |
| 61 | ||
| 62 | const result = await catalog.loadFromDrive(CAT_KEY) | |
| 63 | assert.equal(result.name, 'Community Plugins') | |
| 64 | assert.equal(result.entryCount, 2) // styler + shadow attempt survive validation | |
| 65 | ||
| 66 | const entries = catalog.entries() | |
| 67 | const styler = entries.find(entry => entry.driveKey === pluginKey) | |
| 68 | assert.equal(styler.source, CAT_KEY) | |
| 69 | ||
| 70 | // Builtin wins the dedupe: the shadow entry cannot replace anonGPT. | |
| 71 | const anongpt = entries.filter(entry => entry.driveKey === ANONGPT_DRIVE_KEY) | |
| 72 | assert.equal(anongpt.length, 1) | |
| 73 | assert.equal(anongpt[0].name, 'anonGPT') | |
| 74 | assert.equal(anongpt[0].source, 'builtin') | |
| 75 | ||
| 76 | assert.deepEqual(catalog.sources(), [{ | |
| 77 | driveKey: CAT_KEY, name: 'Community Plugins', entryCount: 2, loadedAt: 4242 | |
| 78 | }]) | |
| 79 | }) | |
| 80 | ||
| 81 | test('catalogue failures are typed and fail closed', async () => { | |
| 82 | await assert.rejects(makeCatalog().loadFromDrive('nope'), err => err.code === 'invalid-drive-key') | |
| 83 | await assert.rejects(makeCatalog({}).loadFromDrive(CAT_KEY), err => err.code === 'catalog-unavailable') | |
| 84 | await assert.rejects( | |
| 85 | makeCatalog({ '/plugins.json': 'not json' }).loadFromDrive(CAT_KEY), | |
| 86 | err => err.code === 'catalog-invalid' | |
| 87 | ) | |
| 88 | await assert.rejects( | |
| 89 | makeCatalog({ '/plugins.json': JSON.stringify({ plugins: [{ name: 'keyless' }] }) }).loadFromDrive(CAT_KEY), | |
| 90 | err => err.code === 'catalog-empty' | |
| 91 | ) | |
| 92 | assert.ok(new PluginCatalogError('x', 'y') instanceof Error) | |
| 93 | }) | |
| 94 | ||
| 95 | test('sources persist through exportState/restore and can be removed', async () => { | |
| 96 | const pluginKey = 'cd'.repeat(32) | |
| 97 | const first = makeCatalog({ | |
| 98 | '/plugins.json': JSON.stringify({ | |
| 99 | name: 'Community', | |
| 100 | plugins: [{ driveKey: pluginKey, name: 'Styler', capabilities: ['pear.content.styles'] }] | |
| 101 | }) | |
| 102 | }) | |
| 103 | await first.loadFromDrive(CAT_KEY) | |
| 104 | const durable = first.exportState() | |
| 105 | ||
| 106 | const second = makeCatalog({}) | |
| 107 | assert.equal(second.restore(durable), 1) | |
| 108 | assert.ok(second.entries().some(entry => entry.driveKey === pluginKey)) | |
| 109 | ||
| 110 | assert.equal(second.removeSource(CAT_KEY), true) | |
| 111 | assert.equal(second.entries().some(entry => entry.driveKey === pluginKey), false) | |
| 112 | assert.deepEqual(second.exportState(), { sources: {} }) | |
| 113 | }) | |
| 114 | ||
| 115 | test('entry validation caps and normalizes fields', () => { | |
| 116 | const entry = validateCatalogEntry({ | |
| 117 | driveKey: 'AB'.repeat(32), | |
| 118 | name: ' Padded name ', | |
| 119 | kind: 'weird-kind', | |
| 120 | description: 'd'.repeat(2000), | |
| 121 | capabilities: ['pear.content.styles', '', 42, 'x'.repeat(200), 'a', 'b', 'c', 'd', 'e', 'f'], | |
| 122 | verified: 'yes' | |
| 123 | }) | |
| 124 | assert.equal(entry.driveKey, 'ab'.repeat(32)) | |
| 125 | assert.equal(entry.name, 'Padded name') | |
| 126 | assert.equal(entry.kind, 'plugin') | |
| 127 | assert.equal(entry.description.length, 500) | |
| 128 | assert.equal(entry.capabilities.length, 8) | |
| 129 | assert.equal(entry.verified, false) | |
| 130 | assert.equal(validateCatalogEntry({ driveKey: 'ab'.repeat(32) }), null) // no name | |
| 131 | }) | |
| 132 | ||
| 133 | test('the shippable catalogue source parses and its anonGPT entry matches constants', async () => { | |
| 134 | const raw = readFileSync(join(root, 'catalogues/pear-plugins/plugins.json')) | |
| 135 | const catalog = makeCatalog({ '/plugins.json': raw }) | |
| 136 | const result = await catalog.loadFromDrive(CAT_KEY) | |
| 137 | assert.ok(result.entryCount >= 1) | |
| 138 | ||
| 139 | const parsed = JSON.parse(raw.toString('utf8')) | |
| 140 | const anongpt = parsed.plugins.find(entry => entry.id === 'anongpt') | |
| 141 | assert.equal(anongpt.driveKey, ANONGPT_DRIVE_KEY) | |
| 142 | }) |
| … | … | @@ -0,0 +1,201 @@ |
| 1 | import test from 'node:test' | |
| 2 | import assert from 'node:assert/strict' | |
| 3 | import { createRequire } from 'node:module' | |
| 4 | ||
| 5 | const require = createRequire(import.meta.url) | |
| 6 | const { ContentShield } = require('../backend/content-shield.cjs') | |
| 7 | const { PearPluginRegistry } = require('../backend/pear-plugins.cjs') | |
| 8 | const { PluginDriveLoader, PluginDriveError, MAX_ASSET_BYTES } = require('../backend/plugin-drive-loader.cjs') | |
| 9 | ||
| 10 | const KEY = 'a1'.repeat(32) | |
| 11 | ||
| 12 | function pluginManifest (capabilities, content, version = '1.0.0') { | |
| 13 | return JSON.stringify({ | |
| 14 | name: 'Fixture Plugin', | |
| 15 | version, | |
| 16 | pear: { plugin: { capabilities, content } } | |
| 17 | }) | |
| 18 | } | |
| 19 | ||
| 20 | function makeLoader (files) { | |
| 21 | const shield = new ContentShield({ builtinList: false }) | |
| 22 | const registry = new PearPluginRegistry({ shield }) | |
| 23 | const persisted = new Map() | |
| 24 | const store = { ...files } | |
| 25 | const loader = new PluginDriveLoader({ | |
| 26 | registry, | |
| 27 | fetchDriveFile: async (driveKey, path) => { | |
| 28 | const value = store[path] | |
| 29 | return value == null ? null : { content: Buffer.from(value) } | |
| 30 | }, | |
| 31 | persistInstall: async (id, payload) => { | |
| 32 | if (payload === null) persisted.delete(id) | |
| 33 | else persisted.set(id, structuredClone(payload)) | |
| 34 | }, | |
| 35 | now: () => 99 | |
| 36 | }) | |
| 37 | return { loader, registry, shield, persisted, store } | |
| 38 | } | |
| 39 | ||
| 40 | test('install from a drive fetches assets and applies the contribution', async () => { | |
| 41 | const { loader, registry, shield, persisted } = makeLoader({ | |
| 42 | '/manifest.json': pluginManifest( | |
| 43 | ['pear.content.styles', 'pear.content.scripts', 'pear.net.filter'], | |
| 44 | { | |
| 45 | styles: { matches: ['*'], path: '/style.css' }, | |
| 46 | scripts: { matches: ['*'], path: '/content.js' }, | |
| 47 | filters: '/filters.txt' | |
| 48 | } | |
| 49 | ), | |
| 50 | '/style.css': '.fixture-hide { display: none }', | |
| 51 | '/content.js': 'window.__fixture = 1', | |
| 52 | '/filters.txt': '||fixture-ads.example.com^' | |
| 53 | }) | |
| 54 | ||
| 55 | const result = await loader.installFromDrive(KEY) | |
| 56 | assert.equal(result.ok, true) | |
| 57 | assert.equal(result.version, '1.0.0') | |
| 58 | assert.deepEqual(result.granted, ['pear.content.styles', 'pear.content.scripts', 'pear.net.filter']) | |
| 59 | ||
| 60 | const listed = registry.list().find(item => item.id === KEY) | |
| 61 | assert.ok(listed) | |
| 62 | assert.equal(listed.enabled, true) | |
| 63 | ||
| 64 | assert.equal(shield.shouldBlockUrl('https://fixture-ads.example.com/x.js').blocked, true) | |
| 65 | assert.ok(shield.pluginStylesFor('anything.example').includes('.fixture-hide')) | |
| 66 | assert.ok(shield.pluginScriptsFor('anything.example').some(item => (item.body || '').includes('__fixture'))) | |
| 67 | ||
| 68 | const payload = persisted.get(KEY) | |
| 69 | assert.equal(payload.version, '1.0.0') | |
| 70 | assert.deepEqual(payload.granted, result.granted) | |
| 71 | }) | |
| 72 | ||
| 73 | test('a narrower explicit grant strips ungranted capabilities before the engine', async () => { | |
| 74 | const { loader, shield } = makeLoader({ | |
| 75 | '/manifest.json': pluginManifest( | |
| 76 | ['pear.content.styles', 'pear.net.filter'], | |
| 77 | { | |
| 78 | styles: { matches: ['*'], path: '/style.css' }, | |
| 79 | filters: '/filters.txt' | |
| 80 | } | |
| 81 | ), | |
| 82 | '/style.css': '.fixture-hide { display: none }', | |
| 83 | '/filters.txt': '||fixture-ads.example.com^' | |
| 84 | }) | |
| 85 | ||
| 86 | const result = await loader.installFromDrive(KEY, { grantedCapabilities: ['pear.content.styles'] }) | |
| 87 | assert.deepEqual(result.granted, ['pear.content.styles']) | |
| 88 | ||
| 89 | // Styles applied, network filter NOT applied (capability was not granted). | |
| 90 | assert.ok(shield.pluginStylesFor('x.example').includes('.fixture-hide')) | |
| 91 | assert.equal(shield.shouldBlockUrl('https://fixture-ads.example.com/x.js').blocked, false) | |
| 92 | }) | |
| 93 | ||
| 94 | test('an update that escalates capabilities disables the plugin pending re-consent', async () => { | |
| 95 | const { loader, registry, store } = makeLoader({ | |
| 96 | '/manifest.json': pluginManifest(['pear.content.styles'], { | |
| 97 | styles: { matches: ['*'], path: '/style.css' } | |
| 98 | }), | |
| 99 | '/style.css': '.fixture-hide { display: none }' | |
| 100 | }) | |
| 101 | await loader.installFromDrive(KEY) | |
| 102 | ||
| 103 | // The drive updates itself over the swarm: same plugin, new powers. | |
| 104 | store['/manifest.json'] = pluginManifest( | |
| 105 | ['pear.content.styles', 'pear.content.scripts'], | |
| 106 | { | |
| 107 | styles: { matches: ['*'], path: '/style.css' }, | |
| 108 | scripts: { matches: ['*'], path: '/content.js' } | |
| 109 | }, | |
| 110 | '2.0.0' | |
| 111 | ) | |
| 112 | store['/content.js'] = 'window.__sneaky = 1' | |
| 113 | ||
| 114 | const outcome = await loader.updateFromDrive(KEY) | |
| 115 | assert.equal(outcome.ok, false) | |
| 116 | assert.equal(outcome.escalated, true) | |
| 117 | assert.deepEqual(outcome.added, ['pear.content.scripts']) | |
| 118 | ||
| 119 | const listed = registry.list().find(item => item.id === KEY) | |
| 120 | assert.equal(listed.enabled, false) | |
| 121 | assert.equal(loader.installRecord(KEY).escalated.added[0], 'pear.content.scripts') | |
| 122 | ||
| 123 | // Explicit re-consent accepts the escalation and re-enables. | |
| 124 | const accepted = await loader.updateFromDrive(KEY, { acceptEscalation: true }) | |
| 125 | assert.equal(accepted.ok, true) | |
| 126 | assert.equal(accepted.escalationAccepted, true) | |
| 127 | assert.deepEqual(accepted.granted, ['pear.content.styles', 'pear.content.scripts']) | |
| 128 | assert.equal(registry.list().find(item => item.id === KEY).enabled, true) | |
| 129 | }) | |
| 130 | ||
| 131 | test('a same-capability update hot-swaps without consent friction', async () => { | |
| 132 | const { loader, shield, store } = makeLoader({ | |
| 133 | '/manifest.json': pluginManifest(['pear.content.styles'], { | |
| 134 | styles: { matches: ['*'], path: '/style.css' } | |
| 135 | }), | |
| 136 | '/style.css': '.v1 { display: none }' | |
| 137 | }) | |
| 138 | await loader.installFromDrive(KEY) | |
| 139 | ||
| 140 | store['/manifest.json'] = pluginManifest(['pear.content.styles'], { | |
| 141 | styles: { matches: ['*'], path: '/style.css' } | |
| 142 | }, '1.1.0') | |
| 143 | store['/style.css'] = '.v2 { display: none }' | |
| 144 | ||
| 145 | const outcome = await loader.updateFromDrive(KEY) | |
| 146 | assert.equal(outcome.ok, true) | |
| 147 | assert.equal(outcome.version, '1.1.0') | |
| 148 | assert.ok(shield.pluginStylesFor('x.example').includes('.v2')) | |
| 149 | }) | |
| 150 | ||
| 151 | test('missing manifests, invalid assets, and oversized assets fail closed', async () => { | |
| 152 | const none = makeLoader({}) | |
| 153 | await assert.rejects(none.loader.installFromDrive(KEY), err => err.code === 'manifest-unavailable') | |
| 154 | ||
| 155 | const notPlugin = makeLoader({ '/manifest.json': JSON.stringify({ name: 'app' }) }) | |
| 156 | await assert.rejects(notPlugin.loader.installFromDrive(KEY), err => err.code === 'not-a-plugin') | |
| 157 | ||
| 158 | const missingAsset = makeLoader({ | |
| 159 | '/manifest.json': pluginManifest(['pear.content.styles'], { | |
| 160 | styles: { matches: ['*'], path: '/style.css' } | |
| 161 | }) | |
| 162 | }) | |
| 163 | await assert.rejects(missingAsset.loader.installFromDrive(KEY), err => err.code === 'asset-unavailable') | |
| 164 | ||
| 165 | const traversal = makeLoader({ | |
| 166 | '/manifest.json': pluginManifest(['pear.content.styles'], { | |
| 167 | styles: { matches: ['*'], path: '/../escape.css' } | |
| 168 | }) | |
| 169 | }) | |
| 170 | await assert.rejects(traversal.loader.installFromDrive(KEY), err => err.code === 'asset-path-invalid') | |
| 171 | ||
| 172 | const oversized = makeLoader({ | |
| 173 | '/manifest.json': pluginManifest(['pear.content.styles'], { | |
| 174 | styles: { matches: ['*'], path: '/style.css' } | |
| 175 | }), | |
| 176 | '/style.css': 'x'.repeat(MAX_ASSET_BYTES + 1) | |
| 177 | }) | |
| 178 | await assert.rejects(oversized.loader.installFromDrive(KEY), err => err.code === 'asset-too-large') | |
| 179 | ||
| 180 | assert.ok(new PluginDriveError('x', 'y') instanceof Error) | |
| 181 | }) | |
| 182 | ||
| 183 | test('uninstall removes the registration and the durable payload', async () => { | |
| 184 | const { loader, registry, shield, persisted } = makeLoader({ | |
| 185 | '/manifest.json': pluginManifest(['pear.content.styles', 'pear.net.filter'], { | |
| 186 | styles: { matches: ['*'], path: '/style.css' }, | |
| 187 | filters: '/filters.txt' | |
| 188 | }), | |
| 189 | '/style.css': '.fixture-hide { display: none }', | |
| 190 | '/filters.txt': '||fixture-ads.example.com^' | |
| 191 | }) | |
| 192 | await loader.installFromDrive(KEY) | |
| 193 | assert.equal(shield.shouldBlockUrl('https://fixture-ads.example.com/x.js').blocked, true) | |
| 194 | ||
| 195 | const removed = await loader.uninstall(KEY) | |
| 196 | assert.equal(removed.removed, true) | |
| 197 | assert.equal(registry.list().length, 0) | |
| 198 | assert.equal(persisted.has(KEY), false) | |
| 199 | assert.equal(shield.shouldBlockUrl('https://fixture-ads.example.com/x.js').blocked, false) | |
| 200 | assert.equal(shield.pluginStylesFor('x.example'), '') | |
| 201 | }) |
| … | … | @@ -0,0 +1,96 @@ |
| 1 | /** | |
| 2 | * The shipped example artifacts must actually work end-to-end through the | |
| 3 | * real loader/sync classes: both example plugins install from their | |
| 4 | * directories as if they were drives, and the built pear-default list | |
| 5 | * passes checksum verification. | |
| 6 | */ | |
| 7 | import test from 'node:test' | |
| 8 | import assert from 'node:assert/strict' | |
| 9 | import { readFileSync, existsSync } from 'node:fs' | |
| 10 | import { createHash } from 'node:crypto' | |
| 11 | import { fileURLToPath } from 'node:url' | |
| 12 | import { dirname, join } from 'node:path' | |
| 13 | import { createRequire } from 'node:module' | |
| 14 | ||
| 15 | const require = createRequire(import.meta.url) | |
| 16 | const { ContentShield } = require('../backend/content-shield.cjs') | |
| 17 | const { PearPluginRegistry } = require('../backend/pear-plugins.cjs') | |
| 18 | const { PluginDriveLoader } = require('../backend/plugin-drive-loader.cjs') | |
| 19 | const { ShieldListSync } = require('../backend/shield-list-sync.cjs') | |
| 20 | ||
| 21 | const root = join(dirname(fileURLToPath(import.meta.url)), '..') | |
| 22 | const PEERIT_KEY = 'ec6e2d6d9d22b9d6b40e11a9ca3042be3197e4bdca9e9a7f079be6ee830761b4' | |
| 23 | ||
| 24 | function driveFromDirectory (dir) { | |
| 25 | return async (driveKey, path) => { | |
| 26 | const file = join(root, dir, path.slice(1)) | |
| 27 | if (!existsSync(file)) return null | |
| 28 | return { content: readFileSync(file) } | |
| 29 | } | |
| 30 | } | |
| 31 | ||
| 32 | function makeHarness (dir) { | |
| 33 | const shield = new ContentShield({ builtinList: false }) | |
| 34 | const registry = new PearPluginRegistry({ shield }) | |
| 35 | const loader = new PluginDriveLoader({ | |
| 36 | registry, | |
| 37 | fetchDriveFile: driveFromDirectory(dir), | |
| 38 | persistInstall: async () => {} | |
| 39 | }) | |
| 40 | return { shield, registry, loader } | |
| 41 | } | |
| 42 | ||
| 43 | test('dark-reader example installs and styles every host', async () => { | |
| 44 | const key = 'a'.repeat(64) | |
| 45 | const { shield, loader } = makeHarness('examples/plugins/dark-reader') | |
| 46 | ||
| 47 | const result = await loader.installFromDrive(key) | |
| 48 | assert.equal(result.ok, true) | |
| 49 | assert.deepEqual(result.granted, ['pear.content.styles']) | |
| 50 | ||
| 51 | const css = shield.pluginStylesFor('anything.example') | |
| 52 | assert.ok(css.includes('color-scheme: dark')) | |
| 53 | // Styles only: no scripts, no filter contributions. | |
| 54 | assert.deepEqual(shield.pluginScriptsFor('anything.example'), []) | |
| 55 | }) | |
| 56 | ||
| 57 | test('peerit-enhancer example installs scoped to the peerit drive', async () => { | |
| 58 | const key = 'b'.repeat(64) | |
| 59 | const { shield, loader } = makeHarness('examples/plugins/peerit-enhancer') | |
| 60 | ||
| 61 | const result = await loader.installFromDrive(key) | |
| 62 | assert.equal(result.ok, true) | |
| 63 | assert.deepEqual(result.granted, ['pear.content.styles', 'pear.content.scripts', 'pear.net.filter']) | |
| 64 | ||
| 65 | // Scoped: applies on the peerit drive host, not elsewhere. | |
| 66 | assert.ok(shield.pluginStylesFor(PEERIT_KEY).includes('reading-width') || shield.pluginStylesFor(PEERIT_KEY).includes('max-width')) | |
| 67 | assert.equal(shield.pluginStylesFor('c'.repeat(64)), '') | |
| 68 | assert.ok(shield.pluginScriptsFor(PEERIT_KEY).some(item => item.body.includes('__peeritEnhancer'))) | |
| 69 | assert.deepEqual(shield.pluginScriptsFor('c'.repeat(64)), []) | |
| 70 | ||
| 71 | // Filter contribution parsed into the plugin's namespaced list. | |
| 72 | assert.ok(shield.cosmeticCssFor(PEERIT_KEY).includes('.promoted-post')) | |
| 73 | }) | |
| 74 | ||
| 75 | test('pear-default list builds, verifies, and subscribes through the real sync', async () => { | |
| 76 | const manifest = JSON.parse(readFileSync(join(root, 'filter-lists/pear-default/manifest.json'), 'utf8')) | |
| 77 | const filters = readFileSync(join(root, 'filter-lists/pear-default/filters.txt')) | |
| 78 | assert.equal(createHash('sha256').update(filters).digest('hex'), manifest.sha256) | |
| 79 | ||
| 80 | const key = 'd'.repeat(64) | |
| 81 | const shield = new ContentShield({ builtinList: false }) | |
| 82 | const sync = new ShieldListSync({ | |
| 83 | shield, | |
| 84 | fetchDriveFile: driveFromDirectory('filter-lists/pear-default'), | |
| 85 | sha256Hex: (buf) => createHash('sha256').update(buf).digest('hex'), | |
| 86 | persistMeta: async () => {} | |
| 87 | }) | |
| 88 | ||
| 89 | const result = await sync.subscribe(key) | |
| 90 | assert.equal(result.changed, true) | |
| 91 | assert.equal(result.name, 'pear-default') | |
| 92 | assert.ok(result.rules >= 60) | |
| 93 | assert.equal(shield.shouldBlockUrl('https://stats.doubleclick.net/pixel').blocked, true) | |
| 94 | assert.equal(shield.shouldBlockUrl('https://maps.googleapis.com/maps/api/js').blocked, false) | |
| 95 | assert.ok(shield.cosmeticCssFor('any.example').includes('.sponsored-content')) | |
| 96 | }) |
| … | … | @@ -0,0 +1,94 @@ |
| 1 | /** | |
| 2 | * Structural / wiring audit for P2P shield distribution (Phase 2/3 gates): | |
| 3 | * filter-list drives and plugin drives. Asserts shipped source surfaces. | |
| 4 | */ | |
| 5 | import test from 'node:test' | |
| 6 | import assert from 'node:assert/strict' | |
| 7 | import { readFileSync } from 'node:fs' | |
| 8 | import { fileURLToPath } from 'node:url' | |
| 9 | import { dirname, join } from 'node:path' | |
| 10 | ||
| 11 | const root = join(dirname(fileURLToPath(import.meta.url)), '..') | |
| 12 | const shell = readFileSync(join(root, 'ui/shell.js'), 'utf8') | |
| 13 | const boot = readFileSync(join(root, 'ui/boot.js'), 'utf8') | |
| 14 | const index = readFileSync(join(root, 'backend/index.js'), 'utf8') | |
| 15 | const constants = readFileSync(join(root, 'backend/constants.js'), 'utf8') | |
| 16 | ||
| 17 | test('boot.js and constants.js share the distribution command ids; index handles them', () => { | |
| 18 | for (const [name, id] of [ | |
| 19 | ['CMD_SHIELD_SUBSCRIBE_LIST', 239], | |
| 20 | ['CMD_SHIELD_UNSUBSCRIBE_LIST', 240], | |
| 21 | ['CMD_SHIELD_REFRESH_LISTS', 241], | |
| 22 | ['CMD_PLUGIN_INSTALL_DRIVE', 242], | |
| 23 | ['CMD_PLUGIN_UPDATE_DRIVE', 243], | |
| 24 | ['CMD_PLUGIN_UNINSTALL', 244] | |
| 25 | ]) { | |
| 26 | assert.match(constants, new RegExp(`const ${name} = ${id}`)) | |
| 27 | assert.match(constants, new RegExp(`${name},`)) | |
| 28 | assert.match(boot, new RegExp(`${name}: ${id}`)) | |
| 29 | assert.match(index, new RegExp(`rpc\\.handle\\(C\\.${name}`)) | |
| 30 | } | |
| 31 | }) | |
| 32 | ||
| 33 | test('backend boots the sync + loader against the hybrid drive fetch path', () => { | |
| 34 | assert.match(index, /require\('\.\/shield-list-sync\.cjs'\)/) | |
| 35 | assert.match(index, /require\('\.\/plugin-drive-loader\.cjs'\)/) | |
| 36 | assert.match(index, /new ShieldListSync\(/) | |
| 37 | assert.match(index, /new PluginDriveLoader\(/) | |
| 38 | assert.match(index, /proxy\._hybridFetch\(keyHex, path\)/) | |
| 39 | assert.match(index, /contentShieldListSync/) | |
| 40 | assert.match(index, /contentShieldPluginInstalls/) | |
| 41 | assert.match(index, /startAutoRefresh\(/) | |
| 42 | assert.match(index, /shieldListSync\?\.stop\(\)/) | |
| 43 | }) | |
| 44 | ||
| 45 | test('settings UI exposes subscribe/install/update/uninstall controls', () => { | |
| 46 | assert.match(shell, /data-testid="content-shield-list-sync"/) | |
| 47 | assert.match(shell, /data-testid="content-shield-subscribe-input"/) | |
| 48 | assert.match(shell, /data-testid="content-shield-subscribe"/) | |
| 49 | assert.match(shell, /data-testid="plugin-install-input"/) | |
| 50 | assert.match(shell, /data-testid="plugin-install"/) | |
| 51 | assert.match(shell, /data-testid="plugin-escalation"/) | |
| 52 | assert.match(shell, /CMD_SHIELD_SUBSCRIBE_LIST/) | |
| 53 | assert.match(shell, /CMD_SHIELD_REFRESH_LISTS/) | |
| 54 | assert.match(shell, /CMD_PLUGIN_INSTALL_DRIVE/) | |
| 55 | assert.match(shell, /CMD_PLUGIN_UPDATE_DRIVE/) | |
| 56 | assert.match(shell, /CMD_PLUGIN_UNINSTALL/) | |
| 57 | }) | |
| 58 | ||
| 59 | test('plugin catalogue command ids are mirrored and handled', () => { | |
| 60 | for (const [name, id] of [ | |
| 61 | ['CMD_PLUGIN_CATALOG', 245], | |
| 62 | ['CMD_PLUGIN_CATALOG_LOAD_DRIVE', 246], | |
| 63 | ['CMD_PLUGIN_CATALOG_REMOVE_SOURCE', 247] | |
| 64 | ]) { | |
| 65 | assert.match(constants, new RegExp(`const ${name} = ${id}`)) | |
| 66 | assert.match(constants, new RegExp(`${name},`)) | |
| 67 | assert.match(boot, new RegExp(`${name}: ${id}`)) | |
| 68 | assert.match(index, new RegExp(`rpc\\.handle\\(C\\.${name}`)) | |
| 69 | } | |
| 70 | assert.match(index, /require\('\.\/plugin-catalog\.cjs'\)/) | |
| 71 | assert.match(index, /new PluginCatalog\(/) | |
| 72 | assert.match(index, /contentShieldPluginCatalog/) | |
| 73 | }) | |
| 74 | ||
| 75 | test('catalogue UI lists entries with install/open actions and a source loader', () => { | |
| 76 | assert.match(shell, /data-testid="plugin-catalog"/) | |
| 77 | assert.match(shell, /data-testid=\$\{'catalog-install-' \+ entry\.id\}/) | |
| 78 | assert.match(shell, /data-testid=\$\{'catalog-open-' \+ entry\.id\}/) | |
| 79 | assert.match(shell, /data-testid="plugin-catalog-source-input"/) | |
| 80 | assert.match(shell, /data-testid="plugin-catalog-load"/) | |
| 81 | assert.match(shell, /CMD_PLUGIN_CATALOG/) | |
| 82 | assert.match(shell, /CMD_PLUGIN_CATALOG_LOAD_DRIVE/) | |
| 83 | assert.match(shell, /CMD_PLUGIN_CATALOG_REMOVE_SOURCE/) | |
| 84 | // The anonGPT app entry opens through the App-level browse navigation. | |
| 85 | assert.match(shell, /onBrowse\(`hyper:\/\/\$\{entry\.driveKey\}\/`\)/) | |
| 86 | assert.match(shell, /onBrowse=\$\{launchInBrowse\}/) | |
| 87 | }) | |
| 88 | ||
| 89 | test('escalation guard is fail-closed in the loader source', () => { | |
| 90 | const loader = readFileSync(join(root, 'backend/plugin-drive-loader.cjs'), 'utf8') | |
| 91 | assert.match(loader, /setEnabled\(key, false\)/) | |
| 92 | assert.match(loader, /acceptEscalation/) | |
| 93 | assert.match(loader, /escalated: true/) | |
| 94 | }) |
| … | … | @@ -0,0 +1,180 @@ |
| 1 | import test from 'node:test' | |
| 2 | import assert from 'node:assert/strict' | |
| 3 | import { createHash } from 'node:crypto' | |
| 4 | import { createRequire } from 'node:module' | |
| 5 | ||
| 6 | const require = createRequire(import.meta.url) | |
| 7 | const { ContentShield } = require('../backend/content-shield.cjs') | |
| 8 | const { ShieldListSync, ShieldListSyncError, DRIVE_LIST_PREFIX, MAX_LIST_BYTES } = require('../backend/shield-list-sync.cjs') | |
| 9 | ||
| 10 | const KEY = 'c'.repeat(64) | |
| 11 | const sha256Hex = (buf) => createHash('sha256').update(buf).digest('hex') | |
| 12 | ||
| 13 | function fakeDrive (files) { | |
| 14 | const calls = [] | |
| 15 | return { | |
| 16 | calls, | |
| 17 | files, | |
| 18 | fetch: async (driveKey, path) => { | |
| 19 | calls.push(`${driveKey.slice(0, 4)}:${path}`) | |
| 20 | const value = files[path] | |
| 21 | return value == null ? null : { content: Buffer.from(value) } | |
| 22 | } | |
| 23 | } | |
| 24 | } | |
| 25 | ||
| 26 | function makeSync ({ files, shield } = {}) { | |
| 27 | const persisted = [] | |
| 28 | const drive = fakeDrive(files || {}) | |
| 29 | const engine = shield || new ContentShield({ builtinList: false }) | |
| 30 | const sync = new ShieldListSync({ | |
| 31 | shield: engine, | |
| 32 | fetchDriveFile: drive.fetch, | |
| 33 | sha256Hex, | |
| 34 | persistMeta: async (meta) => { persisted.push(structuredClone(meta)) }, | |
| 35 | now: () => 1234 | |
| 36 | }) | |
| 37 | return { sync, shield: engine, drive, persisted } | |
| 38 | } | |
| 39 | ||
| 40 | test('subscribe fetches, verifies, registers, and persists a drive list', async () => { | |
| 41 | const filters = '||ads.example.com^\n##.promo\n' | |
| 42 | const { sync, shield, persisted } = makeSync({ | |
| 43 | files: { | |
| 44 | '/manifest.json': JSON.stringify({ name: 'pear-default', version: '3', sha256: sha256Hex(filters) }), | |
| 45 | '/filters.txt': filters | |
| 46 | } | |
| 47 | }) | |
| 48 | ||
| 49 | const result = await sync.subscribe(KEY) | |
| 50 | assert.equal(result.changed, true) | |
| 51 | assert.equal(result.name, 'pear-default') | |
| 52 | assert.equal(result.version, '3') | |
| 53 | assert.ok(result.rules >= 2) | |
| 54 | ||
| 55 | assert.equal(shield.shouldBlockUrl('https://ads.example.com/x.js').blocked, true) | |
| 56 | assert.ok(shield.stats().lists.includes(DRIVE_LIST_PREFIX + KEY)) | |
| 57 | assert.equal(persisted.length, 1) | |
| 58 | assert.equal(persisted[0][KEY].version, '3') | |
| 59 | assert.deepEqual(sync.subscriptions().map(item => item.driveKey), [KEY]) | |
| 60 | }) | |
| 61 | ||
| 62 | test('a checksum mismatch fails closed: nothing registered', async () => { | |
| 63 | const { sync, shield } = makeSync({ | |
| 64 | files: { | |
| 65 | '/manifest.json': JSON.stringify({ version: '1', sha256: 'f'.repeat(64) }), | |
| 66 | '/filters.txt': '||ads.example.com^' | |
| 67 | } | |
| 68 | }) | |
| 69 | ||
| 70 | await assert.rejects(sync.subscribe(KEY), (err) => { | |
| 71 | assert.ok(err instanceof ShieldListSyncError) | |
| 72 | assert.equal(err.code, 'checksum-mismatch') | |
| 73 | return true | |
| 74 | }) | |
| 75 | assert.equal(shield.shouldBlockUrl('https://ads.example.com/x.js').blocked, false) | |
| 76 | assert.equal(sync.subscriptions().length, 0) | |
| 77 | }) | |
| 78 | ||
| 79 | test('a missing or oversized list fails closed', async () => { | |
| 80 | const { sync } = makeSync({ files: {} }) | |
| 81 | await assert.rejects(sync.subscribe(KEY), err => err.code === 'list-unavailable') | |
| 82 | ||
| 83 | const big = makeSync({ files: { '/filters.txt': 'a'.repeat(MAX_LIST_BYTES + 1) } }) | |
| 84 | await assert.rejects(big.sync.subscribe(KEY), err => err.code === 'list-too-large') | |
| 85 | }) | |
| 86 | ||
| 87 | test('refresh skips the download when version and checksum are unchanged, hot-swaps on change', async () => { | |
| 88 | const v1 = '||ads.example.com^' | |
| 89 | const files = { | |
| 90 | '/manifest.json': JSON.stringify({ version: '1', sha256: sha256Hex(v1) }), | |
| 91 | '/filters.txt': v1 | |
| 92 | } | |
| 93 | const { sync, shield, drive } = makeSync({ files }) | |
| 94 | await sync.subscribe(KEY) | |
| 95 | ||
| 96 | const callsAfterSubscribe = drive.calls.length | |
| 97 | const unchanged = await sync.refresh(KEY) | |
| 98 | assert.equal(unchanged.changed, false) | |
| 99 | // Only the manifest was consulted — no rules re-download on same version. | |
| 100 | assert.deepEqual(drive.calls.slice(callsAfterSubscribe), [`${KEY.slice(0, 4)}:/manifest.json`]) | |
| 101 | ||
| 102 | const v2 = '||ads.example.com^\n||tracker.example.net^' | |
| 103 | files['/manifest.json'] = JSON.stringify({ version: '2', sha256: sha256Hex(v2) }) | |
| 104 | files['/filters.txt'] = v2 | |
| 105 | const changed = await sync.refresh(KEY) | |
| 106 | assert.equal(changed.changed, true) | |
| 107 | assert.equal(changed.version, '2') | |
| 108 | assert.equal(shield.shouldBlockUrl('https://tracker.example.net/p.gif').blocked, true) | |
| 109 | }) | |
| 110 | ||
| 111 | test('restore + shield state give offline boot without any fetch', async () => { | |
| 112 | // First session: subscribe and capture durable state. | |
| 113 | const filters = '||ads.example.com^' | |
| 114 | const first = makeSync({ | |
| 115 | files: { | |
| 116 | '/manifest.json': JSON.stringify({ name: 'pear-default', version: '5', sha256: sha256Hex(filters) }), | |
| 117 | '/filters.txt': filters | |
| 118 | } | |
| 119 | }) | |
| 120 | await first.sync.subscribe(KEY) | |
| 121 | const durableShieldState = first.shield.exportListState() | |
| 122 | const durableMeta = first.persisted.at(-1) | |
| 123 | ||
| 124 | // Second session: no network at all — restore from durable state only. | |
| 125 | const offlineShield = new ContentShield({ builtinList: false }) | |
| 126 | offlineShield.importListState(durableShieldState) | |
| 127 | const offline = makeSync({ files: {}, shield: offlineShield }) | |
| 128 | const restored = offline.sync.restore(durableMeta) | |
| 129 | ||
| 130 | assert.equal(restored, 1) | |
| 131 | assert.equal(offline.sync.isSubscribed(KEY), true) | |
| 132 | assert.equal(offline.sync.subscriptions()[0].version, '5') | |
| 133 | assert.equal(offlineShield.shouldBlockUrl('https://ads.example.com/x.js').blocked, true) | |
| 134 | assert.deepEqual(offline.drive.calls, []) | |
| 135 | }) | |
| 136 | ||
| 137 | test('unsubscribe removes the rules and the durable metadata', async () => { | |
| 138 | const filters = '||ads.example.com^' | |
| 139 | const { sync, shield, persisted } = makeSync({ | |
| 140 | files: { '/filters.txt': filters } | |
| 141 | }) | |
| 142 | await sync.subscribe(KEY) | |
| 143 | assert.equal(shield.shouldBlockUrl('https://ads.example.com/x.js').blocked, true) | |
| 144 | ||
| 145 | const removed = await sync.unsubscribe(KEY) | |
| 146 | assert.equal(removed.removed, true) | |
| 147 | assert.equal(shield.shouldBlockUrl('https://ads.example.com/x.js').blocked, false) | |
| 148 | assert.deepEqual(persisted.at(-1), {}) | |
| 149 | assert.equal(sync.subscriptions().length, 0) | |
| 150 | }) | |
| 151 | ||
| 152 | test('refreshAll isolates per-drive failures', async () => { | |
| 153 | const goodKey = 'd'.repeat(64) | |
| 154 | const filters = '||ads.example.com^' | |
| 155 | const files = { '/filters.txt': filters } | |
| 156 | const { sync } = makeSync({ files }) | |
| 157 | await sync.subscribe(goodKey) | |
| 158 | ||
| 159 | // Subscribe a second drive, then break its content for the next sweep. | |
| 160 | const flakyKey = 'e'.repeat(64) | |
| 161 | files['/filters.txt'] = filters | |
| 162 | await sync.subscribe(flakyKey) | |
| 163 | const originalFetch = sync._fetch | |
| 164 | sync._fetch = async (key, path) => { | |
| 165 | if (key === flakyKey) throw new Error('drive offline') | |
| 166 | return originalFetch(key, path) | |
| 167 | } | |
| 168 | ||
| 169 | const outcomes = await sync.refreshAll({ force: true }) | |
| 170 | const byKey = Object.fromEntries(outcomes.map(item => [item.driveKey, item])) | |
| 171 | assert.equal(byKey[goodKey].ok, true) | |
| 172 | assert.equal(byKey[flakyKey].ok, false) | |
| 173 | }) | |
| 174 | ||
| 175 | test('invalid keys and duplicate list names are rejected', async () => { | |
| 176 | const { sync } = makeSync({ files: {} }) | |
| 177 | await assert.rejects(sync.subscribe('not-a-key'), err => err.code === 'invalid-drive-key') | |
| 178 | await assert.rejects(sync.refresh('a'.repeat(64)), err => err.code === 'not-subscribed') | |
| 179 | assert.equal((await sync.unsubscribe('b'.repeat(64))).removed, false) | |
| 180 | }) |
| … | … | @@ -154,6 +154,15 @@ const C = { |
| 154 | 154 | CMD_PLUGIN_LIST: 235, |
| 155 | 155 | CMD_PLUGIN_SET_ENABLED: 236, |
| 156 | 156 | CMD_PLUGIN_REGISTER: 237, |
| 157 | CMD_SHIELD_SUBSCRIBE_LIST: 239, | |
| 158 | CMD_SHIELD_UNSUBSCRIBE_LIST: 240, | |
| 159 | CMD_SHIELD_REFRESH_LISTS: 241, | |
| 160 | CMD_PLUGIN_INSTALL_DRIVE: 242, | |
| 161 | CMD_PLUGIN_UPDATE_DRIVE: 243, | |
| 162 | CMD_PLUGIN_UNINSTALL: 244, | |
| 163 | CMD_PLUGIN_CATALOG: 245, | |
| 164 | CMD_PLUGIN_CATALOG_LOAD_DRIVE: 246, | |
| 165 | CMD_PLUGIN_CATALOG_REMOVE_SOURCE: 247, | |
| 157 | 166 | CMD_PRIVACY_STATUS: 238, |
| 158 | 167 | CMD_BRIDGE: 200, |
| 159 | 168 | EVT_READY: 100, |
| … | … | @@ -1827,9 +1827,9 @@ function Browse ({ rpc, C, navUrl, onNavigated, tabs, setTabs, activeId, setActi |
| 1827 | 1827 | </div>` |
| 1828 | 1828 | : html`<div className="browse-welcome-copy"> |
| 1829 | 1829 | <h2>Loading…</h2> |
| 1830 | <p>${t.kind === 'clearnet' | |
| 1831 | ? html`Fetching <code>${t.url}</code> over the clearnet proxy — shields and the privacy ladder apply.` | |
| 1832 | : html`Fetching <code>${t.url}</code> directly from its peers — first load of a cold drive can take a moment.`}</p> | |
| 1830 | <p>Fetching <code>${t.url}</code> ${t.kind === 'clearnet' | |
| 1831 | ? 'over the clearnet proxy — shields and the privacy ladder apply.' | |
| 1832 | : 'directly from its peers — first load of a cold drive can take a moment.'}</p> | |
| 1833 | 1833 | </div>`} |
| 1834 | 1834 | <div className="browse-welcome-actions"> |
| 1835 | 1835 | <button className="btn primary" onClick=${() => go(t.url, t.id)}>${(t.status && /^error/i.test(t.status)) ? 'Retry' : 'Reload'}</button> |
| … | … | @@ -5026,12 +5026,17 @@ function DeviceSync ({ rpc, C }) { |
| 5026 | 5026 | // (docs/BROWSER_PARITY_PLAN.md Phases 1–3). Toggle, per-drive allowlist/ |
| 5027 | 5027 | // strict, named lists, and plugin kill-switches persist in user-data; |
| 5028 | 5028 | // CMD_SHIELD_* / CMD_PLUGIN_* feed this panel and the urlbar chip. |
| 5029 | function ContentShieldSection ({ rpc, C, activeDriveKey = '' }) { | |
| 5029 | function ContentShieldSection ({ rpc, C, activeDriveKey = '', onBrowse }) { | |
| 5030 | 5030 | const [enabled, setEnabled] = useState(true) |
| 5031 | 5031 | const [status, setStatus] = useState(null) |
| 5032 | 5032 | const [plugins, setPlugins] = useState([]) |
| 5033 | 5033 | const [busy, setBusy] = useState(false) |
| 5034 | 5034 | const [err, setErr] = useState('') |
| 5035 | const [subscribeKey, setSubscribeKey] = useState('') | |
| 5036 | const [installKey, setInstallKey] = useState('') | |
| 5037 | const [escalation, setEscalation] = useState(null) // { driveKey, added: [] } | |
| 5038 | const [catalog, setCatalog] = useState({ entries: [], sources: [] }) | |
| 5039 | const [catalogSourceKey, setCatalogSourceKey] = useState('') | |
| 5035 | 5040 | const driveKey = typeof activeDriveKey === 'string' && /^[0-9a-f]{64}$/i.test(activeDriveKey) |
| 5036 | 5041 | ? activeDriveKey.toLowerCase() |
| 5037 | 5042 | : '' |
| … | … | @@ -5055,6 +5060,11 @@ function ContentShieldSection ({ rpc, C, activeDriveKey = '' }) { |
| 5055 | 5060 | .then((result) => { if (!disposed) setPlugins(result?.plugins || []) }) |
| 5056 | 5061 | .catch(() => {}) |
| 5057 | 5062 | } |
| 5063 | if (C.CMD_PLUGIN_CATALOG != null) { | |
| 5064 | rpc.request(C.CMD_PLUGIN_CATALOG) | |
| 5065 | .then((result) => { if (!disposed && result) setCatalog({ entries: result.entries || [], sources: result.sources || [] }) }) | |
| 5066 | .catch(() => {}) | |
| 5067 | } | |
| 5058 | 5068 | } |
| 5059 | 5069 | refresh() |
| 5060 | 5070 | const timer = setInterval(refresh, 5000) |
| … | … | @@ -5108,6 +5118,109 @@ function ContentShieldSection ({ rpc, C, activeDriveKey = '' }) { |
| 5108 | 5118 | finally { setBusy(false) } |
| 5109 | 5119 | } |
| 5110 | 5120 | |
| 5121 | const refreshStatusAndPlugins = async () => { | |
| 5122 | const result = await rpc.request(C.CMD_SHIELD_STATUS, driveKey ? { driveKey } : {}).catch(() => null) | |
| 5123 | if (result) setStatus(result) | |
| 5124 | const listed = await rpc.request(C.CMD_PLUGIN_LIST).catch(() => null) | |
| 5125 | if (listed) setPlugins(listed.plugins || []) | |
| 5126 | if (C.CMD_PLUGIN_CATALOG != null) { | |
| 5127 | const listing = await rpc.request(C.CMD_PLUGIN_CATALOG).catch(() => null) | |
| 5128 | if (listing) setCatalog({ entries: listing.entries || [], sources: listing.sources || [] }) | |
| 5129 | } | |
| 5130 | } | |
| 5131 | ||
| 5132 | const subscribeList = async () => { | |
| 5133 | const key = subscribeKey.trim().toLowerCase() | |
| 5134 | if (!/^[0-9a-f]{64}$/.test(key) || C.CMD_SHIELD_SUBSCRIBE_LIST == null) return | |
| 5135 | setBusy(true); setErr('') | |
| 5136 | try { | |
| 5137 | await rpc.request(C.CMD_SHIELD_SUBSCRIBE_LIST, { driveKey: key }, 30000) | |
| 5138 | setSubscribeKey('') | |
| 5139 | await refreshStatusAndPlugins() | |
| 5140 | } catch (e) { setErr(`subscribe: ${e.message}`) } | |
| 5141 | finally { setBusy(false) } | |
| 5142 | } | |
| 5143 | ||
| 5144 | const unsubscribeList = async (key) => { | |
| 5145 | if (C.CMD_SHIELD_UNSUBSCRIBE_LIST == null) return | |
| 5146 | setBusy(true); setErr('') | |
| 5147 | try { | |
| 5148 | await rpc.request(C.CMD_SHIELD_UNSUBSCRIBE_LIST, { driveKey: key }) | |
| 5149 | await refreshStatusAndPlugins() | |
| 5150 | } catch (e) { setErr(`unsubscribe: ${e.message}`) } | |
| 5151 | finally { setBusy(false) } | |
| 5152 | } | |
| 5153 | ||
| 5154 | const refreshLists = async (key) => { | |
| 5155 | if (C.CMD_SHIELD_REFRESH_LISTS == null) return | |
| 5156 | setBusy(true); setErr('') | |
| 5157 | try { | |
| 5158 | await rpc.request(C.CMD_SHIELD_REFRESH_LISTS, key ? { driveKey: key, force: true } : {}, 30000) | |
| 5159 | await refreshStatusAndPlugins() | |
| 5160 | } catch (e) { setErr(`refresh: ${e.message}`) } | |
| 5161 | finally { setBusy(false) } | |
| 5162 | } | |
| 5163 | ||
| 5164 | const installPluginByKey = async (key) => { | |
| 5165 | const normalized = String(key || '').trim().toLowerCase() | |
| 5166 | if (!/^[0-9a-f]{64}$/.test(normalized) || C.CMD_PLUGIN_INSTALL_DRIVE == null) return | |
| 5167 | setBusy(true); setErr('') | |
| 5168 | try { | |
| 5169 | await rpc.request(C.CMD_PLUGIN_INSTALL_DRIVE, { driveKey: normalized }, 30000) | |
| 5170 | await refreshStatusAndPlugins() | |
| 5171 | } catch (e) { setErr(`install: ${e.message}`) } | |
| 5172 | finally { setBusy(false) } | |
| 5173 | } | |
| 5174 | ||
| 5175 | const installPlugin = async () => { | |
| 5176 | await installPluginByKey(installKey) | |
| 5177 | setInstallKey('') | |
| 5178 | } | |
| 5179 | ||
| 5180 | const loadCatalogSource = async () => { | |
| 5181 | const key = catalogSourceKey.trim().toLowerCase() | |
| 5182 | if (!/^[0-9a-f]{64}$/.test(key) || C.CMD_PLUGIN_CATALOG_LOAD_DRIVE == null) return | |
| 5183 | setBusy(true); setErr('') | |
| 5184 | try { | |
| 5185 | await rpc.request(C.CMD_PLUGIN_CATALOG_LOAD_DRIVE, { driveKey: key }, 30000) | |
| 5186 | setCatalogSourceKey('') | |
| 5187 | await refreshStatusAndPlugins() | |
| 5188 | } catch (e) { setErr(`catalog: ${e.message}`) } | |
| 5189 | finally { setBusy(false) } | |
| 5190 | } | |
| 5191 | ||
| 5192 | const removeCatalogSource = async (key) => { | |
| 5193 | if (C.CMD_PLUGIN_CATALOG_REMOVE_SOURCE == null) return | |
| 5194 | setBusy(true); setErr('') | |
| 5195 | try { | |
| 5196 | await rpc.request(C.CMD_PLUGIN_CATALOG_REMOVE_SOURCE, { driveKey: key }) | |
| 5197 | await refreshStatusAndPlugins() | |
| 5198 | } catch (e) { setErr(`catalog: ${e.message}`) } | |
| 5199 | finally { setBusy(false) } | |
| 5200 | } | |
| 5201 | ||
| 5202 | const updatePlugin = async (id, acceptEscalation = false) => { | |
| 5203 | if (C.CMD_PLUGIN_UPDATE_DRIVE == null) return | |
| 5204 | setBusy(true); setErr('') | |
| 5205 | try { | |
| 5206 | const outcome = await rpc.request(C.CMD_PLUGIN_UPDATE_DRIVE, { driveKey: id, acceptEscalation }, 30000) | |
| 5207 | setEscalation(outcome && outcome.escalated ? { driveKey: id, added: outcome.added || [] } : null) | |
| 5208 | await refreshStatusAndPlugins() | |
| 5209 | } catch (e) { setErr(`update: ${e.message}`) } | |
| 5210 | finally { setBusy(false) } | |
| 5211 | } | |
| 5212 | ||
| 5213 | const uninstallPlugin = async (id) => { | |
| 5214 | if (C.CMD_PLUGIN_UNINSTALL == null) return | |
| 5215 | setBusy(true); setErr('') | |
| 5216 | try { | |
| 5217 | await rpc.request(C.CMD_PLUGIN_UNINSTALL, { driveKey: id }) | |
| 5218 | if (escalation?.driveKey === id) setEscalation(null) | |
| 5219 | await refreshStatusAndPlugins() | |
| 5220 | } catch (e) { setErr(`uninstall: ${e.message}`) } | |
| 5221 | finally { setBusy(false) } | |
| 5222 | } | |
| 5223 | ||
| 5111 | 5224 | const listNames = (status && (status.listDetails || status.lists)) || [] |
| 5112 | 5225 | const listLabel = Array.isArray(listNames) |
| 5113 | 5226 | ? listNames.map((l) => (typeof l === 'string' ? l : l.name)).join(', ') |
| … | … | @@ -5157,26 +5270,122 @@ function ContentShieldSection ({ rpc, C, activeDriveKey = '' }) { |
| 5157 | 5270 | ${status && Array.isArray(status.topRules) && status.topRules.length > 0 && html` |
| 5158 | 5271 | <div className="settings-subtle">Top rules: ${status.topRules.slice(0, 3).map(item => `${item.rule} (${item.hits})`).join(' · ')}</div> |
| 5159 | 5272 | `} |
| 5160 | ${plugins.length > 0 && html` | |
| 5161 | <div className="settings-row" data-testid="content-shield-plugins"> | |
| 5162 | <div style=${{ width: '100%' }}> | |
| 5163 | <div className="settings-label">Pear Plugins</div> | |
| 5164 | <div className="settings-subtle">Kill-switch disables a plugin's filter/style/script contributions without uninstalling it.</div> | |
| 5165 | ${plugins.map((p) => html` | |
| 5166 | <div className="settings-row" key=${p.id} data-testid=${'plugin-row-' + p.id}> | |
| 5167 | <div> | |
| 5168 | <div className="settings-label">${p.name || p.id}</div> | |
| 5169 | <div className="settings-subtle">${(p.capabilities || []).join(', ') || 'no capabilities'}</div> | |
| 5170 | </div> | |
| 5273 | ||
| 5274 | <div className="settings-row" data-testid="content-shield-list-sync"> | |
| 5275 | <div style=${{ width: '100%' }}> | |
| 5276 | <div className="settings-label">Filter lists from the swarm</div> | |
| 5277 | <div className="settings-subtle">Subscribe to a filter-list Hyperdrive by key. Rules sync peer-to-peer, hot-swap when the publisher updates, and keep working offline — no CDN, no list-fetch fingerprint.</div> | |
| 5278 | <div className="settings-row"> | |
| 5279 | <div className="profile-field" style=${{ flex: 1 }}> | |
| 5280 | <input className="profile-input" placeholder="64-hex filter-list drive key" value=${subscribeKey} | |
| 5281 | data-testid="content-shield-subscribe-input" | |
| 5282 | onInput=${(e) => setSubscribeKey(e.target.value)} | |
| 5283 | onKeyDown=${(e) => e.key === 'Enter' && subscribeList()} /> | |
| 5284 | </div> | |
| 5285 | <button className="btn" data-testid="content-shield-subscribe" onClick=${subscribeList} | |
| 5286 | disabled=${busy || !/^[0-9a-f]{64}$/i.test(subscribeKey.trim())}>Subscribe</button> | |
| 5287 | <button className="btn subtle" onClick=${() => refreshLists()} disabled=${busy || !(status?.subscriptions?.length)}>Refresh all</button> | |
| 5288 | </div> | |
| 5289 | ${(status?.subscriptions || []).map((sub) => html` | |
| 5290 | <div className="settings-row" key=${sub.driveKey} data-testid=${'shield-list-row-' + sub.driveKey}> | |
| 5291 | <div> | |
| 5292 | <div className="settings-label">${sub.name || sub.driveKey.slice(0, 12) + '…'}${sub.version ? ` · v${sub.version}` : ''}</div> | |
| 5293 | <div className="settings-subtle">${sub.rules || 0} rules · ${sub.driveKey.slice(0, 16)}…</div> | |
| 5294 | </div> | |
| 5295 | <div className="settings-inline-actions"> | |
| 5296 | <button className="btn small subtle" onClick=${() => refreshLists(sub.driveKey)} disabled=${busy}>Refresh</button> | |
| 5297 | <button className="btn small subtle danger" onClick=${() => unsubscribeList(sub.driveKey)} disabled=${busy}>Remove</button> | |
| 5298 | </div> | |
| 5299 | </div> | |
| 5300 | `)} | |
| 5301 | </div> | |
| 5302 | </div> | |
| 5303 | ||
| 5304 | <div className="settings-row" data-testid="plugin-catalog"> | |
| 5305 | <div style=${{ width: '100%' }}> | |
| 5306 | <div className="settings-label">Plugin catalog</div> | |
| 5307 | <div className="settings-subtle">Curated plugins and AI add-ons you can add yourself. Installing a plugin shows its declared capabilities and records your grant; app entries open as ordinary P2P apps gated by their own manifests. Load more catalogues from a drive key below.</div> | |
| 5308 | ${catalog.entries.map((entry) => html` | |
| 5309 | <div className="settings-row" key=${entry.id} data-testid=${'catalog-entry-' + entry.id}> | |
| 5310 | <div> | |
| 5311 | <div className="settings-label">${entry.name}${entry.verified ? html`<span title="Curated entry" style=${{ marginLeft: '5px', color: '#3fb950', fontSize: '12px' }}>✦</span>` : ''}</div> | |
| 5312 | <div className="settings-subtle">${entry.description}</div> | |
| 5313 | <div className="settings-subtle">${entry.kind === 'app' ? 'P2P app' : 'plugin'}${entry.capabilities?.length ? ` · ${entry.capabilities.join(', ')}` : ''}${entry.source !== 'builtin' ? ` · from ${String(entry.source).slice(0, 8)}…` : ''}</div> | |
| 5314 | </div> | |
| 5315 | <div className="settings-inline-actions"> | |
| 5316 | ${entry.kind === 'app' && entry.driveKey && html` | |
| 5317 | <button className="btn small" data-testid=${'catalog-open-' + entry.id} | |
| 5318 | onClick=${() => onBrowse && onBrowse(`hyper://${entry.driveKey}/`)} | |
| 5319 | disabled=${busy || !onBrowse}>Open</button> | |
| 5320 | `} | |
| 5321 | ${entry.kind === 'plugin' && entry.driveKey && !entry.installed && html` | |
| 5322 | <button className="btn small" data-testid=${'catalog-install-' + entry.id} | |
| 5323 | onClick=${() => installPluginByKey(entry.driveKey)} disabled=${busy}>Install</button> | |
| 5324 | `} | |
| 5325 | ${entry.kind === 'plugin' && entry.installed && html`<span className="settings-subtle">Installed</span>`} | |
| 5326 | ${entry.kind === 'plugin' && !entry.driveKey && html`<span className="settings-subtle" title=${entry.unpublished ? `Publish ${entry.unpublished} to enable` : ''}>Publish pending</span>`} | |
| 5327 | </div> | |
| 5328 | </div> | |
| 5329 | `)} | |
| 5330 | <div className="settings-row"> | |
| 5331 | <div className="profile-field" style=${{ flex: 1 }}> | |
| 5332 | <input className="profile-input" placeholder="64-hex catalogue drive key" value=${catalogSourceKey} | |
| 5333 | data-testid="plugin-catalog-source-input" | |
| 5334 | onInput=${(e) => setCatalogSourceKey(e.target.value)} | |
| 5335 | onKeyDown=${(e) => e.key === 'Enter' && loadCatalogSource()} /> | |
| 5336 | </div> | |
| 5337 | <button className="btn subtle" data-testid="plugin-catalog-load" onClick=${loadCatalogSource} | |
| 5338 | disabled=${busy || !/^[0-9a-f]{64}$/i.test(catalogSourceKey.trim())}>Load catalogue</button> | |
| 5339 | </div> | |
| 5340 | ${catalog.sources.map((source) => html` | |
| 5341 | <div className="settings-row" key=${source.driveKey}> | |
| 5342 | <div className="settings-subtle">${source.name} · ${source.entryCount} entries · ${source.driveKey.slice(0, 16)}…</div> | |
| 5343 | <button className="btn small subtle danger" onClick=${() => removeCatalogSource(source.driveKey)} disabled=${busy}>Remove</button> | |
| 5344 | </div> | |
| 5345 | `)} | |
| 5346 | </div> | |
| 5347 | </div> | |
| 5348 | ||
| 5349 | <div className="settings-row" data-testid="content-shield-plugins"> | |
| 5350 | <div style=${{ width: '100%' }}> | |
| 5351 | <div className="settings-label">Pear Plugins</div> | |
| 5352 | <div className="settings-subtle">Plugins are Hyperdrives with declared capabilities. An update that requests new capabilities is disabled automatically until you re-approve it. Kill-switch disables a plugin's filter/style/script contributions without uninstalling it.</div> | |
| 5353 | <div className="settings-row"> | |
| 5354 | <div className="profile-field" style=${{ flex: 1 }}> | |
| 5355 | <input className="profile-input" placeholder="64-hex plugin drive key" value=${installKey} | |
| 5356 | data-testid="plugin-install-input" | |
| 5357 | onInput=${(e) => setInstallKey(e.target.value)} | |
| 5358 | onKeyDown=${(e) => e.key === 'Enter' && installPlugin()} /> | |
| 5359 | </div> | |
| 5360 | <button className="btn" data-testid="plugin-install" onClick=${installPlugin} | |
| 5361 | disabled=${busy || !/^[0-9a-f]{64}$/i.test(installKey.trim())}>Install</button> | |
| 5362 | </div> | |
| 5363 | ${escalation && html` | |
| 5364 | <div className="apps-error" data-testid="plugin-escalation"> | |
| 5365 | Update for ${escalation.driveKey.slice(0, 12)}… requests new capabilities: ${escalation.added.join(', ')}. | |
| 5366 | <button className="btn small" onClick=${() => updatePlugin(escalation.driveKey, true)} disabled=${busy}>Accept and re-enable</button> | |
| 5367 | </div> | |
| 5368 | `} | |
| 5369 | ${plugins.map((p) => html` | |
| 5370 | <div className="settings-row" key=${p.id} data-testid=${'plugin-row-' + p.id}> | |
| 5371 | <div> | |
| 5372 | <div className="settings-label">${p.name || p.id}</div> | |
| 5373 | <div className="settings-subtle">${(p.capabilities || []).join(', ') || 'no capabilities'}${p.version ? ` · v${p.version}` : ''}</div> | |
| 5374 | </div> | |
| 5375 | <div className="settings-inline-actions"> | |
| 5376 | ${/^[0-9a-f]{64}$/.test(p.id) && html` | |
| 5377 | <button className="btn small subtle" data-testid=${'plugin-update-' + p.id} onClick=${() => updatePlugin(p.id)} disabled=${busy}>Update</button> | |
| 5378 | <button className="btn small subtle danger" data-testid=${'plugin-uninstall-' + p.id} onClick=${() => uninstallPlugin(p.id)} disabled=${busy}>Uninstall</button> | |
| 5379 | `} | |
| 5171 | 5380 | <label className="login-scope${p.enabled ? ' on' : ''}"> |
| 5172 | 5381 | <input type="checkbox" checked=${!!p.enabled} disabled=${busy} |
| 5173 | 5382 | onChange=${() => togglePlugin(p.id, p.enabled)} data-testid=${'plugin-enabled-' + p.id} /> |
| 5174 | 5383 | </label> |
| 5175 | 5384 | </div> |
| 5176 | `)} | |
| 5177 | </div> | |
| 5385 | </div> | |
| 5386 | `)} | |
| 5178 | 5387 | </div> |
| 5179 | `} | |
| 5388 | </div> | |
| 5180 | 5389 | </div> |
| 5181 | 5390 | ` |
| 5182 | 5391 | } |
| … | … | @@ -5421,7 +5630,7 @@ function ExperimentalSection ({ rpc, C, onAutobeeChange, onDeviceSyncChange }) { |
| 5421 | 5630 | ` |
| 5422 | 5631 | } |
| 5423 | 5632 | |
| 5424 | function Settings ({ rpc, C, status, storagePath, log, appearanceTheme, onAppearanceThemeChange, activeDriveKey = '' }) { | |
| 5633 | function Settings ({ rpc, C, status, storagePath, log, appearanceTheme, onAppearanceThemeChange, activeDriveKey = '', onBrowse }) { | |
| 5425 | 5634 | const [identity, setIdentity] = useState(null) |
| 5426 | 5635 | const [seedPhrase, setSeedPhrase] = useState(null) |
| 5427 | 5636 | const [err, setErr] = useState('') |
| … | … | @@ -5705,7 +5914,7 @@ function Settings ({ rpc, C, status, storagePath, log, appearanceTheme, onAppear |
| 5705 | 5914 | |
| 5706 | 5915 | <h2>Content Shield</h2> |
| 5707 | 5916 | <p className="subtitle">Brave-style ad and tracker blocking, enforced inside the browser's own proxy — blocked requests never reach a peer, a relay, or the network. Named filter lists hot-swap offline; per-drive allowlist and strict mode live here; Pear Plugins feed the same engine with a kill switch.</p> |
| 5708 | <${ContentShieldSection} rpc=${rpc} C=${C} activeDriveKey=${activeDriveKey} /> | |
| 5917 | <${ContentShieldSection} rpc=${rpc} C=${C} activeDriveKey=${activeDriveKey} onBrowse=${onBrowse} /> | |
| 5709 | 5918 | |
| 5710 | 5919 | <h2>Clearnet & privacy</h2> |
| 5711 | 5920 | <p className="subtitle">Browse https:// sites through the browser-owned clearnet proxy (shields on) or direct load. Privacy ladder: HTTPS-only upgrades, tracking-parameter stripping, referrer policy, fingerprint farbling, third-party cookie isolation in proxy mode.</p> |
| … | … | @@ -6431,7 +6640,7 @@ export function App ({ rpc, C, storagePath }) { |
| 6431 | 6640 | ${tab === 'apps' && html`<${Apps} rpc=${rpc} C=${C} onLaunch=${launchInBrowse} />`} |
| 6432 | 6641 | ${tab === 'sites' && html`<${Sites} rpc=${rpc} C=${C} onBrowse=${launchInBrowse} />`} |
| 6433 | 6642 | ${tab === 'library' && html`<${Library} rpc=${rpc} C=${C} onBrowse=${launchInBrowse} />`} |
| 6434 | ${tab === 'settings' && html`<${Settings} rpc=${rpc} C=${C} status=${status} storagePath=${storagePath} log=${log} appearanceTheme=${appearanceTheme} onAppearanceThemeChange=${setAndPersistAppearanceTheme} activeDriveKey=${(tabs.find((t) => t.id === browseActiveId) && tabDriveKey(tabs.find((t) => t.id === browseActiveId))) || ''} />`} | |
| 6643 | ${tab === 'settings' && html`<${Settings} rpc=${rpc} C=${C} status=${status} storagePath=${storagePath} log=${log} appearanceTheme=${appearanceTheme} onAppearanceThemeChange=${setAndPersistAppearanceTheme} activeDriveKey=${(tabs.find((t) => t.id === browseActiveId) && tabDriveKey(tabs.find((t) => t.id === browseActiveId))) || ''} onBrowse=${launchInBrowse} />`} | |
| 6435 | 6644 | </div> |
| 6436 | 6645 | |
| 6437 | 6646 | <div className=${'status ' + statusClass}> |