| … | … | @@ -60,92 +60,63 @@ function applyAppearanceTheme (theme) { |
| 60 | 60 | |
| 61 | 61 | applyAppearanceTheme(readCachedAppearanceTheme()) |
| 62 | 62 | |
| 63 | | // Vetted against https://github.com/holepunchto/pear-aliases — these |
| 64 | | // are the canonical pear:// keys for Holepunch-ecosystem apps. |
| 65 | | // |
| 66 | | // The `type` field + the window-vs-tab distinction is adopted from |
| 67 | | // Drache93's Pear Browser (https://github.com/Drache93/pear-browser), which |
| 68 | | // pioneered running P2P apps in a tab via pear-request (htmx hooked to a |
| 69 | | // worker pipe). Two kinds of apps, exactly as Drache93 models them: |
| 70 | | // - 'standalone' : a full Bare app with its own UI (Keet, PearPass …). It |
| 71 | | // can only open in its OWN WINDOW — there is no htmx/pipe |
| 72 | | // UI to stream into a tab. (Drache93: "Standalone site |
| 73 | | // opened in new window".) |
| 74 | | // - 'hypersite' : a pear-request/htmx app whose UI IS served over a pipe, |
| 75 | | // so it renders HEADLESS in a tab. |
| 76 | | // The UI gates the action on this: standalone → "Open" (window); hypersite → |
| 77 | | // "Run in tab". This removes the confusing "Run in tab" on apps that can't. |
| 78 | | const PEERCORD_LINK = 'pear://wmir47w7mai3b1skj66mx7fzso6k6o91kipaney7gtt69npimouy' |
| 79 | | |
| 80 | | // First rollout: Peercord. Add other standalone pear:// apps here only when |
| 81 | | // they need the same first-run window/trust warning. |
| 82 | | const STANDALONE_PRELAUNCH_WARNINGS = { |
| 83 | | peercord: { |
| 84 | | key: 'peercord', |
| 85 | | link: PEERCORD_LINK, |
| 86 | | appName: 'Peercord', |
| 87 | | title: 'Before opening Peercord', |
| 88 | | body: 'Peercord opens in its own Pear window. On first launch, Pear may ask you to review and approve a persistent third-party trust prompt.', |
| 89 | | trust: 'Approving it executes third-party code and creates a persistent trust decision.' |
| 90 | | } |
| 91 | | } |
| 63 | // Legacy native apps remain discoverable, but their former remote application |
| 64 | // references are opaque migration identifiers. PearBrowser can never fetch or |
| 65 | // execute them; the publisher must provide a verified native v3 package. |
| 92 | 66 | |
| 93 | 67 | const FEATURED_APPS = [ |
| 94 | 68 | { |
| 95 | 69 | id: 'keet', |
| 96 | 70 | name: 'Keet', |
| 97 | | type: 'standalone', |
| 71 | nativeDelivery: { status: 'migration-required' }, |
| 98 | 72 | tagline: 'End-to-end encrypted P2P chat, voice, and video calls by Holepunch.', |
| 99 | | link: 'pear://oeeoz3w6fjjt7bym3ndpa6hhicm8f8naxyk11z4iypeoupn6jzpo', |
| 73 | legacyMigrationId: 'oeeoz3w6fjjt7bym3ndpa6hhicm8f8naxyk11z4iypeoupn6jzpo', |
| 100 | 74 | initial: 'K', |
| 101 | 75 | gradient: 'linear-gradient(135deg, #fbbf24, #f97316)' |
| 102 | 76 | }, |
| 103 | 77 | { |
| 104 | 78 | id: 'pearpass', |
| 105 | 79 | name: 'PearPass', |
| 106 | | type: 'standalone', |
| 80 | nativeDelivery: { status: 'migration-required' }, |
| 107 | 81 | tagline: 'Peer-to-peer password manager from Tether — synced across devices without a cloud.', |
| 108 | | link: 'pear://tywsat7gz8m65ejx4zjn3773pbdc4j8m66tukis8dgzekraymtzo', |
| 82 | legacyMigrationId: 'tywsat7gz8m65ejx4zjn3773pbdc4j8m66tukis8dgzekraymtzo', |
| 109 | 83 | initial: 'P', |
| 110 | 84 | gradient: 'linear-gradient(135deg, #3fb950, #58a6ff)' |
| 111 | 85 | }, |
| 112 | | // anonGPT — private P2P AI chat. A full Pear app: opens in its own window. |
| 86 | // anonGPT — private P2P AI chat. Its legacy native release needs migration. |
| 113 | 87 | // (The in-browser window.pear.anongpt buyer shim in backend/anongpt-buyer.js |
| 114 | | // is a separate hyper:// hosting path, not exercised by this pear:// launch.) |
| 88 | // is a separate hyper:// hosting path, not an executable delivery path.) |
| 115 | 89 | { |
| 116 | 90 | id: 'anongpt', |
| 117 | 91 | name: 'anonGPT', |
| 118 | | type: 'standalone', |
| 92 | nativeDelivery: { status: 'migration-required' }, |
| 119 | 93 | tagline: 'Private P2P AI chat — pay-per-inference from a HiveMind seller, with signed receipts.', |
| 120 | | link: 'pear://rpzh3fsgg38kfir9nmae7x3o8ubofddzzixr5js4mxd6a6drb6wo', |
| 94 | legacyMigrationId: 'rpzh3fsgg38kfir9nmae7x3o8ubofddzzixr5js4mxd6a6drb6wo', |
| 121 | 95 | initial: 'A', |
| 122 | 96 | gradient: 'linear-gradient(135deg, #22d3ee, #6366f1)' |
| 123 | 97 | }, |
| 124 | | // Paste — local-first, E2E-encrypted notes & clipboard sync. A full Pear |
| 125 | | // app: opens in its own window. Landing page (hyper://25a06bb3…) is in the |
| 98 | // Paste — local-first, E2E-encrypted notes & clipboard sync. Landing page |
| 99 | // (hyper://25a06bb3…) is in the |
| 126 | 100 | // default catalog as its homepage. Link MUST match the catalogue entry |
| 127 | | // (catalog-source/catalog.json id:pearpaste) — the LEAN, compacted key |
| 128 | | // qnax5k8o (≈432MB). The old u6oyh38 key carried ~35GB of dev-history blob |
| 129 | | // bloat (see app-pinning notes) and forced a huge first-load download. |
| 101 | // (catalog-source/catalog.json id:pearpaste). Its legacy native release |
| 102 | // awaits a publisher-provided verified v3 package. |
| 130 | 103 | { |
| 131 | 104 | id: 'pearpaste', |
| 132 | 105 | name: 'Paste', |
| 133 | | type: 'standalone', |
| 106 | nativeDelivery: { status: 'migration-required' }, |
| 134 | 107 | tagline: 'Local-first, end-to-end encrypted notes & clipboard sync for your own devices — no account, no cloud.', |
| 135 | | link: 'pear://qnax5k8ojtod51ci9qwkrawdof1hx5w3a7gqbueoqnzzq9dw5hfo', |
| 108 | legacyMigrationId: 'qnax5k8ojtod51ci9qwkrawdof1hx5w3a7gqbueoqnzzq9dw5hfo', |
| 136 | 109 | initial: '📋', |
| 137 | 110 | gradient: 'linear-gradient(135deg, #4ade80, #22d3ee)' |
| 138 | 111 | }, |
| 139 | | // Peercord — Discord-style P2P chat. Its current Pear release is |
| 140 | | // pear.json type:"desktop" (Electron/Pear window class), not a pear-request |
| 141 | | // terminal worker, so route it through the standalone launch path. Marking it |
| 142 | | // hypersite would surface "Run in tab" but hang on the headless wrapper. |
| 112 | // Peercord — Discord-style P2P chat. Its former desktop release is retained |
| 113 | // only as a migration record, not as a launchable browser app. |
| 143 | 114 | { |
| 144 | 115 | id: 'peercord', |
| 145 | 116 | name: 'Peercord', |
| 146 | | type: 'standalone', |
| 117 | nativeDelivery: { status: 'migration-required' }, |
| 147 | 118 | tagline: 'Decentralized Discord-style chat with text, voice, video, screen sharing, and P2P file transfer.', |
| 148 | | link: PEERCORD_LINK, |
| 119 | legacyMigrationId: 'wmir47w7mai3b1skj66mx7fzso6k6o91kipaney7gtt69npimouy', |
| 149 | 120 | initial: 'P', |
| 150 | 121 | gradient: 'linear-gradient(135deg, #5865f2, #22d3ee)' |
| 151 | 122 | } |
| … | … | @@ -1306,23 +1277,19 @@ function Browse ({ rpc, C, navUrl, onNavigated, tabs, setTabs, activeId, setActi |
| 1306 | 1277 | if (nameQuery) { |
| 1307 | 1278 | try { |
| 1308 | 1279 | const { resolved } = await rpc.request(C.CMD_NAME_RESOLVE, { name: nameQuery }) |
| 1280 | if (resolved?.legacyMigrationId) { |
| 1281 | updateTab(id, { status: `migration required for ${resolved.label || nameQuery} · ${resolved.provenance}…` }) |
| 1282 | try { |
| 1283 | const result = await rpc.request(C.CMD_LEGACY_APP_MIGRATION, { legacyMigrationId: resolved.legacyMigrationId }, 10000) |
| 1284 | updateTab(id, { status: result?.message || 'A verified native v3 package is required.' }) |
| 1285 | } catch (err) { |
| 1286 | updateTab(id, { status: `error: ${err.message}` }) |
| 1287 | } |
| 1288 | return |
| 1289 | } |
| 1309 | 1290 | if (resolved && (resolved.link || resolved.key)) { |
| 1310 | 1291 | const link = resolved.link || `hyper://${resolved.key}/` |
| 1311 | | // A pear:// / file:// target is a full Pear-runtime app (Keet, PearPass, |
| 1312 | | // …) — launch it in its OWN window exactly as the Apps tab does |
| 1313 | | // (CMD_LAUNCH_PEAR_LINK), not through the in-tab web navigate path that |
| 1314 | | // serves /hyper/<key> as a page. The current tab stays put. |
| 1315 | | if (/^(?:pear|file):\/\//i.test(link)) { |
| 1316 | | updateTab(id, { status: `opening ${resolved.label || nameQuery} · ${resolved.provenance}…` }) |
| 1317 | | try { |
| 1318 | | const result = await rpc.request(C.CMD_LAUNCH_PEAR_LINK, { link }, 60000) |
| 1319 | | updateTab(id, { status: result?.message || '' }) |
| 1320 | | } catch (err) { |
| 1321 | | updateTab(id, { status: `error: ${err.message}` }) |
| 1322 | | } |
| 1323 | | return |
| 1324 | | } |
| 1325 | | // A browsable hyper:// target — navigate it in-tab below, with a chip. |
| 1292 | // A browsable Hyper target navigates in-tab with an honest provenance chip. |
| 1326 | 1293 | target = link |
| 1327 | 1294 | prov = { provenance: resolved.provenance, label: resolved.label || nameQuery, name: nameQuery, source: resolved.source || null } |
| 1328 | 1295 | } |
| … | … | @@ -2145,8 +2112,8 @@ const ONBOARDING_FIRST_SITES = [ |
| 2145 | 2112 | { |
| 2146 | 2113 | id: 'hiveworm', |
| 2147 | 2114 | title: 'HiveWorm', |
| 2148 | | subtitle: 'Multiplayer worm life-sim, fully P2P', |
| 2149 | | url: 'pear://d1xbkcpcbi1xa8dexp49rsendra5r67w3qh5a9k8t44oemm4k16y', |
| 2115 | subtitle: 'Legacy native app — a verified v3 package is required', |
| 2116 | legacyMigrationId: 'd1xbkcpcbi1xa8dexp49rsendra5r67w3qh5a9k8t44oemm4k16y', |
| 2150 | 2117 | initial: '🐛', |
| 2151 | 2118 | gradient: 'linear-gradient(135deg, #a371f7, #d946ef)' |
| 2152 | 2119 | }, |
| … | … | @@ -2171,13 +2138,19 @@ const ONBOARDING_FIRST_SITES = [ |
| 2171 | 2138 | function Onboarding ({ rpc, C, onPickSite, onClose }) { |
| 2172 | 2139 | const [slide, setSlide] = useState(0) |
| 2173 | 2140 | |
| 2174 | | const finish = async (pickedUrl) => { |
| 2141 | const finish = async (pickedSite) => { |
| 2175 | 2142 | // Persist the flag so we never ask again — fire-and-forget so we |
| 2176 | 2143 | // don't block the close on a slow user-data write. |
| 2177 | 2144 | rpc.request(C.CMD_USERDATA_SET_SETTINGS, { |
| 2178 | 2145 | updates: { onboardingDone: true, onboardingDoneAt: Date.now() } |
| 2179 | 2146 | }).catch(() => {}) |
| 2180 | | if (pickedUrl) onPickSite(pickedUrl) |
| 2147 | if (pickedSite?.legacyMigrationId) { |
| 2148 | try { |
| 2149 | await rpc.request(C.CMD_LEGACY_APP_MIGRATION, { legacyMigrationId: pickedSite.legacyMigrationId }, 10000) |
| 2150 | } catch {} |
| 2151 | } else if (pickedSite?.url) { |
| 2152 | onPickSite(pickedSite.url) |
| 2153 | } |
| 2181 | 2154 | onClose() |
| 2182 | 2155 | } |
| 2183 | 2156 | |
| … | … | @@ -2212,8 +2185,8 @@ function Onboarding ({ rpc, C, onPickSite, onClose }) { |
| 2212 | 2185 | </div> |
| 2213 | 2186 | <div className="onb-pitch"> |
| 2214 | 2187 | <div className="onb-pitch-icon">📦</div> |
| 2215 | | <div className="onb-pitch-title">Run Pear apps</div> |
| 2216 | | <div className="onb-pitch-body">Click a pear:// link, the app opens in its own window.</div> |
| 2188 | <div className="onb-pitch-title">Use verified native apps</div> |
| 2189 | <div className="onb-pitch-body">Legacy entries explain the migration path; native code comes from a verified local package.</div> |
| 2217 | 2190 | </div> |
| 2218 | 2191 | <div className="onb-pitch"> |
| 2219 | 2192 | <div className="onb-pitch-icon">✒️</div> |
| … | … | @@ -2241,8 +2214,8 @@ function Onboarding ({ rpc, C, onPickSite, onClose }) { |
| 2241 | 2214 | <button |
| 2242 | 2215 | className="onb-site-card" |
| 2243 | 2216 | key=${s.id} |
| 2244 | | onClick=${() => finish(s.url)} |
| 2245 | | title=${s.url} |
| 2217 | onClick=${() => finish(s)} |
| 2218 | title=${s.url || 'Legacy native app'} |
| 2246 | 2219 | > |
| 2247 | 2220 | <div className="onb-site-icon" style=${{ background: s.gradient }}>${s.initial}</div> |
| 2248 | 2221 | <div className="onb-site-text"> |
| … | … | @@ -2395,10 +2368,12 @@ function CollaborativeCatalog ({ rpc, C }) { |
| 2395 | 2368 | const addApp = async () => { |
| 2396 | 2369 | const val = appKey.trim() |
| 2397 | 2370 | if (!val) return |
| 2398 | | const app = /^(?:pear|file):\/\//i.test(val) |
| 2399 | | ? { link: val, name: appName || val } |
| 2400 | | : { driveKey: driveKeyFromHyperRef(val), name: appName || val } |
| 2401 | | if (!app.link && !app.driveKey) { setErr('Enter a pear:// link, file:// link, or a valid hyper:// drive key.'); return } |
| 2371 | if (/^(?:pear|file):\/\//i.test(val)) { |
| 2372 | setErr('Remote executable app links are not accepted. Add browsable hyper:// content only.') |
| 2373 | return |
| 2374 | } |
| 2375 | const app = { driveKey: driveKeyFromHyperRef(val), name: appName || val } |
| 2376 | if (!app.driveKey) { setErr('Enter a valid hyper:// drive key.'); return } |
| 2402 | 2377 | setErr(''); setBusy('addapp') |
| 2403 | 2378 | try { |
| 2404 | 2379 | const res = await rpc.request(C.CMD_AUTOBEE_ADD_APP, { keyHex: cat.keyHex, app }, 60000) |
| … | … | @@ -2480,7 +2455,7 @@ function CollaborativeCatalog ({ rpc, C }) { |
| 2480 | 2455 | <div className="profile-field"> |
| 2481 | 2456 | <div className="settings-label">Add an app</div> |
| 2482 | 2457 | <input className="profile-input" placeholder="App name (optional)" value=${appName} onInput=${(e) => setAppName(e.target.value)} /> |
| 2483 | | <input className="profile-input" placeholder="hyper:// drive key or pear:// link" value=${appKey} onInput=${(e) => setAppKey(e.target.value)} onKeyDown=${(e) => e.key === 'Enter' && addApp()} /> |
| 2458 | <input className="profile-input" placeholder="hyper:// drive key" value=${appKey} onInput=${(e) => setAppKey(e.target.value)} onKeyDown=${(e) => e.key === 'Enter' && addApp()} /> |
| 2484 | 2459 | </div> |
| 2485 | 2460 | <button className="btn primary" onClick=${addApp} disabled=${busy === 'addapp' || !appKey.trim()}>${busy === 'addapp' ? 'Adding…' : 'Add app'}</button> |
| 2486 | 2461 | </div> |
| … | … | @@ -2525,7 +2500,8 @@ function CommunitySubmit ({ rpc, C }) { |
| 2525 | 2500 | const submit = async () => { |
| 2526 | 2501 | setErr(''); setOk('') |
| 2527 | 2502 | if (!name.trim()) { setErr('App name is required.'); return } |
| 2528 | | if (!link.trim()) { setErr('Paste a pear:// link, a hyper:// link, or a drive key.'); return } |
| 2503 | if (!link.trim()) { setErr('Paste a hyper:// link or a drive key.'); return } |
| 2504 | if (/^(?:pear|file):\/\//i.test(link.trim())) { setErr('Remote executable app links are not accepted. Submit browsable hyper:// content only.'); return } |
| 2529 | 2505 | setBusy(true) |
| 2530 | 2506 | try { |
| 2531 | 2507 | const res = await rpc.request(C.CMD_SUBMIT_APP, { |
| … | … | @@ -2540,7 +2516,7 @@ function CommunitySubmit ({ rpc, C }) { |
| 2540 | 2516 | return html` |
| 2541 | 2517 | <div className="community-submit"> |
| 2542 | 2518 | <h2>Submit your app <span className="settings-subtle">→ Community list</span></h2> |
| 2543 | | <p className="subtitle">Add any Pear app or hyperdrive site to the community catalogue. Submissions are pinned on relays and reviewed by a moderator before appearing in everyone's Community list.</p> |
| 2519 | <p className="subtitle">Add browsable Hyperdrive content to the community catalogue. HiveRelay replicates content; it never attests or delivers native executable code.</p> |
| 2544 | 2520 | <div className="settings-card"> |
| 2545 | 2521 | ${err && html`<div className="apps-error">${err}</div>`} |
| 2546 | 2522 | ${ok && html`<div className="apps-ok">${ok}</div>`} |
| … | … | @@ -2553,7 +2529,7 @@ function CommunitySubmit ({ rpc, C }) { |
| 2553 | 2529 | <div className="settings-row"> |
| 2554 | 2530 | <div className="profile-field"> |
| 2555 | 2531 | <div className="settings-label">Link *</div> |
| 2556 | | <input className="profile-input" placeholder="pear://… or hyper://… (or a 64-hex / z-base-32 key)" value=${link} onInput=${(e) => setLink(e.target.value)} onKeyDown=${(e) => e.key === 'Enter' && submit()} /> |
| 2532 | <input className="profile-input" placeholder="hyper://… (or a 64-hex / z-base-32 key)" value=${link} onInput=${(e) => setLink(e.target.value)} onKeyDown=${(e) => e.key === 'Enter' && submit()} /> |
| 2557 | 2533 | </div> |
| 2558 | 2534 | </div> |
| 2559 | 2535 | <div className="settings-row"> |
| … | … | @@ -2717,7 +2693,9 @@ function appStableDedupeKey (app) { |
| 2717 | 2693 | const link = normalizeAppLinkForKey(app.link) |
| 2718 | 2694 | const hyperKey = /^hyper:\/\//i.test(link) ? driveKeyFromHyperRef(link) : '' |
| 2719 | 2695 | if (driveKey || hyperKey) return 'drive:' + (driveKey || hyperKey) |
| 2720 | | if (/^(?:hyper|pear|file):\/\/.+/i.test(link)) return 'link:' + link |
| 2696 | if (/^hyper:\/\/.+/i.test(link)) return 'link:' + link |
| 2697 | const legacyMigrationId = String(app.legacyMigrationId || '').trim().toLowerCase() |
| 2698 | if (/^[13-9a-km-uw-z]{52}$/.test(legacyMigrationId)) return 'legacy:' + legacyMigrationId |
| 2721 | 2699 | const id = String(app.id || '').trim() |
| 2722 | 2700 | return id ? 'id:' + id : '' |
| 2723 | 2701 | } |
| … | … | @@ -2750,20 +2728,14 @@ function dedupeApps (list) { |
| 2750 | 2728 | return [...byKey.values(), ...anon] |
| 2751 | 2729 | } |
| 2752 | 2730 | |
| 2753 | | // Decode an app's bundle drive key from its pear:// link (z-base-32 host, |
| 2754 | | // tolerating a versioned `N.M.<z32>` form), falling back to its driveKey. |
| 2755 | | // Used for live size/peers and to correlate launch-progress events. |
| 2731 | // Drive metadata belongs to browsable Hyper content only. A legacy native-app |
| 2732 | // migration identifier is intentionally not a drive key and has no fetch path. |
| 2756 | 2733 | function appBundleKey (app) { |
| 2757 | | if (app && typeof app.link === 'string' && /^pear:\/\//.test(app.link)) { |
| 2758 | | const host = app.link.replace(/^pear:\/\//, '').split('/')[0].split('.').pop() |
| 2759 | | try { const k = hexFromZ32(host); if (/^[0-9a-f]{64}$/i.test(k)) return k.toLowerCase() } catch {} |
| 2760 | | } |
| 2761 | 2734 | if (app && /^[0-9a-f]{64}$/i.test(app.driveKey || '')) return app.driveKey.toLowerCase() |
| 2762 | 2735 | return null |
| 2763 | 2736 | } |
| 2764 | 2737 | |
| 2765 | | // A card footer: the app's pear:// (or hyper://) address (click to copy), its |
| 2766 | | // size, and a live peer count — all from CMD_GET_DRIVE_INFO on the bundle key. |
| 2738 | // A card footer for a browsable Hyper address, its size, and live peer count. |
| 2767 | 2739 | function AppMeta ({ rpc, C, app }) { |
| 2768 | 2740 | const bundleKey = appBundleKey(app) |
| 2769 | 2741 | const addr = (app && app.link) ? app.link : (app && /^[0-9a-f]{64}$/i.test(app.driveKey || '') ? ('hyper://' + app.driveKey + '/') : null) |
| … | … | @@ -2796,65 +2768,6 @@ function AppMeta ({ rpc, C, app }) { |
| 2796 | 2768 | |
| 2797 | 2769 | // Inline download-progress bar shown in place of a card's Run-app button while |
| 2798 | 2770 | // the bundle is being pulled (driven by EVT_LAUNCH_PROGRESS). |
| 2799 | | function LaunchBar ({ prog, onRetry }) { |
| 2800 | | if (!prog) return null |
| 2801 | | if (prog.phase === 'error') { |
| 2802 | | return html`<div style=${{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: '11px', color: '#f85149', width: '100%' }}> |
| 2803 | | <span>Launch failed${prog.error ? ': ' + prog.error : ''}</span> |
| 2804 | | <button className="btn subtle" onClick=${onRetry}>Retry</button> |
| 2805 | | </div>` |
| 2806 | | } |
| 2807 | | const pct = Math.max(0, Math.min(100, prog.phase === 'launching' ? 100 : (prog.percent || 0))) |
| 2808 | | const label = prog.phase === 'connecting' ? ('Finding peers… ' + (prog.peers || 0)) |
| 2809 | | : prog.phase === 'launching' ? 'Launching…' |
| 2810 | | : (formatBytes(prog.downloaded || 0) + ' / ' + formatBytes(prog.total || 0) + ' · ' + (prog.peers || 0) + ' peers · ' + pct + '%') |
| 2811 | | return html` |
| 2812 | | <div style=${{ width: '100%' }}> |
| 2813 | | <div style=${{ height: '6px', borderRadius: '3px', background: '#21262d', overflow: 'hidden' }}> |
| 2814 | | <div style=${{ height: '100%', width: pct + '%', background: 'linear-gradient(90deg,#58a6ff,#3fb950)', transition: 'width .25s ease' }}></div> |
| 2815 | | </div> |
| 2816 | | <div style=${{ marginTop: '4px', fontSize: '11px', color: '#8b949e' }}>${label}</div> |
| 2817 | | </div> |
| 2818 | | ` |
| 2819 | | } |
| 2820 | | |
| 2821 | | function standalonePrelaunchWarningFor (app) { |
| 2822 | | if (!app || app.type === 'hypersite') return null |
| 2823 | | const link = normalizeAppLinkForKey(app.link) |
| 2824 | | if (!/^pear:\/\//i.test(link)) return null |
| 2825 | | const id = String(app.id || '').trim().toLowerCase() |
| 2826 | | const warning = STANDALONE_PRELAUNCH_WARNINGS.peercord |
| 2827 | | if (id === warning.key || link === warning.link) return warning |
| 2828 | | return null |
| 2829 | | } |
| 2830 | | |
| 2831 | | function normalizeStandaloneWarningsSeen (value) { |
| 2832 | | return value && typeof value === 'object' && !Array.isArray(value) ? value : {} |
| 2833 | | } |
| 2834 | | |
| 2835 | | function StandalonePrelaunchWarning ({ pending, onCancel, onConfirm, busy }) { |
| 2836 | | const warning = pending?.warning |
| 2837 | | const appName = warning?.appName || pending?.app?.name || 'This app' |
| 2838 | | if (!warning) return null |
| 2839 | | return html` |
| 2840 | | <div className="modal-overlay standalone-prelaunch-overlay" role="dialog" aria-modal="true" |
| 2841 | | onClick=${(e) => e.target.classList.contains('modal-overlay') && onCancel()}> |
| 2842 | | <div className="modal-card standalone-prelaunch-card"> |
| 2843 | | <div className="standalone-prelaunch-kicker">Standalone Pear app</div> |
| 2844 | | <h2>${warning.title || `Before opening ${appName}`}</h2> |
| 2845 | | <p>${warning.body}</p> |
| 2846 | | <p className="standalone-prelaunch-trust">${warning.trust}</p> |
| 2847 | | ${pending.link && html`<code className="standalone-prelaunch-link">${pending.link}</code>`} |
| 2848 | | <div className="standalone-prelaunch-actions"> |
| 2849 | | <button className="btn subtle" onClick=${onCancel} disabled=${busy === 'pear-link'}>Cancel</button> |
| 2850 | | <button className="btn primary" onClick=${onConfirm} disabled=${busy === 'pear-link'}> |
| 2851 | | ${busy === 'pear-link' ? 'Opening...' : `Open ${appName}`} |
| 2852 | | </button> |
| 2853 | | </div> |
| 2854 | | </div> |
| 2855 | | </div> |
| 2856 | | ` |
| 2857 | | } |
| 2858 | 2771 | |
| 2859 | 2772 | function Apps ({ rpc, C, onLaunch }) { |
| 2860 | 2773 | const [catalogKey, setCatalogKey] = useState('') |
| … | … | @@ -2887,125 +2800,34 @@ function Apps ({ rpc, C, onLaunch }) { |
| 2887 | 2800 | const [busy, setBusy] = useState(null) |
| 2888 | 2801 | const [err, setErr] = useState('') |
| 2889 | 2802 | const [autoLoadAttempted, setAutoLoadAttempted] = useState(false) |
| 2890 | | const [pearLink, setPearLink] = useState('') |
| 2891 | 2803 | const [launched, setLaunched] = useState('') |
| 2892 | | // Live download/launch progress per bundle key, fed by EVT_LAUNCH_PROGRESS. |
| 2893 | | const [launchProg, setLaunchProg] = useState({}) |
| 2894 | | const [standaloneWarningsSeen, setStandaloneWarningsSeen] = useState(null) |
| 2895 | | const [pendingStandaloneLaunch, setPendingStandaloneLaunch] = useState(null) |
| 2896 | | useEffect(() => { |
| 2897 | | if (!(rpc && C && C.EVT_LAUNCH_PROGRESS)) return |
| 2898 | | const onProg = (e) => { |
| 2899 | | const d = (e && e.detail) || {} |
| 2900 | | const k = d.key || d.link |
| 2901 | | if (!k) return |
| 2902 | | setLaunchProg((prev) => { |
| 2903 | | const next = { ...prev, [k]: d } |
| 2904 | | if (d.phase === 'done') setTimeout(() => setLaunchProg((p) => { const n = { ...p }; delete n[k]; return n }), 1200) |
| 2905 | | return next |
| 2906 | | }) |
| 2804 | const showLegacyMigration = async (app) => { |
| 2805 | const legacyMigrationId = String(app?.legacyMigrationId || '').trim().toLowerCase() |
| 2806 | if (!legacyMigrationId) { |
| 2807 | setErr(`${app?.name || 'This app'} has no verified native v3 package yet.`) |
| 2808 | return |
| 2907 | 2809 | } |
| 2908 | | rpc.addEventListener(`event:${C.EVT_LAUNCH_PROGRESS}`, onProg) |
| 2909 | | return () => rpc.removeEventListener(`event:${C.EVT_LAUNCH_PROGRESS}`, onProg) |
| 2910 | | }, [rpc, C]) |
| 2911 | | |
| 2912 | | const launchPearLink = async (overrideLink) => { |
| 2913 | | const link = (typeof overrideLink === 'string' ? overrideLink : pearLink).trim() |
| 2914 | | if (!link) return |
| 2915 | | const keyHex = appBundleKey({ link }) |
| 2916 | | setErr(''); setBusy('pear-link'); setLaunched('') |
| 2917 | | if (keyHex) setLaunchProg((p) => ({ ...p, [keyHex]: { phase: 'connecting', percent: 0, peers: 0, downloaded: 0, total: 0 } })) |
| 2810 | setErr(''); setBusy('legacy-migration'); setLaunched('') |
| 2918 | 2811 | try { |
| 2919 | | const result = await rpc.request(C.CMD_LAUNCH_PEAR_LINK, { link, keyHex }, 60000) |
| 2920 | | if (result?.action === 'legacy-migration-required') { |
| 2921 | | setErr(result.message) |
| 2922 | | return |
| 2923 | | } |
| 2924 | | setPearLink('') |
| 2925 | | // pear:// launches show progress inline via EVT_LAUNCH_PROGRESS; file:// |
| 2926 | | // and others have no bundle to track, so keep the toast for them. |
| 2927 | | if (!keyHex) { setLaunched(`Launched ${link.slice(0, 60)}${link.length > 60 ? '…' : ''} in a new window.`); setTimeout(() => setLaunched(''), 4000) } |
| 2812 | const result = await rpc.request(C.CMD_LEGACY_APP_MIGRATION, { legacyMigrationId }, 10000) |
| 2813 | setErr(result?.message || 'A verified native v3 package is required.') |
| 2928 | 2814 | } catch (e) { |
| 2929 | | setErr(`launch: ${e.message}`) |
| 2930 | | if (keyHex) setLaunchProg((p) => ({ ...p, [keyHex]: { phase: 'error', error: e.message } })) |
| 2815 | setErr(`migration: ${e.message}`) |
| 2931 | 2816 | } finally { |
| 2932 | 2817 | setBusy(null) |
| 2933 | 2818 | } |
| 2934 | 2819 | } |
| 2935 | 2820 | |
| 2936 | | const readStandaloneWarningsSeen = async () => { |
| 2937 | | if (standaloneWarningsSeen !== null) return standaloneWarningsSeen |
| 2938 | | try { |
| 2939 | | const settings = unwrapSettings(await rpc.request(C.CMD_USERDATA_GET_SETTINGS)) |
| 2940 | | const seen = normalizeStandaloneWarningsSeen(settings?.standaloneLaunchWarningsSeen) |
| 2941 | | setStandaloneWarningsSeen(seen) |
| 2942 | | return seen |
| 2943 | | } catch { |
| 2944 | | setStandaloneWarningsSeen({}) |
| 2945 | | return {} |
| 2946 | | } |
| 2947 | | } |
| 2948 | | |
| 2949 | | const requestPearLinkLaunch = async (overrideLink, app = null) => { |
| 2950 | | const link = (typeof overrideLink === 'string' ? overrideLink : pearLink).trim() |
| 2951 | | if (!link) return |
| 2952 | | const launchApp = { ...(app || {}), link, name: app?.name || '' } |
| 2953 | | const warning = standalonePrelaunchWarningFor(launchApp) |
| 2954 | | if (warning) { |
| 2955 | | const seen = await readStandaloneWarningsSeen() |
| 2956 | | if (!seen[warning.key]) { |
| 2957 | | setErr('') |
| 2958 | | setLaunched('') |
| 2959 | | setPendingStandaloneLaunch({ |
| 2960 | | app: { ...launchApp, id: launchApp.id || warning.key, name: launchApp.name || warning.appName }, |
| 2961 | | link, |
| 2962 | | warning |
| 2963 | | }) |
| 2964 | | return |
| 2965 | | } |
| 2966 | | } |
| 2967 | | await launchPearLink(link) |
| 2968 | | } |
| 2969 | | |
| 2970 | | const confirmStandaloneLaunch = async () => { |
| 2971 | | const pending = pendingStandaloneLaunch |
| 2972 | | if (!pending) return |
| 2973 | | const warningKey = pending.warning?.key |
| 2974 | | if (warningKey) { |
| 2975 | | const next = { ...normalizeStandaloneWarningsSeen(standaloneWarningsSeen), [warningKey]: true } |
| 2976 | | setStandaloneWarningsSeen(next) |
| 2977 | | rpc.request(C.CMD_USERDATA_SET_SETTINGS, { |
| 2978 | | updates: { standaloneLaunchWarningsSeen: next } |
| 2979 | | }).catch(() => {}) |
| 2980 | | } |
| 2981 | | setPendingStandaloneLaunch(null) |
| 2982 | | await launchPearLink(pending.link) |
| 2983 | | } |
| 2984 | | |
| 2985 | | // Featured apps in this list are a mix of `pear://` apps (spawn |
| 2986 | | // their own runtime via CMD_LAUNCH_PEAR_LINK — Keet, PearPass, |
| 2987 | | // HiveWorm) and `hyper://` sites that are hosted INSIDE PearBrowser |
| 2988 | | // with a gated runtime API injection (anonGPT today; the spec calls |
| 2989 | | // this an "injected version" — the app's bytes come from its own |
| 2990 | | // Hyperdrive, and PearBrowser injects window.pear.<app>.* into the |
| 2991 | | // page when a manifest gate passes. The app is NOT bundled into the |
| 2992 | | // PearBrowser runtime; it's still its own Hyperdrive — we just host |
| 2993 | | // it). |
| 2994 | | // |
| 2995 | | // Either way the user pressed "Launch" expecting a new app to open, |
| 2996 | | // so the action is symmetric: |
| 2997 | | // pear:// / file:// → CMD_LAUNCH_PEAR_LINK spawns a new window |
| 2998 | | // hyper:// / http:// → open in a Browse tab, with the proxy's |
| 2999 | | // per-drive shim injection applied |
| 3000 | | // Both surface a "Launched <name>" toast so the user sees the same |
| 3001 | | // feedback regardless of which underlying mechanism ran. |
| 2821 | // A featured item either opens browsable content or explains that its former |
| 2822 | // native release needs a verified package. It never treats a catalog value as |
| 2823 | // executable code. |
| 3002 | 2824 | const launchFeaturedApp = (app) => { |
| 3003 | | const link = (app.link || '').trim() |
| 3004 | | if (!link) return |
| 3005 | | if (link.startsWith('pear://') || link.startsWith('file://')) { |
| 3006 | | requestPearLinkLaunch(link, app) |
| 2825 | if (app?.nativeDelivery?.status === 'migration-required') { |
| 2826 | showLegacyMigration(app) |
| 3007 | 2827 | return |
| 3008 | 2828 | } |
| 2829 | const link = (app.link || '').trim() |
| 2830 | if (!link) return |
| 3009 | 2831 | if (link.startsWith('hyper://') || link.startsWith('http://') || link.startsWith('https://')) { |
| 3010 | 2832 | // onLaunch is the App-level helper that does setNavUrl(url) + |
| 3011 | 2833 | // setTab('browse'). Browse opens this in a new browser-tab if |
| … | … | @@ -3325,7 +3147,6 @@ function Apps ({ rpc, C, onLaunch }) { |
| 3325 | 3147 | ;(async () => { |
| 3326 | 3148 | try { |
| 3327 | 3149 | const settings = unwrapSettings(await rpc.request(C.CMD_USERDATA_GET_SETTINGS)) |
| 3328 | | setStandaloneWarningsSeen(normalizeStandaloneWarningsSeen(settings?.standaloneLaunchWarningsSeen)) |
| 3329 | 3150 | const recentRaw = Array.isArray(settings?.recentCatalogs) ? settings.recentCatalogs : [] |
| 3330 | 3151 | // Back-compat: older builds persisted only a single lastCatalogKey. |
| 3331 | 3152 | const last = settings?.lastCatalogKey |
| … | … | @@ -3394,7 +3215,6 @@ function Apps ({ rpc, C, onLaunch }) { |
| 3394 | 3215 | } catch { |
| 3395 | 3216 | // user-data not ready yet — first-launch / boot races. The user |
| 3396 | 3217 | // can still paste a key by hand below. |
| 3397 | | setStandaloneWarningsSeen({}) |
| 3398 | 3218 | } finally { |
| 3399 | 3219 | setAutoLoadAttempted(true) |
| 3400 | 3220 | } |
| … | … | @@ -3451,10 +3271,8 @@ function Apps ({ rpc, C, onLaunch }) { |
| 3451 | 3271 | const filteredApps = useMemo(() => { |
| 3452 | 3272 | const q = query.normalize('NFKC').trim().toLowerCase() |
| 3453 | 3273 | const matched = apps.filter((a) => { |
| 3454 | | // Apps page = runnable apps only. A `link` (launchable pear://|file:// app, |
| 3455 | | // or a pear-request hypersite) means runnable; pure static sites (driveKey, |
| 3456 | | // no link) live on the P2P Sites tab, not here. |
| 3457 | | if (!a || !a.link) return false |
| 3274 | // Apps page presents browsable content and legacy migration records. |
| 3275 | if (!a || (!a.link && !a.legacyMigrationId)) return false |
| 3458 | 3276 | if (source !== 'all' && a.catalogKey !== source) return false |
| 3459 | 3277 | if (category !== 'all' && !appCategories(a).includes(category)) return false |
| 3460 | 3278 | if (!q) return true |
| … | … | @@ -3465,7 +3283,7 @@ function Apps ({ rpc, C, onLaunch }) { |
| 3465 | 3283 | }, [apps, query, category, source]) |
| 3466 | 3284 | |
| 3467 | 3285 | // Total unique-app count (deduped, ignoring search/category) for the headers. |
| 3468 | | const uniqueAppCount = useMemo(() => dedupeApps(apps.filter((a) => a && a.link)).length, [apps]) |
| 3286 | const uniqueAppCount = useMemo(() => dedupeApps(apps.filter((a) => a && (a.link || a.legacyMigrationId))).length, [apps]) |
| 3469 | 3287 | |
| 3470 | 3288 | const renderMyCatalogApp = (app) => { |
| 3471 | 3289 | const savedId = app.id || app.driveKey || app.name || 'untitled' |
| … | … | @@ -3487,8 +3305,7 @@ function Apps ({ rpc, C, onLaunch }) { |
| 3487 | 3305 | <label> |
| 3488 | 3306 | Type <span style=${{ opacity: 0.6, fontWeight: 'normal' }}>(how it launches — required)</span> |
| 3489 | 3307 | <select value=${appDraft.type || 'standalone'} onChange=${(e) => updateAppDraft('type', e.target.value)} style=${{ width: '100%', padding: '8px', borderRadius: '6px', background: '#0d1117', color: '#c9d1d9', border: '1px solid #30363d' }}> |
| 3490 | | <option value="standalone">standalone — opens in its own window (pear:// app)</option> |
| 3491 | | <option value="hypersite">hypersite — runs inline in a tab (pear-request / static)</option> |
| 3308 | <option value="hypersite">hypersite — browsable P2P content</option> |
| 3492 | 3309 | </select> |
| 3493 | 3310 | </label> |
| 3494 | 3311 | <label> |
| … | … | @@ -3551,7 +3368,7 @@ function Apps ({ rpc, C, onLaunch }) { |
| 3551 | 3368 | return html` |
| 3552 | 3369 | <div className="apps"> |
| 3553 | 3370 | <h1>Apps</h1> |
| 3554 | | <p className="subtitle">Launch any Pear app by link, or browse a HiveRelay catalog.</p> |
| 3371 | <p className="subtitle">Browse P2P content or find verified native v3 package guidance in a HiveRelay catalog.</p> |
| 3555 | 3372 | |
| 3556 | 3373 | <h2>Featured</h2> |
| 3557 | 3374 | <div className="app-grid"> |
| … | … | @@ -3561,30 +3378,20 @@ function Apps ({ rpc, C, onLaunch }) { |
| 3561 | 3378 | <div className="app-info"> |
| 3562 | 3379 | <div className="app-name">${app.name}</div> |
| 3563 | 3380 | <div className="app-desc">${app.tagline}</div> |
| 3564 | | <div className="app-meta" title=${app.link}>${app.link.slice(0, 20)}…${app.link.slice(-6)}</div> |
| 3381 | <div className="app-meta" title=${app.legacyMigrationId}>Legacy native release · migration required</div> |
| 3565 | 3382 | </div> |
| 3566 | 3383 | <div className="app-actions"> |
| 3567 | 3384 | ${app.type === 'hypersite' |
| 3568 | 3385 | ? html`<button key="run-featured" className="btn primary" onClick=${() => runInTab(app)} disabled=${busy === 'run-in-tab'} title="Run headless — the app's UI streams into a tab over a pipe">Run in tab</button>` |
| 3569 | | : html`<button key="open-featured" className="btn primary" onClick=${() => launchFeaturedApp(app)} disabled=${busy === 'pear-link'} title="Full Pear app — opens in its own window">Open</button>`} |
| 3386 | : html`<button key="open-featured" className="btn primary" onClick=${() => launchFeaturedApp(app)} disabled=${busy === 'legacy-migration'} title="Requires a verified native v3 package">Migration status</button>`} |
| 3570 | 3387 | </div> |
| 3571 | 3388 | </div> |
| 3572 | 3389 | `)} |
| 3573 | 3390 | </div> |
| 3574 | 3391 | |
| 3575 | | <h2>Launch a Pear app</h2> |
| 3392 | <h2>Legacy native apps</h2> |
| 3576 | 3393 | <div className="catalog-loader"> |
| 3577 | | <input |
| 3578 | | type="text" |
| 3579 | | placeholder=${'pear://<key> — opens in a new window'} |
| 3580 | | value=${pearLink} |
| 3581 | | onInput=${(e) => setPearLink(e.target.value)} |
| 3582 | | onKeyDown=${(e) => e.key === 'Enter' && requestPearLinkLaunch()} |
| 3583 | | spellCheck="false" |
| 3584 | | /> |
| 3585 | | <button className="btn primary" onClick=${() => requestPearLinkLaunch()} disabled=${!pearLink || busy === 'pear-link'}> |
| 3586 | | ${busy === 'pear-link' ? 'Launching…' : 'Launch'} |
| 3587 | | </button> |
| 3394 | <p className="placeholder">Older remote app links cannot run in PearBrowser. Install only a publisher-provided, verified native v3 package.</p> |
| 3588 | 3395 | </div> |
| 3589 | 3396 | ${launched && html`<div className="apps-ok">${launched}</div>`} |
| 3590 | 3397 | |
| … | … | @@ -3680,25 +3487,22 @@ function Apps ({ rpc, C, onLaunch }) { |
| 3680 | 3487 | <div className="app-desc">${app.description || ''}</div> |
| 3681 | 3488 | <div className="app-meta"> |
| 3682 | 3489 | ${app.version ? 'v' + app.version : ''} ${app.author ? '· ' + app.author : ''} |
| 3683 | | ${app.type === 'hypersite' ? html`<span style=${{ marginLeft: '6px', opacity: 0.75 }}>· ${app.driveKey && !app.link ? 'opens in a tab' : 'runs in a tab'}</span>` : (app.link && !app.driveKey ? html`<span style=${{ marginLeft: '6px', opacity: 0.75 }}>· opens in a window</span>` : '')} |
| 3490 | ${app.nativeDelivery?.status === 'migration-required' |
| 3491 | ? html`<span style=${{ marginLeft: '6px', opacity: 0.75 }}>· verified native package required</span>` |
| 3492 | : (app.type === 'hypersite' ? html`<span style=${{ marginLeft: '6px', opacity: 0.75 }}>· opens in a tab</span>` : '')} |
| 3684 | 3493 | </div> |
| 3685 | 3494 | ${app.catalogName && html`<div className="app-source-tag">${app.catalogName}</div>`} |
| 3686 | 3495 | <${AppMeta} rpc=${rpc} C=${C} app=${app} /> |
| 3687 | 3496 | </div> |
| 3688 | 3497 | <div className="app-actions"> |
| 3689 | 3498 | ${(() => { |
| 3690 | | const bk = appBundleKey(app) |
| 3691 | | const prog = bk && launchProg[bk] |
| 3692 | | if (prog && prog.phase !== 'done') { |
| 3693 | | return html`<${LaunchBar} prog=${prog} onRetry=${() => (app.type === 'hypersite' ? runInTab(app) : launchFeaturedApp(app))} />` |
| 3694 | | } |
| 3695 | 3499 | return html` |
| 3696 | 3500 | ${app.driveKey && /^[0-9a-f]{64}$/i.test(app.driveKey) |
| 3697 | 3501 | ? html`<button key="open-page" className="btn subtle" onClick=${() => openSite(app)} title="Open this app's P2P page in a tab">Open page</button>` |
| 3698 | 3502 | : ''} |
| 3699 | | ${app.type === 'hypersite' |
| 3700 | | ? html`<button key="run-in-tab" className="btn primary" onClick=${() => runInTab(app)} disabled=${busy === 'run-in-tab'} title="Run headless — the app's UI streams into a tab">Run app</button>` |
| 3701 | | : html`<button key="run-window" className="btn primary" onClick=${() => launchFeaturedApp(app)} disabled=${busy === 'pear-link'} title="Open the app in its own window">Run app</button>`} |
| 3503 | ${app.nativeDelivery?.status === 'migration-required' |
| 3504 | ? html`<button key="migration" className="btn primary" onClick=${() => showLegacyMigration(app)} disabled=${busy === 'legacy-migration'} title="Requires a verified native v3 package">Migration status</button>` |
| 3505 | : html`<button key="open-content" className="btn primary" onClick=${() => openSite(app)} title="Open browsable P2P content">Open</button>`} |
| 3702 | 3506 | ${canEditMyCatalog && app.catalogKey !== myCatalog.keyHex && !inMyCatalog([app.id, app.driveKey, app.link]) && html` |
| 3703 | 3507 | <button key="add-catalog" className="btn subtle" title="Add to my catalog" onClick=${() => addToMyCatalog(app)} disabled=${busy === `addcat:${app.id || app.driveKey || app.link}`}>+ Catalog</button> |
| 3704 | 3508 | `} |
| … | … | @@ -3810,13 +3614,6 @@ function Apps ({ rpc, C, onLaunch }) { |
| 3810 | 3614 | |
| 3811 | 3615 | <${ModeratorPanel} rpc=${rpc} C=${C} /> |
| 3812 | 3616 | |
| 3813 | | ${pendingStandaloneLaunch && html`<${StandalonePrelaunchWarning} |
| 3814 | | pending=${pendingStandaloneLaunch} |
| 3815 | | onCancel=${() => setPendingStandaloneLaunch(null)} |
| 3816 | | onConfirm=${confirmStandaloneLaunch} |
| 3817 | | busy=${busy} |
| 3818 | | />`} |
| 3819 | | |
| 3820 | 3617 | ${detailApp && html` |
| 3821 | 3618 | <div onClick=${() => setDetailApp(null)} style=${{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.6)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000, padding: '24px' }}> |
| 3822 | 3619 | <div onClick=${(e) => e.stopPropagation()} style=${{ background: '#11161f', border: '1px solid rgba(255,255,255,0.12)', borderRadius: '14px', padding: '20px 24px 24px', maxWidth: '480px', width: '100%', maxHeight: '82vh', overflowY: 'auto' }}> |