OverviewFilesCommitsIssuesDocsReleasesPeers

pearbrowser-desktop commits / 721d8e2

enforce non-executable browser catalog migration
721d8e22980f0f2a499600aa4b21622300cfb887
by bigdestiny2 <33146784+bigdestiny2@users.noreply.github.com> · 2026-07-22T23:31:47.000Z

Changed files (11) +154 -429

backend/catalog-manager.js +4 -3
……@@ -400,7 +400,8 @@ class CatalogManager {
400400 if (!manager.writable) throw new Error('You are not a writer on this catalog.')
401401 const driveKey = app && typeof app.driveKey === 'string' ? app.driveKey.trim() : ''
402402 const link = app && typeof app.link === 'string' ? app.link.trim() : ''
403 if (!driveKey && !link) throw new Error('App needs a driveKey or link.')
403 if (link && !/^hyper:\/\//i.test(link)) throw new Error('Catalog links must be browsable hyper:// content, never executable remote app links.')
404 if (!driveKey && !link) throw new Error('App needs a Hyperdrive key or hyper:// link.')
404405 await manager.upsertApp(app) // ops layer whitelists/clamps + derives id
405406 const { data } = await this._refreshAutobee(keyHex)
406407 return this._formatAutobee(keyHex, manager, data)
……@@ -624,7 +625,7 @@ class CatalogManager {
624625 const drive = await this._ensureMyCatalogDrive(keyHex)
625626 if (!drive.writable) throw new Error('This catalog is not editable on this device.')
626627 const entry = this._sanitizeCatalogEntry(app)
627 if (!entry.driveKey && !entry.link) throw new Error('App is missing a valid drive key or app link.')
628 if (!entry.driveKey && !entry.link) throw new Error('App is missing a valid Hyperdrive key or hyper:// link.')
628629 entry.id = entry.id || entry.driveKey || entry.link
629630
630631 const data = await this._readMyCatalogData(drive)
……@@ -663,7 +664,7 @@ class CatalogManager {
663664 const existing = data.apps[idx]
664665 const stableId = existing.id || appId
665666 const entry = this._sanitizeCatalogEntry({ ...existing, ...(patch || {}), id: stableId })
666 if (!entry.driveKey && !entry.link) throw new Error('App is missing a valid drive key or app link.')
667 if (!entry.driveKey && !entry.link) throw new Error('App is missing a valid Hyperdrive key or hyper:// link.')
667668 entry.id = stableId
668669 data.apps[idx] = entry
669670
backend/catalog-safety.cjs +3 -1
……@@ -26,7 +26,9 @@ function catalogAppsFromEnvelope (catalog) {
2626
2727const HEX64_RE = /^[0-9a-f]{64}$/i
2828const Z32_RE = /^[13-9a-km-uw-z]{52}$/i
29const APP_LINK_RE = /^(?:hyper|pear|file):\/\/.+/i
29// Catalog links are browsable content only. Native delivery must be represented
30// separately as a verified package or a non-executable migration record.
31const APP_LINK_RE = /^hyper:\/\/.+/i
3032const VERIFICATION_RANK = { 'author-signed': 3, 'relay-listed': 2, unverified: 1 }
3133const NATIVE_DELIVERY_STATUSES = new Set(['migration-required', 'available'])
3234
backend/community-submit.cjs +8 -14
……@@ -28,25 +28,22 @@ function slugify (s) {
2828}
2929
3030/**
31 * From a single user-supplied input (a pear:// link, a hyper:// link, or a bare
32 * key) derive both the 64-hex content drive key (what gets seeded/pinned) and a
33 * launchable link. `normalizeKey` is injected (index.js passes its
31 * From a single user-supplied Hyperdrive input (a hyper:// link or a bare key)
32 * derive the 64-hex content drive key (what gets seeded/pinned) and a
33 * browsable link. `normalizeKey` is injected (index.js passes its
3434 * normalizeDriveKey, which z32-decodes 52-char keys) so this stays pure.
3535 *
3636 * Returns { driveKey, link, kind } or { error }.
3737 */
3838function deriveKeyAndLink (rawInput, normalizeKey = (x) => x) {
3939 const raw = String(rawInput == null ? '' : rawInput).trim()
40 if (!raw) return { error: 'A pear:// link or hyper:// / drive key is required.' }
40 if (!raw) return { error: 'A hyper:// link or drive key is required.' }
4141
4242 let driveKey = ''
4343 let link = ''
4444 let kind = ''
45 if (/^pear:\/\//i.test(raw)) {
46 link = raw.replace(/\/+$/, '')
47 const token = link.replace(/^pear:\/\//i, '').split(/[/?#]/)[0]
48 driveKey = String(normalizeKey(token) || '').toLowerCase()
49 kind = 'pear'
45 if (/^pear:\/\//i.test(raw) || /^file:\/\//i.test(raw)) {
46 return { error: 'Remote executable app links are not accepted. Submit browsable hyper:// content; native apps use verified package delivery.' }
5047 } else {
5148 const token = raw.replace(/^hyper:\/\//i, '').split(/[/?#]/)[0]
5249 driveKey = String(normalizeKey(token) || '').toLowerCase()
……@@ -55,7 +52,7 @@ function deriveKeyAndLink (rawInput, normalizeKey = (x) => x) {
5552 }
5653
5754 if (!HEX64.test(driveKey)) {
58 return { error: 'Could not derive a 64-character drive key from "' + raw + '". Paste a pear:// link, a hyper:// link, or a 64-hex / z-base-32 key.' }
55 return { error: 'Could not derive a 64-character drive key from "' + raw + '". Paste a hyper:// link or a 64-hex / z-base-32 key.' }
5956 }
6057 return { driveKey, link, kind }
6158}
……@@ -97,14 +94,11 @@ function buildSubmissionManifest (input = {}, ctx = {}) {
9794 version: String(input.version || '1.0.0').slice(0, 40),
9895 author: String(input.author || '').slice(0, 160),
9996 categories,
100 type: derived.kind === 'pear' ? 'standalone' : 'hypersite',
97 type: 'hypersite',
10198 list: 'community',
10299 submittedBy: ctx.submittedBy || null,
103100 submittedAt: now
104101 }
105 // A pear:// app keeps its launchable pearLink alongside the generic link so
106 // the browser's launcher can route it without re-deriving.
107 if (derived.kind === 'pear') manifest.pearLink = derived.link
108102 // Inline icon (data: URI) is optional; cap it so a submission can't bloat the
109103 // manifest drive. ~200KB is generous for a 64x64 SVG/PNG data URI.
110104 if (typeof input.iconData === 'string' && input.iconData && input.iconData.length <= 200000) {
backend/constants.js +4 -2
……@@ -43,7 +43,9 @@ const CMD_LIST_SITES = 24
4343const CMD_DELETE_SITE = 25
4444const CMD_LOAD_TEMPLATE = 26
4545const CMD_GET_SITE_BLOCKS = 27
46const CMD_LAUNCH_PEAR_LINK = 28
46// Records a legacy app identifier and returns non-executable migration guidance.
47// This slot deliberately replaces the old remote Pear-link launcher.
48const CMD_LEGACY_APP_MIGRATION = 28
4749const CMD_RESET_APP = 29
4850const CMD_CLEAR_CACHE = 30
4951const CMD_RUN_APP_IN_TAB = 201 // run a pear-request app headless, streamed into a browser tab
……@@ -303,7 +305,7 @@ module.exports = {
303305 CMD_SHEETS_LOAD, CMD_SHEETS_LIST, CMD_SHEETS_LIST_SCHEMAS,
304306 CMD_SEARCH, CMD_SEARCH_INDEX,
305307 CMD_CREATE_SITE, CMD_UPDATE_SITE, CMD_PUBLISH_SITE,
306 CMD_UNPUBLISH_SITE, CMD_LIST_SITES, CMD_DELETE_SITE, CMD_LOAD_TEMPLATE, CMD_GET_SITE_BLOCKS, CMD_LAUNCH_PEAR_LINK, CMD_RESET_APP,
308 CMD_UNPUBLISH_SITE, CMD_LIST_SITES, CMD_DELETE_SITE, CMD_LOAD_TEMPLATE, CMD_GET_SITE_BLOCKS, CMD_LEGACY_APP_MIGRATION, CMD_RESET_APP,
307309 CMD_CLEAR_CACHE, CMD_RUN_APP_IN_TAB, CMD_GET_IDENTITY, CMD_GET_APP_ICON, CMD_SET_SITE_ICON,
308310 CMD_GET_RELAYS, CMD_SET_RELAYS, CMD_SET_RELAY_ENABLED, CMD_CHECK_RELAY_CAPABILITY,
309311 CMD_USERDATA_LIST_BOOKMARKS, CMD_USERDATA_ADD_BOOKMARK, CMD_USERDATA_REMOVE_BOOKMARK,
backend/index.js +8 -80
……@@ -1272,93 +1272,21 @@ rpc.handle(C.CMD_RESET_APP, async () => {
12721272 return report
12731273})
12741274
1275rpc.handle(C.CMD_LAUNCH_PEAR_LINK, async (data) => {
1276 const link = String(data?.link || '').trim()
1277 if (!link) throw new Error('legacy app link required')
1278 if (!/^pear:\/\/.+/.test(link) && !/^file:\/\/.+/.test(link)) {
1279 throw new Error('Only legacy pear:// and file:// app links can be assessed')
1275rpc.handle(C.CMD_LEGACY_APP_MIGRATION, async (data) => {
1276 const legacyMigrationId = String(data?.legacyMigrationId || '').trim().toLowerCase()
1277 if (!/^[13-9a-km-uw-z]{52}$/.test(legacyMigrationId)) {
1278 throw new Error('legacy migration id must be a 52-character z-base-32 identifier')
12801279 }
12811280 return {
1282 started: false,
12831281 action: 'legacy-migration-required',
1284 link,
1282 legacyMigrationId,
12851283 message: 'This legacy Pear app cannot be launched by PearBrowser v3. Install a verified native v3 package when its publisher provides one.'
12861284 }
12871285})
12881286
1289// Retained only as a compatibility seam for callers compiled against older
1290// builds. A remote link is not a v3 worker entrypoint and must never execute.
1291async function launchPearLinkWithProgress (link, keyHex) {
1292 const emit = (p) => { try { rpc.event(C.EVT_LAUNCH_PROGRESS, { key: keyHex, link, ...p }) } catch {} }
1293 const spawn = () => {
1294 throw new Error('legacy app migration required; remote links are not v3 worker entrypoints')
1295 }
1296
1297 // No drive to pre-fetch (file:// or unknown key) → straight to launch.
1298 if (!link.startsWith('pear://') || !keyHex) {
1299 emit({ phase: 'launching', percent: 100, downloaded: 0, total: 0, peers: 0 })
1300 spawn()
1301 emit({ phase: 'done', percent: 100 })
1302 return
1303 }
1304
1305 emit({ phase: 'connecting', downloaded: 0, total: 0, percent: 0, peers: 0 })
1306
1307 let drive
1308 try {
1309 drive = await getDriveForProxy(keyHex)
1310 registerHiveRelayDrive(keyHex, drive)
1311 await updateDriveBestEffort(drive)
1312 } catch {
1313 // Do not fall back to a remote launcher; surface the migration boundary.
1314 emit({ phase: 'launching', percent: 100 })
1315 spawn()
1316 emit({ phase: 'done', percent: 100 })
1317 return
1318 }
1319
1320 let blobs = null
1321 try { blobs = await drive.getBlobs() } catch {}
1322 let downloaded = 0
1323 const onDl = (_i, byteLength) => { downloaded += (byteLength || 0) }
1324 try { drive.core && drive.core.on('download', onDl) } catch {}
1325 try { blobs && blobs.core && blobs.core.on('download', onDl) } catch {}
1326 const totalNow = () => (drive.core && drive.core.byteLength || 0) + (blobs && blobs.core && blobs.core.byteLength || 0)
1327 const peersNow = () => { try { return getDrivePeerSnapshot(drive).peerCount } catch { return 0 } }
1328
1329 let dl = null
1330 try { dl = drive.download('/') } catch {}
1331
1332 let finished = false
1333 const timer = setInterval(() => {
1334 if (finished) return
1335 const total = totalNow()
1336 const peers = peersNow()
1337 const percent = total > 0 ? Math.min(99, Math.round((downloaded / total) * 100)) : 0
1338 emit({ phase: peers > 0 ? 'downloading' : 'connecting', downloaded, total, percent, peers })
1339 }, 300)
1340
1341 try {
1342 const waitDl = (dl && typeof dl.done === 'function') ? dl.done()
1343 : (dl && typeof dl.then === 'function') ? dl
1344 : Promise.resolve()
1345 await Promise.race([waitDl, new Promise((r) => setTimeout(r, 10 * 60 * 1000))])
1346 } catch {}
1347
1348 finished = true
1349 clearInterval(timer)
1350 try { drive.core && drive.core.removeListener('download', onDl) } catch {}
1351 try { blobs && blobs.core && blobs.core.removeListener('download', onDl) } catch {}
1352
1353 const total = totalNow()
1354 emit({ phase: 'launching', downloaded: total, total, percent: 100, peers: peersNow() })
1355 spawn()
1356 emit({ phase: 'done', downloaded: total, total, percent: 100, peers: peersNow() })
1357}
1358
1359// Run a pear-request app HEADLESS, streamed into a browser tab (the in-tab
1360// sibling of CMD_LAUNCH_PEAR_LINK's window spawn). Returns the wrapper URL the
1361// UI opens in a Browse tab. 'demo' runs the in-process demo router.
1287// Runs only the in-process demo router headlessly in a browser tab. Remote
1288// application workers require a signed native v3 package and are never fetched
1289// or executed from a browser catalog.
13621290rpc.handle(C.CMD_RUN_APP_IN_TAB, async (data) => {
13631291 const link = String(data?.link || 'demo').trim()
13641292 if (!tabRuntime) throw new Error('tab runtime is not available')
backend/name-aliases.cjs +16 -11
……@@ -7,29 +7,34 @@
77// overridden, never displacing the key. Pure data + lookup (Node-testable).
88const { normalize } = require('./name-normalize.cjs')
99
10// name → launch target. `link` is a pear://… (or hyper://…) the nav layer can
11// open directly; `label` is the display name. Multiple names may point at one
12// target (e.g. 'pass' → PearPass).
10// Name → target. Browsable Hyper sites supply `link`; retired native apps carry
11// an opaque `legacyMigrationId`, which can only produce migration guidance.
1312const NAME_ALIASES = [
14 { name: 'keet', label: 'Keet', link: 'pear://oeeoz3w6fjjt7bym3ndpa6hhicm8f8naxyk11z4iypeoupn6jzpo' },
15 { name: 'pearpass', label: 'PearPass', link: 'pear://tywsat7gz8m65ejx4zjn3773pbdc4j8m66tukis8dgzekraymtzo' },
16 { name: 'pass', label: 'PearPass', link: 'pear://tywsat7gz8m65ejx4zjn3773pbdc4j8m66tukis8dgzekraymtzo' },
17 { name: 'peercord', label: 'Peercord', link: 'pear://wmir47w7mai3b1skj66mx7fzso6k6o91kipaney7gtt69npimouy' },
13 { name: 'keet', label: 'Keet', legacyMigrationId: 'oeeoz3w6fjjt7bym3ndpa6hhicm8f8naxyk11z4iypeoupn6jzpo' },
14 { name: 'pearpass', label: 'PearPass', legacyMigrationId: 'tywsat7gz8m65ejx4zjn3773pbdc4j8m66tukis8dgzekraymtzo' },
15 { name: 'pass', label: 'PearPass', legacyMigrationId: 'tywsat7gz8m65ejx4zjn3773pbdc4j8m66tukis8dgzekraymtzo' },
16 { name: 'peercord', label: 'Peercord', legacyMigrationId: 'wmir47w7mai3b1skj66mx7fzso6k6o91kipaney7gtt69npimouy' },
1817 { name: 'peerit', label: 'peerit', link: 'hyper://ec6e2d6d9d22b9d6b40e11a9ca3042be3197e4bdca9e9a7f079be6ee830761b4/' },
19 { name: 'hiveworm', label: 'HiveWorm', link: 'pear://d1xbkcpcbi1xa8dexp49rsendra5r67w3qh5a9k8t44oemm4k16y' },
20 { name: 'anongpt', label: 'anonGPT', link: 'pear://rpzh3fsgg38kfir9nmae7x3o8ubofddzzixr5js4mxd6a6drb6wo' }
18 { name: 'hiveworm', label: 'HiveWorm', legacyMigrationId: 'd1xbkcpcbi1xa8dexp49rsendra5r67w3qh5a9k8t44oemm4k16y' },
19 { name: 'anongpt', label: 'anonGPT', legacyMigrationId: 'rpzh3fsgg38kfir9nmae7x3o8ubofddzzixr5js4mxd6a6drb6wo' }
2120]
2221
2322// Pre-normalize once so lookups are O(1) and canonical.
2423const _byNorm = new Map()
2524for (const a of NAME_ALIASES) _byNorm.set(normalize(a.name), a)
2625
27// Resolve a curated alias. Returns { name, label, link, provenance:'curated' } or null.
26// Resolve a curated alias. Returns a browsable target or migration record.
2827function lookupAlias (name) {
2928 const n = normalize(name)
3029 if (!n) return null
3130 const hit = _byNorm.get(n)
32 return hit ? { name: hit.name, label: hit.label, link: hit.link, provenance: 'curated' } : null
31 return hit ? {
32 name: hit.name,
33 label: hit.label,
34 ...(hit.link ? { link: hit.link } : {}),
35 ...(hit.legacyMigrationId ? { legacyMigrationId: hit.legacyMigrationId } : {}),
36 provenance: 'curated'
37 } : null
3338}
3439
3540module.exports = { NAME_ALIASES, lookupAlias }
backend/resolve-name.cjs +2 -2
……@@ -17,7 +17,7 @@ const { targetToResolution } = require('./name-registry-ops.cjs')
1717 * @param {object} ctx.registry { [normalizedName]: { target, owner?, version?, label? } }
1818 * the multi-writer N5 name registry (Tier 2)
1919 * @param {boolean} ctx.aliases include the curated bootstrap floor (default true)
20 * @returns {null | { name, key, link, label, provenance }} provenance ∈
20 * @returns {null | { name, key, link, legacyMigrationId, label, provenance }} provenance ∈
2121 * 'petname' | 'registry' | 'curated' (later: 'contact').
2222 */
2323function resolveName (rawName, { petnames = {}, registry = {}, aliases = true } = {}) {
……@@ -48,7 +48,7 @@ function resolveName (rawName, { petnames = {}, registry = {}, aliases = true }
4848 if (a) {
4949 const key = a.key || null
5050 const link = a.link || null
51 return { name: n, key, link, target: link || key, label: a.label, provenance: 'curated' }
51 return { name: n, key, link, legacyMigrationId: a.legacyMigrationId || null, target: link || key || null, label: a.label, provenance: 'curated' }
5252 }
5353 }
5454
test/community-submit.test.js +7 -12
……@@ -47,17 +47,14 @@ test('deriveKeyAndLink: bare 64-hex key → hyper link', () => {
4747 assert.equal(r.kind, 'hyper')
4848})
4949
50test('deriveKeyAndLink: pear:// link keeps the pear link + z32-decodes the key', () => {
50test('deriveKeyAndLink: remote executable app link is rejected', () => {
5151 const r = deriveKeyAndLink('pear://' + Z32, fakeNormalize)
52 assert.equal(r.error, undefined)
53 assert.equal(r.driveKey, HEX)
54 assert.equal(r.link, 'pear://' + Z32)
55 assert.equal(r.kind, 'pear')
52 assert.match(r.error, /not accepted/i)
5653})
5754
58test('deriveKeyAndLink: strips path/query and trailing slashes from pear links', () => {
55test('deriveKeyAndLink: rejects remote executable links with paths and queries', () => {
5956 const r = deriveKeyAndLink('pear://' + Z32 + '/foo?x=1', fakeNormalize)
60 assert.equal(r.driveKey, HEX)
57 assert.match(r.error, /not accepted/i)
6158})
6259
6360test('deriveKeyAndLink: rejects junk + empty', () => {
……@@ -88,14 +85,12 @@ test('buildSubmissionManifest: full hyper submission', () => {
8885 assert.equal(manifest.iconData, 'data:image/svg+xml,<svg/>')
8986})
9087
91test('buildSubmissionManifest: pear submission is type standalone + keeps pearLink', () => {
92 const { manifest } = buildSubmissionManifest(
88test('buildSubmissionManifest: remote executable app submission is rejected', () => {
89 const { error } = buildSubmissionManifest(
9390 { name: 'Pear Thing', link: 'pear://' + Z32 },
9491 { now: 5, normalizeKey: fakeNormalize }
9592 )
96 assert.equal(manifest.type, 'standalone')
97 assert.equal(manifest.pearLink, 'pear://' + Z32)
98 assert.equal(manifest.driveKey, HEX)
93 assert.match(error, /not accepted/i)
9994})
10095
10196test('buildSubmissionManifest: name required + bad key rejected', () => {
test/resolve-name.test.js +7 -6
……@@ -11,15 +11,16 @@ const KEY = '0c35d12fd9b1115dd2d1fb1cd1751817c9173d3196ac7c62ae37d023340dcb75'
1111test('bootstrap floor: a bare word resolves on day one (curated)', () => {
1212 const r = resolveName('keet', { petnames: {} })
1313 assert.equal(r.provenance, 'curated')
14 assert.match(r.link, /^pear:\/\//)
14 assert.equal(r.link, null)
15 assert.match(r.legacyMigrationId, /^[13-9a-km-uw-z]{52}$/)
1516 assert.equal(r.label, 'Keet')
1617})
1718
1819test('curated lookup is normalized + supports alias names', () => {
1920 assert.equal(resolveName('KEET', { petnames: {} }).provenance, 'curated') // case-folded
2021 assert.equal(lookupAlias('pass').label, 'PearPass') // alias → PearPass
21 assert.equal(lookupAlias('peercord').link, 'pear://wmir47w7mai3b1skj66mx7fzso6k6o91kipaney7gtt69npimouy')
22 // peerit is a hyper:// site (not a pear:// app) — the alias layer carries it too
22 assert.match(lookupAlias('peercord').legacyMigrationId, /^[13-9a-km-uw-z]{52}$/)
23 // peerit is a browsable hyper:// site — the alias layer carries it too
2324 assert.equal(lookupAlias('peerit').link, 'hyper://ec6e2d6d9d22b9d6b40e11a9ca3042be3197e4bdca9e9a7f079be6ee830761b4/')
2425})
2526
……@@ -48,7 +49,7 @@ test('miss → null; aliases:false disables the floor', () => {
4849// --- Tier 2: the N5 name registry --------------------------------------------
4950
5051const TKEY = 'aa'.repeat(32)
51const TLINK = 'pear://link-only-app'
52const TLINK = `hyper://${TKEY}/link-only-app`
5253const HYPER_TLINK = `hyper://${TKEY}/app`
5354
5455test('registry tier resolves a claimed name (target → key, provenance registry)', () => {
……@@ -61,11 +62,11 @@ test('registry tier resolves a claimed name (target → key, provenance registry
6162 assert.equal(r.label, 'alice')
6263})
6364
64test('registry tier resolves a link-only app target as link', () => {
65test('registry tier resolves a link-only Hyper target as link', () => {
6566 const registry = { keet: { target: TLINK, owner: 'bb'.repeat(32), version: 1, label: 'Keet via registry' } }
6667 const r = resolveName('keet', { petnames: {}, registry })
6768 assert.equal(r.provenance, 'registry')
68 assert.equal(r.key, null)
69 assert.equal(r.key, TKEY)
6970 assert.equal(r.link, TLINK)
7071 assert.equal(r.target, TLINK)
7172 assert.equal(r.label, 'Keet via registry')
ui/boot.js +1 -1
……@@ -53,7 +53,7 @@ const C = {
5353 CMD_DELETE_SITE: 25,
5454 CMD_LOAD_TEMPLATE: 26,
5555 CMD_GET_SITE_BLOCKS: 27,
56 CMD_LAUNCH_PEAR_LINK: 28,
56 CMD_LEGACY_APP_MIGRATION: 28,
5757 CMD_RUN_APP_IN_TAB: 201,
5858 CMD_RESET_APP: 29,
5959 CMD_CLEAR_CACHE: 30,
ui/shell.js +94 -297
……@@ -60,92 +60,63 @@ function applyAppearanceTheme (theme) {
6060
6161applyAppearanceTheme(readCachedAppearanceTheme())
6262
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.
78const 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.
82const 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.
9266
9367const FEATURED_APPS = [
9468 {
9569 id: 'keet',
9670 name: 'Keet',
97 type: 'standalone',
71 nativeDelivery: { status: 'migration-required' },
9872 tagline: 'End-to-end encrypted P2P chat, voice, and video calls by Holepunch.',
99 link: 'pear://oeeoz3w6fjjt7bym3ndpa6hhicm8f8naxyk11z4iypeoupn6jzpo',
73 legacyMigrationId: 'oeeoz3w6fjjt7bym3ndpa6hhicm8f8naxyk11z4iypeoupn6jzpo',
10074 initial: 'K',
10175 gradient: 'linear-gradient(135deg, #fbbf24, #f97316)'
10276 },
10377 {
10478 id: 'pearpass',
10579 name: 'PearPass',
106 type: 'standalone',
80 nativeDelivery: { status: 'migration-required' },
10781 tagline: 'Peer-to-peer password manager from Tether — synced across devices without a cloud.',
108 link: 'pear://tywsat7gz8m65ejx4zjn3773pbdc4j8m66tukis8dgzekraymtzo',
82 legacyMigrationId: 'tywsat7gz8m65ejx4zjn3773pbdc4j8m66tukis8dgzekraymtzo',
10983 initial: 'P',
11084 gradient: 'linear-gradient(135deg, #3fb950, #58a6ff)'
11185 },
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.
11387 // (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.)
11589 {
11690 id: 'anongpt',
11791 name: 'anonGPT',
118 type: 'standalone',
92 nativeDelivery: { status: 'migration-required' },
11993 tagline: 'Private P2P AI chat — pay-per-inference from a HiveMind seller, with signed receipts.',
120 link: 'pear://rpzh3fsgg38kfir9nmae7x3o8ubofddzzixr5js4mxd6a6drb6wo',
94 legacyMigrationId: 'rpzh3fsgg38kfir9nmae7x3o8ubofddzzixr5js4mxd6a6drb6wo',
12195 initial: 'A',
12296 gradient: 'linear-gradient(135deg, #22d3ee, #6366f1)'
12397 },
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
126100 // 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.
130103 {
131104 id: 'pearpaste',
132105 name: 'Paste',
133 type: 'standalone',
106 nativeDelivery: { status: 'migration-required' },
134107 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',
136109 initial: '📋',
137110 gradient: 'linear-gradient(135deg, #4ade80, #22d3ee)'
138111 },
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.
143114 {
144115 id: 'peercord',
145116 name: 'Peercord',
146 type: 'standalone',
117 nativeDelivery: { status: 'migration-required' },
147118 tagline: 'Decentralized Discord-style chat with text, voice, video, screen sharing, and P2P file transfer.',
148 link: PEERCORD_LINK,
119 legacyMigrationId: 'wmir47w7mai3b1skj66mx7fzso6k6o91kipaney7gtt69npimouy',
149120 initial: 'P',
150121 gradient: 'linear-gradient(135deg, #5865f2, #22d3ee)'
151122 }
……@@ -1306,23 +1277,19 @@ function Browse ({ rpc, C, navUrl, onNavigated, tabs, setTabs, activeId, setActi
13061277 if (nameQuery) {
13071278 try {
13081279 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 }
13091290 if (resolved && (resolved.link || resolved.key)) {
13101291 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.
13261293 target = link
13271294 prov = { provenance: resolved.provenance, label: resolved.label || nameQuery, name: nameQuery, source: resolved.source || null }
13281295 }
……@@ -2145,8 +2112,8 @@ const ONBOARDING_FIRST_SITES = [
21452112 {
21462113 id: 'hiveworm',
21472114 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',
21502117 initial: '🐛',
21512118 gradient: 'linear-gradient(135deg, #a371f7, #d946ef)'
21522119 },
……@@ -2171,13 +2138,19 @@ const ONBOARDING_FIRST_SITES = [
21712138function Onboarding ({ rpc, C, onPickSite, onClose }) {
21722139 const [slide, setSlide] = useState(0)
21732140
2174 const finish = async (pickedUrl) => {
2141 const finish = async (pickedSite) => {
21752142 // Persist the flag so we never ask again — fire-and-forget so we
21762143 // don't block the close on a slow user-data write.
21772144 rpc.request(C.CMD_USERDATA_SET_SETTINGS, {
21782145 updates: { onboardingDone: true, onboardingDoneAt: Date.now() }
21792146 }).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 }
21812154 onClose()
21822155 }
21832156
……@@ -2212,8 +2185,8 @@ function Onboarding ({ rpc, C, onPickSite, onClose }) {
22122185 </div>
22132186 <div className="onb-pitch">
22142187 <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>
22172190 </div>
22182191 <div className="onb-pitch">
22192192 <div className="onb-pitch-icon">✒️</div>
……@@ -2241,8 +2214,8 @@ function Onboarding ({ rpc, C, onPickSite, onClose }) {
22412214 <button
22422215 className="onb-site-card"
22432216 key=${s.id}
2244 onClick=${() => finish(s.url)}
2245 title=${s.url}
2217 onClick=${() => finish(s)}
2218 title=${s.url || 'Legacy native app'}
22462219 >
22472220 <div className="onb-site-icon" style=${{ background: s.gradient }}>${s.initial}</div>
22482221 <div className="onb-site-text">
……@@ -2395,10 +2368,12 @@ function CollaborativeCatalog ({ rpc, C }) {
23952368 const addApp = async () => {
23962369 const val = appKey.trim()
23972370 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 }
24022377 setErr(''); setBusy('addapp')
24032378 try {
24042379 const res = await rpc.request(C.CMD_AUTOBEE_ADD_APP, { keyHex: cat.keyHex, app }, 60000)
……@@ -2480,7 +2455,7 @@ function CollaborativeCatalog ({ rpc, C }) {
24802455 <div className="profile-field">
24812456 <div className="settings-label">Add an app</div>
24822457 <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()} />
24842459 </div>
24852460 <button className="btn primary" onClick=${addApp} disabled=${busy === 'addapp' || !appKey.trim()}>${busy === 'addapp' ? 'Adding…' : 'Add app'}</button>
24862461 </div>
……@@ -2525,7 +2500,8 @@ function CommunitySubmit ({ rpc, C }) {
25252500 const submit = async () => {
25262501 setErr(''); setOk('')
25272502 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 }
25292505 setBusy(true)
25302506 try {
25312507 const res = await rpc.request(C.CMD_SUBMIT_APP, {
……@@ -2540,7 +2516,7 @@ function CommunitySubmit ({ rpc, C }) {
25402516 return html`
25412517 <div className="community-submit">
25422518 <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>
25442520 <div className="settings-card">
25452521 ${err && html`<div className="apps-error">${err}</div>`}
25462522 ${ok && html`<div className="apps-ok">${ok}</div>`}
……@@ -2553,7 +2529,7 @@ function CommunitySubmit ({ rpc, C }) {
25532529 <div className="settings-row">
25542530 <div className="profile-field">
25552531 <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()} />
25572533 </div>
25582534 </div>
25592535 <div className="settings-row">
……@@ -2717,7 +2693,9 @@ function appStableDedupeKey (app) {
27172693 const link = normalizeAppLinkForKey(app.link)
27182694 const hyperKey = /^hyper:\/\//i.test(link) ? driveKeyFromHyperRef(link) : ''
27192695 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
27212699 const id = String(app.id || '').trim()
27222700 return id ? 'id:' + id : ''
27232701}
……@@ -2750,20 +2728,14 @@ function dedupeApps (list) {
27502728 return [...byKey.values(), ...anon]
27512729}
27522730
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.
27562733function 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 }
27612734 if (app && /^[0-9a-f]{64}$/i.test(app.driveKey || '')) return app.driveKey.toLowerCase()
27622735 return null
27632736}
27642737
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.
27672739function AppMeta ({ rpc, C, app }) {
27682740 const bundleKey = appBundleKey(app)
27692741 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 }) {
27962768
27972769// Inline download-progress bar shown in place of a card's Run-app button while
27982770// the bundle is being pulled (driven by EVT_LAUNCH_PROGRESS).
2799function 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
2821function 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
2831function normalizeStandaloneWarningsSeen (value) {
2832 return value && typeof value === 'object' && !Array.isArray(value) ? value : {}
2833}
2834
2835function 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}
28582771
28592772function Apps ({ rpc, C, onLaunch }) {
28602773 const [catalogKey, setCatalogKey] = useState('')
……@@ -2887,125 +2800,34 @@ function Apps ({ rpc, C, onLaunch }) {
28872800 const [busy, setBusy] = useState(null)
28882801 const [err, setErr] = useState('')
28892802 const [autoLoadAttempted, setAutoLoadAttempted] = useState(false)
2890 const [pearLink, setPearLink] = useState('')
28912803 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
29072809 }
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('')
29182811 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.')
29282814 } catch (e) {
2929 setErr(`launch: ${e.message}`)
2930 if (keyHex) setLaunchProg((p) => ({ ...p, [keyHex]: { phase: 'error', error: e.message } }))
2815 setErr(`migration: ${e.message}`)
29312816 } finally {
29322817 setBusy(null)
29332818 }
29342819 }
29352820
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.
30022824 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)
30072827 return
30082828 }
2829 const link = (app.link || '').trim()
2830 if (!link) return
30092831 if (link.startsWith('hyper://') || link.startsWith('http://') || link.startsWith('https://')) {
30102832 // onLaunch is the App-level helper that does setNavUrl(url) +
30112833 // setTab('browse'). Browse opens this in a new browser-tab if
……@@ -3325,7 +3147,6 @@ function Apps ({ rpc, C, onLaunch }) {
33253147 ;(async () => {
33263148 try {
33273149 const settings = unwrapSettings(await rpc.request(C.CMD_USERDATA_GET_SETTINGS))
3328 setStandaloneWarningsSeen(normalizeStandaloneWarningsSeen(settings?.standaloneLaunchWarningsSeen))
33293150 const recentRaw = Array.isArray(settings?.recentCatalogs) ? settings.recentCatalogs : []
33303151 // Back-compat: older builds persisted only a single lastCatalogKey.
33313152 const last = settings?.lastCatalogKey
……@@ -3394,7 +3215,6 @@ function Apps ({ rpc, C, onLaunch }) {
33943215 } catch {
33953216 // user-data not ready yet — first-launch / boot races. The user
33963217 // can still paste a key by hand below.
3397 setStandaloneWarningsSeen({})
33983218 } finally {
33993219 setAutoLoadAttempted(true)
34003220 }
……@@ -3451,10 +3271,8 @@ function Apps ({ rpc, C, onLaunch }) {
34513271 const filteredApps = useMemo(() => {
34523272 const q = query.normalize('NFKC').trim().toLowerCase()
34533273 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
34583276 if (source !== 'all' && a.catalogKey !== source) return false
34593277 if (category !== 'all' && !appCategories(a).includes(category)) return false
34603278 if (!q) return true
……@@ -3465,7 +3283,7 @@ function Apps ({ rpc, C, onLaunch }) {
34653283 }, [apps, query, category, source])
34663284
34673285 // 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])
34693287
34703288 const renderMyCatalogApp = (app) => {
34713289 const savedId = app.id || app.driveKey || app.name || 'untitled'
……@@ -3487,8 +3305,7 @@ function Apps ({ rpc, C, onLaunch }) {
34873305 <label>
34883306 Type <span style=${{ opacity: 0.6, fontWeight: 'normal' }}>(how it launches — required)</span>
34893307 <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>
34923309 </select>
34933310 </label>
34943311 <label>
……@@ -3551,7 +3368,7 @@ function Apps ({ rpc, C, onLaunch }) {
35513368 return html`
35523369 <div className="apps">
35533370 <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>
35553372
35563373 <h2>Featured</h2>
35573374 <div className="app-grid">
……@@ -3561,30 +3378,20 @@ function Apps ({ rpc, C, onLaunch }) {
35613378 <div className="app-info">
35623379 <div className="app-name">${app.name}</div>
35633380 <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>
35653382 </div>
35663383 <div className="app-actions">
35673384 ${app.type === 'hypersite'
35683385 ? 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>`}
35703387 </div>
35713388 </div>
35723389 `)}
35733390 </div>
35743391
3575 <h2>Launch a Pear app</h2>
3392 <h2>Legacy native apps</h2>
35763393 <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>
35883395 </div>
35893396 ${launched && html`<div className="apps-ok">${launched}</div>`}
35903397
……@@ -3680,25 +3487,22 @@ function Apps ({ rpc, C, onLaunch }) {
36803487 <div className="app-desc">${app.description || ''}</div>
36813488 <div className="app-meta">
36823489 ${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>` : '')}
36843493 </div>
36853494 ${app.catalogName && html`<div className="app-source-tag">${app.catalogName}</div>`}
36863495 <${AppMeta} rpc=${rpc} C=${C} app=${app} />
36873496 </div>
36883497 <div className="app-actions">
36893498 ${(() => {
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 }
36953499 return html`
36963500 ${app.driveKey && /^[0-9a-f]{64}$/i.test(app.driveKey)
36973501 ? html`<button key="open-page" className="btn subtle" onClick=${() => openSite(app)} title="Open this app's P2P page in a tab">Open page</button>`
36983502 : ''}
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>`}
37023506 ${canEditMyCatalog && app.catalogKey !== myCatalog.keyHex && !inMyCatalog([app.id, app.driveKey, app.link]) && html`
37033507 <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>
37043508 `}
……@@ -3810,13 +3614,6 @@ function Apps ({ rpc, C, onLaunch }) {
38103614
38113615 <${ModeratorPanel} rpc=${rpc} C=${C} />
38123616
3813 ${pendingStandaloneLaunch && html`<${StandalonePrelaunchWarning}
3814 pending=${pendingStandaloneLaunch}
3815 onCancel=${() => setPendingStandaloneLaunch(null)}
3816 onConfirm=${confirmStandaloneLaunch}
3817 busy=${busy}
3818 />`}
3819
38203617 ${detailApp && html`
38213618 <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' }}>
38223619 <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' }}>